Skip to content

Flow Execution Model

The Flows guide covers authoring a flow. This page covers what the engine does with it at runtime: how a node is dispatched, how its return value becomes the next move, how history is reshaped between nodes, and how a flow pauses and resumes.

A flow is a core primitive — a directed graph of typed nodes. Each node returns a transition, and that transition is the only thing that drives control flow. Nothing the model says, and no tool result, moves the graph — only transitions do.

When a flow is active, the runtime runs one loop over the graph:

  1. Resolve the entry node — the active node recorded on the run, or the flow's start on first entry. This is the resume point: a flow that suspended mid-graph re-enters at the node it left, not at the beginning.
  2. Dispatch the node — run it according to its kind (below) and get back a transition.
  3. Act on the transition:
    • end / handoff / escalate → leave the flow.
    • stay → pause for the next user turn, or consume buffered input and continue.
    • a node (goto) → verify the current node, check the oscillation guard, reduce the transition (reshape context, merge data, emit events), persist, and advance.
  4. Repeat until a terminal transition or a pause.

The loop returns one of three results to the host: the flow ended, it wants a handoff, or it is awaiting user input.

KindWhat runsHow the next move is chosen
actionYour run(state, ctx) — no model callIts return value, normalized
decideA structured model call against the node's schemadecide(data, state) branches on the result
replyA model turn — or the node's authored response(state) rendered by the engine, no model callnext(turn, state), or stay if no next
collectThe extraction loop (below)onComplete(data, state) once the schema is satisfied

A reply node with a response is engine-rendered: the text is emitted verbatim and the turn is tagged rendered: 'engine' in the trace (a spoken model turn is rendered: 'model'). Declarative flows run through the same engine; in the JSON dialect (see Dynamic Flows) consecutive generate: true reply chains are additionally batched into one model call per segment.

action is the only kind handed the durable ctx directly — ctx.tool, ctx.approve, ctx.signal, ctx.now, ctx.uuid, ctx.emit. The durable-execution rules (use ctx.now() not Date.now(), keep effect order stable) apply specifically to action logic.

decide requires a channel driver that supports structured output; it produces data, and your decide function turns that data into a transition.

A reply node also handles interruption: if the turn is interrupted (barge-in), the engine waits for the user, appends their input, and re-dispatches the same node.

Every node's return value is normalized into one of five internal moves:

You returnNormalized move
a node object registered in flow.nodesgoto that node
{ goto: 'nodeId', data? }goto, merging data into flow state
{ end: reason }finish the flow
{ handoff, reason }hand off to another agent
{ escalate: reason }fire an __escalate signal, then hand off to human
'stay'stay on this node for another user turn

Anything else throws — a malformed transition fails loudly rather than silently doing nothing. Inline node definitions and thunks (() => node) are not transitions: defineFlow rejects them at definition time (inline-transition-target), because a target with no registered identity cannot survive a parked run's resume.

When the engine moves from one node to the next, it emits node-exitflow-transitionnode-enter, merges any data into the flow state, and applies the context strategy for the node being entered.

Context strategy controls the message history the next model call sees. Resolution order is node context (reply nodes only) → flow contextappend:

  • append (default) — keep the full history.
  • reset — keep system messages and the last user message only.
  • reset_with_summary — summarize the prior turns into a single system message (falls back to reset if the summary is empty).

This is how a long flow keeps the model focused per stage instead of dragging the entire transcript through every node.

Verify runs on a node before a goto out of it commits. A node's outputSchema (or a verify.check predicate) validates the merged { ...state, ...data }; the check form can also inspect the durable step log. On failure the flow does not advance — it holds position and returns awaiting user, so the next turn can supply what's missing.

Oscillation guard counts how many times each from → to edge is traversed. Exceeding the flow's maxOscillations (default 2) throws — the backstop against two nodes ping-ponging forever.

A flow can declare gates — checks the engine evaluates over the run record when the flow reaches a terminal transition. Two kinds, two severities:

  • kind: 'predicate' — a declarative predicate over input, state, results, and requestContext.
  • kind: 'judge' — a structured model verdict over an explicit allow-list of run-record paths (inputs), with an optional rubric. Requires flowGateJudge (a provider or a LanguageModel) on the harness config; an absent judge is an execution error.

Each gate is severity: 'blocking' or 'advisory'. A failed blocking gate — or any gate that failed to execute, regardless of declared severity — sets the run's verification record to outcome: 'failed-verification' and the turn ends with that reason; advisory failures are recorded in the verdicts but do not block. The verdicts travel on the flow-end event, so a consumer sees exactly which gate failed and why.

collect is the most involved node. Each pass:

  1. If the schema's required fields are all populated, project that data and call onComplete.
  2. Otherwise, if maxTurns (default 10) is exceeded, complete only if the node is genuinely satisfied; if required fields are still missing, escalate naming them. Running out of turns is not the same as finishing — calling onComplete with a half-filled record lets a downstream action act on it.
  3. Run the node's tier-0 resolvers (enum_check, range, jsonpath) against the fresh user text. A field resolved deterministically is recorded with slotSources: 'deterministic' and excluded from the model's schema this turn.
  4. Build a per-turn extraction tool named submit_<nodeId>_data for the fields that remain. Its schema is the node schema made partial and nullable (so the model can submit just-learned fields), and its description embeds the still-missing fields plus the user's latest verbatim message to anchor extraction.
  5. Run the non-speaking extraction turn and merge submitted fields into flow state — dropping any value for a verbatimFields slot that has no provenance in the user's actual message (the never-guess guard). If still incomplete and there's no buffered input, stay (wait for the user).

The extraction turn produces no user-facing text. When the node pauses on missing fields, the engine emits the node's deterministic ask(missing, state) — or a safe default built from the field names — so a collect can never narrate a downstream outcome. instructions on a collect node only steers the non-speaking extraction.

Collected fields are stored under an internal __collect_<nodeId> key, and the turn counter under __collectTurns_<nodeId> — namespaced so they don't collide with your domain state. onComplete receives only the projected required fields.

A flow persists its position on every transition, so a crash mid-flow resumes at the last committed node. It pauses in two ways:

  • stay with no buffered input — the engine saves the run and returns awaiting user. The next runtime.run({ input }) re-enters at the same node. (While a flow is active, user input is buffered for the node to consume rather than appended as a free-conversation message.)
  • ctx.approve() / ctx.signal() inside an action — a durable suspend; see Durable Execution.

A flow narrates itself through the stream: flow-enter, node-enter, node-exit, flow-transition, flow-end, plus handoff and error. This is what an inspector UI or SSE consumer renders as flow progress.

Collect an address, book the shipment in an action (gated by verify), then confirm in a reply that resets context to a summary first.

flow-execution.ts
import { openai } from '@ai-sdk/openai';
import { defineAgent, defineFlow, collect, action, reply, defineTool } from '@kuralle-agents/core';
import { z } from 'zod';
const bookShipment = defineTool({
name: 'bookShipment',
description: 'Book a shipment to the given address',
input: z.object({ address: z.string() }),
execute: async ({ address }) => ({ id: `trk_${address.length}` }),
});
const done = reply({
id: 'done',
// `reset_with_summary` collapses the gathering turns into a single summary
// line before this node runs, keeping the model focused on the confirmation.
context: 'reset_with_summary',
instructions: 'Confirm the shipment was booked with its tracking id, then end.',
next: () => ({ end: 'booked' }),
});
const book = action({
id: 'book',
// `outputSchema` gates the transition OUT of this node. It validates the
// merged { ...state, ...data }; if it fails, runFlow holds position
// (awaitingUser) instead of advancing to `done`.
outputSchema: z.object({ trackingId: z.string().min(1) }),
run: async (state, ctx) => {
const receipt = (await ctx.tool('bookShipment', { address: state.address })) as { id: string };
// `data` is merged into flow state on the transition, so `done` and the
// verify check above both see `trackingId`.
return { goto: done.id, data: { trackingId: receipt.id } };
},
});
const getAddress = collect({
id: 'get_address',
schema: z.object({ address: z.string() }),
required: ['address'],
maxTurns: 5,
ask: (missing) => `Which ${missing.join(' and ')} should the shipment go to?`,
onComplete: () => book,
});
export const shippingAgent = defineAgent({
id: 'shipping',
instructions: 'You book shipments for customers.',
model: openai('gpt-4o-mini'),
tools: { bookShipment },
flows: [
defineFlow({
name: 'ship',
description: 'Collect an address, book the shipment, then confirm',
start: getAddress,
nodes: [getAddress, book, done],
maxOscillations: 2,
}),
],
});
  • Flows — authoring nodes and transitions.
  • Durable Execution — the effect log behind action nodes and pauses.
  • Tools — wiring the tools an action or reply node calls.