- AI & Agents
- Common Pitfalls
Common Pitfalls
AI assistants make predictable mistakes when generating Ironflow code. Learn to spot and fix them.
1. Non-Idempotent Step Code
Section titled “1. Non-Idempotent Step Code”The Problem: AI generates step code that causes duplicate operations on retry.
await step.run("create-payment", async () => { // No idempotency key - creates duplicate charges on retry! return await stripe.charges.create({ amount: order.total, currency: "usd", });});await step.run("create-payment", async () => { // Idempotency key prevents duplicate charges return await stripe.charges.create({ amount: order.total, currency: "usd", idempotencyKey: `order-${orderId}`, });});How to Fix: Always use idempotency keys when calling external APIs. Most payment processors, email services, and APIs support idempotency keys.
Tell the AI:
Ensure all external API calls inside steps use idempotency keys.For Stripe, use idempotencyKey. For other services, use appropriatededuplication mechanisms.2. Side Effects Outside Steps
Section titled “2. Side Effects Outside Steps”The Problem: AI puts side effects (API calls, database queries) outside step.run, causing them to execute on every replay.
async ({ event, step }) => { // BAD: This runs on EVERY replay, not just once const user = await db.users.find(event.data.userId);
await step.run("process", async () => { return processUser(user); });};async ({ event, step }) => { // GOOD: Database call is memoized const user = await step.run("fetch-user", async () => { return await db.users.find(event.data.userId); });
await step.run("process", async () => { return processUser(user); });};How to Fix: Every external call (database, API, file system) must be inside a step.run.
Tell the AI:
All side effects (database queries, API calls, file operations) must beinside step.run. The function body outside steps should only containcontrol flow logic using data returned from steps.3. Duplicate Step IDs
Section titled “3. Duplicate Step IDs”The Problem: AI reuses step IDs, especially in loops.
for (const item of items) { // BAD: Same step ID for every item - only first executes await step.run("process-item", async () => { return processItem(item); });}// GOOD: Use step.map for arraysconst results = await step.map( "process-items", items, async (item, s, index) => { return await s.run(`process-${index}`, async () => { return processItem(item); }); },);
// OR if manual loop is needed, use unique IDsfor (let i = 0; i < items.length; i++) { await step.run(`process-item-${i}`, async () => { return processItem(items[i]); });}How to Fix: Use step.map for arrays. If using loops, include a unique identifier in the step ID.
Tell the AI:
Each step needs a unique ID. For arrays, use step.map instead of loops.If you must use loops, include the index or item ID in the step name.4. Missing Error Handling
Section titled “4. Missing Error Handling”The Problem: AI doesn’t distinguish between retryable and non-retryable errors.
await step.run("validate", async () => { if (!isValidEmail(email)) { // BAD: Will retry forever, but email won't become valid throw new Error("Invalid email"); }});import { NonRetryableError } from "@ironflow/core";
await step.run("validate", async () => { if (!isValidEmail(email)) { // GOOD: Stops immediately, won't waste retries throw new NonRetryableError("Invalid email format"); }});How to Fix: Use NonRetryableError for validation failures and permanent errors. Let transient errors (network, timeout) retry normally.
Tell the AI:
Use NonRetryableError for:- Validation failures- Invalid input- Business rule violations- Resource not found- Permission denied
Let these retry normally:- Network timeouts- Database connection errors- Rate limiting (with backoff)5. Misunderstanding waitForEvent Timeout
Section titled “5. Misunderstanding waitForEvent Timeout”The Problem: AI treats waitForEvent timeout as something the workflow can catch and recover from inline. It cannot — the SDK return type is non-nullable (Promise<IronflowEvent<T>>), and the engine transitions the step to timed_out and fails the run with "waitForEvent timed out" (timeoutStep in internal/engine/scheduler.go). The code after the wait never runs.
Do not reach for try/catch either. waitForEvent suspends the run by throwing an internal YieldSignal that the SDK catches at the handler boundary (isYieldSignal in sdk/js/node/src/serve.ts). A user catch — or a chained .catch() — swallows that signal, so the run does not suspend at all: it returns as if it had finished and the wait silently never happens. The same applies to sleep, sleepUntil, invoke and invokeAsync. Only step.run throws ordinary catchable errors, because it invokes your callback inline. (In the Go SDK the yield is a panic, so a stray recover() does the same damage.)
// BAD: null check never fires — timeout fails the run insteadconst approval = await step.waitForEvent("wait-approval", { event: "order.approved", match: "data.orderId", timeout: "24h",});
if (!approval) { await step.run("cancel", async () => cancelOrder(orderId)); return { status: "cancelled" };}
// ALSO BAD: the catch eats the YieldSignal — the run completes without waitingtry { const approval = await step.waitForEvent("wait-approval", { ... });} catch { return { status: "cancelled" };}// GOOD: model the deadline as its own event, and arm it in a SEPARATE run so// this handler can sit on a single wait. invokeAsync returns immediately with a// runId; the child sleeps 24h and then emits order.settled with an "expired"// outcome. The approval path emits the same event with an "approved" outcome.await step.invokeAsync("order-approval-deadline", { orderId });
const settled = await step.waitForEvent<{ outcome: "approved" | "expired" }>( "await-settlement", { event: "order.settled", // emitted by the approval path OR the deadline path match: "data.orderId", timeout: "48h", // hard ceiling — the run FAILS if this fires },);
await step.run("process", async () => processSettlement(settled.data));Arming the deadline with step.sleep in this handler does not work: the sleep
suspends this run, so the wait below cannot start until the deadline has already
elapsed. The two must be different runs to race.
How to Fix: Pick the model that matches the business requirement:
- Hard deadline is acceptable — let
waitForEventtime out. The run fails with"waitForEvent timed out"; an external listener onsystem.run.*.failedtriggers the recovery flow. - Soft deadline needs inline branching — arm a fallback event from a separate run (
step.invokeAsyncto a deadline function, or a sibling cron function) so thewaitForEventmatches either the real event or the timeout event. The workflow keeps running on a single, well-typed result. Do not usestep.sleepin the waiting handler for this — it suspends the very run that needs to be waiting.
Tell the AI:
waitForEvent does NOT return null on timeout. The return type isPromise<IronflowEvent<T>>, and a timeout marks the step "timed_out"and fails the run.
Never wrap waitForEvent (or sleep, sleepUntil, invoke, invokeAsync)in try/catch or a .catch() chain. They suspend the run by throwing aninternal YieldSignal; catching it makes the run complete without everwaiting. Only step.run throws catchable errors.
If the workflow needs to handle the timeout case inline, model thetimeout as its own event: arm a delayed emit in a SEPARATE run(step.invokeAsync to a deadline function, or a cron sibling), thenwaitForEvent on a unified "settled" event that either branchpublishes. Otherwise, let the run fail and react via a separatelistener on system.run.*.failed.6. Wrong Execution Mode
Section titled “6. Wrong Execution Mode”The Problem: AI uses push mode for long-running tasks or pull mode for quick tasks.
// BAD: Video transcoding in push mode will timeoutexport const POST = serve({ functions: [transcodeVideo], // Takes 10+ minutes});// GOOD: Long-running task uses pull modeconst transcodeVideo = ironflow.createFunction( { id: "transcode-video", mode: "pull", // Worker polls the server for jobs over HTTP triggers: [{ event: "video.uploaded" }], }, async ({ event, step }) => { // Can run for hours without timeout },);
const worker = createWorker({ serverUrl: "http://localhost:9123", functions: [transcodeVideo],});await worker.start();How to Fix:
- Push mode: Tasks under 10 seconds, serverless deployments
- Pull mode: Long-running tasks, GPU workloads, no timeout limits
Tell the AI:
Use push mode for tasks < 10 seconds (serverless).Use pull mode for:- Video/audio processing- ML inference- Large file operations- Any task that might exceed serverless timeouts7. Incorrect Event Matching
Section titled “7. Incorrect Event Matching”The Problem: AI uses a field path that is not in the payload. match is a path inside event.data. The engine strips an optional data. prefix, so match: "orderId" and match: "data.orderId" are equivalent. A key that the payload does not carry never matches. A value that differs between the triggering event and the awaited event never matches either.
// Original event: { data: { orderId: "123" } }
await step.waitForEvent("wait-payment", { event: "payment.completed", match: "id", // BAD: the payload carries "orderId", not "id" timeout: "1h",});// Original event: { data: { orderId: "123" } }
await step.waitForEvent("wait-payment", { event: "payment.completed", match: "data.orderId", // GOOD: a field the payload carries timeout: "1h",});How to Fix: Use a path to a field that exists in both events, for example data.orderId.
Tell the AI:
The match field in waitForEvent is a path inside event.data (an optional"data." prefix is accepted). Use a field that exists in both events, forexample "data.orderId".8. Mixing Async Patterns
Section titled “8. Mixing Async Patterns”The Problem: AI mixes promises incorrectly within steps.
await step.run("fetch-all", async () => { // BAD: Untracked promises - may not complete before step returns const users = fetchUsers(); const orders = fetchOrders(); return { users, orders }; // Returns promises, not values});// GOOD: Use step.parallel for concurrent operationsconst [users, orders] = await step.parallel("fetch-all", [ async (s) => s.run("fetch-users", fetchUsers), async (s) => s.run("fetch-orders", fetchOrders),]);
// OR await inside a single stepawait step.run("fetch-all", async () => { const [users, orders] = await Promise.all([fetchUsers(), fetchOrders()]); return { users, orders };});How to Fix: Either use step.parallel for tracked concurrent operations, or properly await all promises inside a step.
Tell the AI:
For concurrent operations, prefer step.parallel so each operation isindependently memoized and tracked. If using Promise.all inside a step,await the result - don't return unresolved promises.9. Hardcoded Configuration
Section titled “9. Hardcoded Configuration”The Problem: AI hardcodes URLs and configuration instead of using environment variables.
const client = createClient({ serverUrl: "http://localhost:9123", // BAD: Hardcoded});const client = createClient({ serverUrl: process.env.IRONFLOW_SERVER_URL || "http://localhost:9123",});
// Or in Goclient := ironflow.NewClient(ironflow.ClientConfig{ ServerURL: ironflow.GetServerURL(), // Reads env var})How to Fix: Use environment variables for URLs, API keys, and configuration.
Tell the AI:
Use environment variables for configuration:- IRONFLOW_SERVER_URL for server address- IRONFLOW_SIGNING_KEY for webhook verification- Never hardcode production URLs10. Ignoring Type Safety
Section titled “10. Ignoring Type Safety”The Problem: AI uses any types instead of proper TypeScript types.
const processOrder = ironflow.createFunction( { id: "process-order", triggers: [{ event: "order.placed" }] }, async ({ event, step }) => { const data = event.data; // any type - no autocomplete, no safety await step.run("process", async () => { return data.orderId; // Could crash at runtime }); },);import { z } from "zod";
const OrderEventSchema = z.object({ orderId: z.string(), customerId: z.string(), items: z.array(z.object({ productId: z.string(), quantity: z.number() })), total: z.number(),});
const processOrder = ironflow.createFunction( { id: "process-order", triggers: [{ event: "order.placed" }], schema: OrderEventSchema, }, async ({ event, step }) => { const { orderId, customerId, items, total } = event.data; // Full type safety and autocomplete — inferred from Zod schema },);How to Fix: Define Zod schemas for event data and pass them via the schema config option.
Tell the AI:
Define Zod schemas for:- Event data payloads- Step return types- Function return types
Pass the schema via createFunction({ schema: MySchema }) to get full type safety.11. Impure Managed Projections
Section titled “11. Impure Managed Projections”The Problem: AI puts side effects (database queries, external API calls, fetch) inside managed projections. Managed projections must be pure reducers.
const statsProjection = createProjection({ name: "stats", events: ["order.completed"], initialState: () => ({ total: 0 }), handler: async (state, event) => { // BAD: Side effect in a managed projection await sendSlackNotification("New order!"); return { total: state.total + event.data.amount }; }});// GOOD: Pure reducer for stateconst statsProjection = createProjection({ name: "stats", events: ["order.completed"], initialState: () => ({ total: 0 }), handler: (state, event) => { return { total: state.total + event.data.amount }; }});
// GOOD: Separate external projection for side effectsconst notificationProjection = createProjection({ name: "notifications", events: ["order.completed"], mode: "external", handler: async (event) => { await sendSlackNotification("New order!"); }});How to Fix: Use mode: "external" for projections that need to perform side effects. Managed projections must only return the new state based purely on the previous state and the event. Managed handlers must also be deterministic and must not change the state argument.
Tell the AI:
Managed projections (which return state) MUST be pure synchronous functions with NO side effects.If you need to make API calls, send emails, or write to external databases, use an external projection (mode: "external") or a function.12. Missing Expected Versions in Entity Streams
Section titled “12. Missing Expected Versions in Entity Streams”The Problem: AI appends to entity streams without using optimistic concurrency control, which can lead to race conditions when multiple workers update the same entity.
// BAD: Unconditional append can overwrite other updatesawait client.streams.append("user-123", { name: "user.updated", data: { status: "active" }, entityType: "user"});// GOOD: Use optimistic concurrencyconst info = await client.streams.getInfo("user-123");const currentVersion = info ? info.version : 0;
await client.streams.append("user-123", { name: "user.updated", data: { status: "active" }, entityType: "user"}, { expectedVersion: currentVersion });How to Fix: Always provide expectedVersion when appending to an entity stream if the append depends on previous state.
Tell the AI:
When appending events to entity streams, always use optimistic concurrency control by fetching the current version and passing it as expectedVersion in the options object.13. Missing Properties in Upcasters
Section titled “13. Missing Properties in Upcasters”The Problem: AI writes upcasters that fail to return all required properties, inadvertently deleting data during schema migrations.
registry.register("user.created", 1, 2, (data) => { // BAD: Forgot to spread the rest of the old data! // This deletes email, name, etc. return { fullName: `${data.firstName} ${data.lastName}` };});registry.register("user.created", 1, 2, (data) => { const { firstName, lastName, ...rest } = data; // GOOD: Preserves all unchanged properties return { ...rest, fullName: `${firstName} ${lastName}` };});How to Fix: Always spread the existing data payload (...data) or use destructuring to preserve un-migrated properties.
Tell the AI:
When writing event upcasters, always return ALL properties of the event data, not just the migrated fields. Use the spread operator to ensure data isn't lost during the upcast.14. Non-Deterministic Managed Reducers
Section titled “14. Non-Deterministic Managed Reducers”The Problem: AI reads the wall clock, a random ID, or an environment variable inside a managed projection. A rebuild reads the events table. The live NATS tail can call the reducer for the same event. Each call gets a different value, so the rebuilt state silently disagrees with the live state. No runtime check catches this.
// BAD: wall clockhandler: (state, event) => ({ ...state, lastSeen: Date.now() })// BAD: random IDhandler: (state, event) => ({ ...state, lineId: crypto.randomUUID(), qty: event.data.qty })// BAD: env readhandler: (state, event) => ({ ...state, region: process.env.REGION })// GOOD: derive every value from the eventhandler: (state, event) => ({ ...state, lastSeen: event.timestamp })handler: (state, event) => ({ ...state, lineId: event.data.lineId, qty: event.data.qty })How to Fix: Derive timestamps from event.timestamp and IDs from event.data. In Go, do not call time.Now(), rand.Int*, uuid.New(), os.Getenv, or read files inside a managed handler. Use event.Timestamp and event.Data. See the four-rule reducer contract.
Tell the AI:
Managed projection handlers must be deterministic. Never read the clock,random numbers, environment variables, or files inside them. Take everyvalue from the event.15. Mutating the State Argument
Section titled “15. Mutating the State Argument”The Problem: AI changes the state argument in place and returns the same object. This is an aliasing hazard. If the runner keeps the earlier reference (for progress reports, caching, or diagnostics), your change silently rewrites it. The Go SDK deep-copies state before each call, which removes the sharpest case. The JS SDK does not.
// BAD: mutates the argumenthandler: (state, event) => { state.count += 1; return state; }// BADHandler: func(state map[string]any, event ironflow.ProjectionEvent, _ ironflow.ProjectionContext) (map[string]any, error) { state["count"] = state["count"].(float64) + 1 return state, nil}// GOOD: fresh objecthandler: (state, event) => ({ ...state, count: state.count + 1 })// GOODHandler: func(state map[string]any, event ironflow.ProjectionEvent, _ ironflow.ProjectionContext) (map[string]any, error) { next := map[string]any{} for k, v := range state { next[k] = v } next["count"] = state["count"].(float64) + 1 return next, nil}How to Fix: Always return a new state object from a managed handler.
Tell the AI:
Managed projection handlers must return a new state object. Never changethe state argument and return it.16. Polling Projections from the Browser
Section titled “16. Polling Projections from the Browser”The Problem: AI re-fetches a projection on a timer, or on a raw event subscription. The projection runner is asynchronous, so the fetch can return stale data.
// BADsetInterval(() => ironflow.getProjection("name"), 2000);ironflow.subscribe("events:order.>", { onEvent: () => ironflow.getProjection("name") });// GOOD: the server pushes state on saveironflow.subscribeToProjection("name", { onUpdate: (state) => setState(state) });How to Fix: Use subscribeToProjection. Fetch the initial state once with getProjection.
Tell the AI:
Never poll getProjection and never re-fetch it on a raw event subscription.Use subscribeToProjection.17. Querying Entity Streams for Display Data
Section titled “17. Querying Entity Streams for Display Data”The Problem: AI loads entity stream history to render a list or a UI. The entity stream is the write model. Reading it for display couples the UI to the aggregate shape, and the cost grows with every entity’s history.
// BAD: the UI lists by folding entity streamsconst orders = await Promise.all(ids.map(id => client.streams.read(id)));return orders.map(foldOrder);// GOOD: a projection keyed for the view// Node: client.projections.get(...). getProjection is the BROWSER client's name.const { state } = await client.projections.get("orders-by-customer");return state[customerId];How to Fix: Entity streams enforce invariants on append. Projections serve reads. Do not cross them.
Tell the AI:
Do not read entity streams to build lists or views. Create a projectionfor the view and read the projection.18. Dual-Emitting Append and Emit
Section titled “18. Dual-Emitting Append and Emit”The Problem: AI calls streams.append and then emit for the same fact. One append already writes two outbox rows in the same transaction (internal/eventtrigger/helper.go). One row goes to the entity topic for stream subscribers. The other goes to BuildUserEventTopic(eventName), which is the exact subject a projection’s durable filters on (internal/projection/coordinator.go). A second emit publishes the fact twice. The reducer runs twice and every total double-counts.
await client.streams.append(streamId, { name: "issue.created", data, entityType: "issue" }, { expectedVersion: v });await client.emit("issue.created", data); // BAD: the projection sees it twice// GOOD: append alone is enoughawait client.streams.append(streamId, { name: "issue.created", data, entityType: "issue" }, { expectedVersion: v });How to Fix: Use emit only for facts that have no entity stream. Pick one path for each fact, never both.
If last_event_seq is 0 while the stream holds events, the cause is a failed outbox drain, not a missing emit. The events-namespace row never published. Check the outbox, not the append site:
ironflow outbox dlq list --env <env> --jsonironflow outbox dlq requeue <event-id> --env <env>Then check that the projection’s events array lists the event name. An unlisted name is ignored and looks the same from outside.
Tell the AI:
streams.append already delivers the event to projections. Do not alsocall emit for the same fact.19. Missing recording: true
Section titled “19. Missing recording: true”The Problem: AI omits recording: true from the function config. Without it, ironflow inspect --at and the timeline view refuse to run. The plain step list still works.
{ id: "fn", triggers: [{ event: "order.placed" }] } // BAD: no time-travel debugging{ id: "fn", triggers: [{ event: "order.placed" }], recording: true } // GOODHow to Fix: Add recording: true to every function you may need to debug.
Tell the AI:
Set recording: true in the createFunction config.20. Slashes in Entity IDs
Section titled “20. Slashes in Entity IDs”The Problem: AI namespaces entity IDs with /. Slashes collide with REST path segments and dashboard links. The server rejects them at write time. Legacy streams created before this check show in the list view but return 404 on click. The same holds for ?, #, %, &, and whitespace.
// BAD: breaks every path-based routeawait client.streams.append(`${venueId}/issue-${issueId}`, event, opts);// GOOD: one segment, or use entityType for the namespace dimensionawait client.streams.append(`${venueId}-${issueId}`, event, opts);// entityType lives on the event, not the optionsawait client.streams.append(issueId, { ...event, entityType: `issue.${venueId}` }, opts);How to Fix: Use only letters, digits, -, _, ., :, and ~ in entity IDs.
Tell the AI:
Entity IDs may contain only letters, digits, "-", "_", ".", ":" and "~".Never use "/" to namespace them.21. CRUD-Named Events
Section titled “21. CRUD-Named Events”The Problem: AI names events UserUpdated, OrderChanged, or ItemModified. These names erase intent. Consumers cannot tell why something changed, so they must inspect the diff. Over time every reactor grows a defensive if (event.data.something_changed) branch.
// BADawait client.emit("user.updated", { email: "new@ex.com", reason: "user_request" });// GOOD: one fact per intentawait client.emit("user.email_changed", { from: old, to: new, reason: "user_request" });await client.emit("user.name_corrected", { from: old, to: new });How to Fix: If the event name does not tell you why, it is a CRUD event. Rename it to the domain action.
Tell the AI:
Name events for the domain action (user.email_changed), not for a CRUDoperation (user.updated). One fact per event.22. Fat Events
Section titled “22. Fat Events”The Problem: AI puts the full entity state in the event (...entity). This freezes the read shape into the stream. A schema change then needs upcaster work for every event instead of only the changed field.
// BAD: the event is a state snapshot{ name: "order.placed", data: { ...entireOrderObject } }// GOOD: the event is a fact with cause and delta{ name: "order.placed", data: { orderId, customerId, items, totalCents, placedAt } }How to Fix: Events carry what happened, not what the entity now looks like. Projections derive the current shape.
Tell the AI:
Events carry what happened, not the full entity state. Let projectionsbuild the current shape.23. Anemic Aggregates
Section titled “23. Anemic Aggregates”The Problem: AI writes the entity stream as a dumb event log. The business rules then live in function bodies, projection handlers, or reactive listeners. This is CRUD with extra steps. The aggregate owns its invariants or it is not an aggregate.
// BAD: the rule runs in a downstream reactor, too lateconst reactToPlaced = createFunction({ triggers: [{ event: "OrderPlaced" }] }, async ({ event }) => { if (event.data.total > creditLimit) { /* now what? the event is already a fact */ }});// GOOD: the decider enforces the rule before the event is appendedfunction decideOrder(state, cmd) { if (cmd.total > state.creditLimit) throw new NonRetryableError("exceeds credit limit"); return [{ name: "OrderPlaced", data: { /* ... */ } }];}How to Fix: Run all invariant checks in the pure decider, before streams.append. After the append, the event is a fact. See Invariants live in the decider.
Tell the AI:
Put business rules in a pure decider that runs before streams.append.Do not enforce them in reactors or projections.24. Present-Tense or Imperative Event Names
Section titled “24. Present-Tense or Imperative Event Names”The Problem: AI names events in the imperative (PlaceOrder) or the present tense (OrderPlacing). Imperative names read as commands. Present-tense names read as process state. An event is a fact that already happened.
// BADconst OrderEvents = { PLACE: "order.place", PROCESSING: "order.processing" } as const;// GOODconst OrderEvents = { PLACED: "order.placed", SHIPPED: "order.shipped" } as const;const OrderCommands = { PLACE: "place.order", CANCEL: "cancel.order" } as const; // verb-first = commandHow to Fix: Use past tense {noun}.{verb-ed} for events. Use imperative {verb}.{noun} for commands. Do not add a commands. prefix. Ironflow does no routing on it, and the verb-first shape is the signal. See Commands vs events.
Tell the AI:
Events use past tense (order.placed). Commands use verb-first imperative(place.order) with no "commands." prefix.25. Raw String Event Names
Section titled “25. Raw String Event Names”The Problem: AI writes event names as string literals in triggers. A typo compiles and the function never fires.
triggers: [{ event: "order.placed" }] // BAD: typos compileimport { OrderEvents } from "../events/order-events";
triggers: [{ event: OrderEvents.PLACED }] // GOODHow to Fix: Define event names once as constants (as const) and import them.
Tell the AI:
Define event names as shared constants and import them. Do not repeatstring literals.26. Missing IronflowProjection Cast
Section titled “26. Missing IronflowProjection Cast”The Problem: AI passes a typed managed projection to a TypeScript worker without the cast. A typed managed projection is not assignable to IronflowProjection<unknown, unknown>, so tsc fails.
createWorker({ projections: [myProjection] }); // BAD: type errorimport { type IronflowProjection } from "@ironflow/node";
createWorker({ projections: [myProjection as IronflowProjection] }); // GOODHow to Fix: Add the cast in TypeScript workers. Go’s NewWorker and Python’s Worker(functions=[...], projections=[...]).run() take other configuration shapes. Python’s projections take ironflow.projection.create_projection(...) values, so Python needs no cast.
Tell the AI:
In TypeScript workers, cast each projection with "as IronflowProjection".27. KV get Without try/catch
Section titled “27. KV get Without try/catch”The Problem: AI calls bucket.get() and assumes the key exists. It throws when the key is missing.
const entry = await bucket.get(key); // BAD: throws if the key is missingtry { const entry = await bucket.get(key);} catch { // GOOD: missing key — use a default}How to Fix: Wrap bucket.get() in try/catch and use a default value.
Tell the AI:
KV get throws when the key is missing. Catch the error and use a default.28. Watchers Without Cleanup
Section titled “28. Watchers Without Cleanup”The Problem: AI calls config.watch() or bucket.watch() and never stops the watcher. Each call leaks a server-side watch connection.
config.watch("flags", { onUpdate: handleFlags }); // BAD: never stoppedconst watcher = config.watch("flags", { onUpdate: handleFlags });// when the owner shuts down:watcher.stop(); // GOODHow to Fix: Keep the watcher and call stop() when the owner shuts down.
Tell the AI:
Store the return value of every watch() call and call stop() on it atcleanup.29. Webhook Without Signature Verification
Section titled “29. Webhook Without Signature Verification”The Problem: AI writes a webhook with no verify step. Anyone can send fake payloads.
const stripe = createWebhook({ id: "stripe", // BAD: no verify transform: (payload) => ({ name: `stripe.${payload.type}`, data: payload.data.object }),});const stripe = createWebhook({ id: "stripe", // GOOD: reject unsigned payloads verify: async (req) => stripeSdk.webhooks.constructEvent(req.body, req.headers["stripe-signature"], secret), transform: (payload) => ({ name: `stripe.${payload.type}`, data: payload.data.object }),});How to Fix: Always verify the provider’s signature in verify.
Tell the AI:
Every webhook must verify the provider signature. Never accept unsignedpayloads.30. Secrets Resolved at Import Time
Section titled “30. Secrets Resolved at Import Time”The Problem: AI reads secrets from process.env at module level. The value is fixed at import time, not at execution.
const key = process.env.STRIPE_KEY; // BAD: resolved at importconst fn = createFunction({ /* ... */ secrets: ["stripe-key"] }, async ({ secrets, step }) => { const key = secrets.get("stripe-key"); // GOOD: resolved at execution});How to Fix: Declare the secret in the function config and call secrets.get() inside the handler. secrets hangs off the function context, not step. It is synchronous and throws when the secret is unset. It does not return undefined.
Tell the AI:
Declare secrets in the function config and read them with secrets.get()inside the handler. Never read them at module level.When NOT to Use AI
Section titled “When NOT to Use AI”Some tasks should be done manually:
Security Configuration
Section titled “Security Configuration”Don’t let AI generate:
- Signing keys or secrets
- Authentication logic
- Permission checks
- Encryption/decryption code
Instead, use established libraries and review security code manually.
Production Deployments
Section titled “Production Deployments”Don’t let AI:
- Configure production infrastructure
- Set up monitoring/alerting rules
- Create database migrations for production
- Manage secrets in CI/CD
Complex Business Logic
Section titled “Complex Business Logic”Review AI-generated code carefully for:
- Financial calculations
- Legal compliance logic
- Data privacy handling
- Multi-tenant isolation
Verification Checklist
Section titled “Verification Checklist”After AI generates Ironflow code, verify:
- Every step has a unique, descriptive ID
- All external calls are inside
step.run - External API calls use idempotency keys
-
NonRetryableErrorused for permanent failures -
waitForEventtimeout strategy is intentional (hard fail viasystem.run.*.failed, or modeled as a fallback event the wait can match) - Correct execution mode (push vs pull)
- Event
matchfield names a field that exists in both events - Environment variables used for configuration
- Types are properly defined (no
any) - Managed projection handlers are pure, deterministic, and return a new state object
- No
streams.appendplusemitfor the same fact - Entity IDs contain only letters, digits,
-,_,.,:,~ - Event names are past tense, and trigger names come from shared constants
- Webhooks verify signatures, and secrets are read inside the handler
- No sensitive data logged or exposed