Flows
A flow is a small directed graph of typed nodes. Each node owns a slice of the conversation — collecting input, giving a reply, running an action, or making a decision — and returns a transition to the next node when it’s done.
Put your SOP in a flow instead of a system prompt. Code you can test beats instructions you can’t.
Defining a flow
Section titled “Defining a flow”import { defineFlow, reply, collect } from '@kuralle-agents/core';
const flow = defineFlow({ name: 'booking', description: 'Book an appointment', start: getDate, // the node to enter on the first turn nodes: [getDate, confirm], // every node in the flow});Attach the flow to an agent with defineAgent({ flows: [flow] }).
Node kinds
Section titled “Node kinds”Sends a response and returns a transition. Use it for confirmations, summaries, and any step where the agent speaks and then moves on.
import { reply } from '@kuralle-agents/core';
const confirm = reply({ id: 'confirm', instructions: 'Confirm the booking with the collected date, then end.', next: () => ({ end: 'done' }),});next is optional. When present it’s called after the model responds (unless the turn produced a handoff or end control signal) and returns the next transition; omit it to stay on the node.
collect
Section titled “collect”Collects a structured schema from the user over one or more turns. The node re-enters until all required fields are filled — or maxTurns (default 10) is reached, at which point onComplete runs with whatever was collected.
import { collect } from '@kuralle-agents/core';import { z } from 'zod';
const getDate = collect({ id: 'get_date', schema: z.object({ date: z.string() }), required: ['date'], instructions: (missing) => `Ask the user for: ${missing.join(', ')}`, onComplete: () => confirm,});instructions receives the still-missing fields (and the current flow state) on each re-entry. onComplete is called when all required fields are collected and returns the next node.
action
Section titled “action”Runs a side effect (tool call, API request, state update) without a user-facing reply. The node executes and then transitions immediately.
decide
Section titled “decide”Asks the model for a structured decision against a schema (no user-facing reply), then decide(data, state) returns the next transition. Use it for routing branches that depend on classifying the conversation.
Transitions
Section titled “Transitions”A node’s next (or onComplete) function returns a Transition:
| Return value | Effect |
|---|---|
| A node object (or () => node) | Move to that node on the next turn |
| { goto: node, data? } | Move to node, merging data into flow state |
| { end: 'label' } | End the flow with a label |
| { handoff: 'agentId', reason? } | Hand off to another agent |
| { escalate: 'reason' } | Escalate to a human |
| 'stay' | Stay on the current node for another turn |
Writing node instructions
Section titled “Writing node instructions”A node’s instructions is a focused micro-prompt for that node’s job — not a system prompt. The flow already owns control flow (which node runs next); the instructions only steer what the model says and which tool it calls on this step. Two habits keep node prompts reliable.
Map intent to a tool call explicitly. A node usually advances when a specific tool fires — its next inspects toolResults. Don’t assume the model will infer when to call it: name the trigger, list the phrases that should map to it, and require the call in the same turn.
const confirm = reply({ id: 'confirm', instructions: `Read back the order details. When the user clearly affirms the order iscorrect — "yes", "looks good", "that's correct", "go ahead" — call complete_orderimmediately in that same turn. Use revise_order only if they want to change something.After any clear affirmative, you must call complete_order, not re-ask.`, tools: () => buildToolSet({ complete_order: completeOrder, revise_order: reviseOrder }), next: (turn) => turn.toolResults.some((r) => r.name === 'complete_order') ? end : 'stay',});Avoid confirmation loops. Open-ended phrasing like “ask if they want anything else or to make changes” keeps the model asking — it never calls the tool, so the node never transitions. If a node should complete on agreement, tell it to act on agreement, not to ask again.
Holding position across turns
Section titled “Holding position across turns”A flow records its current node on the session and resumes there on the next turn. When a node returns 'stay' (and there’s no pending input), the run is persisted and the turn ends; the next runtime.run({ input }) re-enters the same node rather than restarting the flow. A collect node uses this to gather one field at a time over several turns.
This means the flow keeps its place across turns automatically — you don’t wire anything for it. For the full picture of how nodes are dispatched, how context is reshaped between them, and how a flow pauses and resumes, see the Flow Execution Model.
Example: booking flow
Section titled “Example: booking flow”import { openai } from '@ai-sdk/openai';import { defineAgent, defineFlow, collect, reply } from '@kuralle-agents/core';import { z } from 'zod';
const confirm = reply({ id: 'confirm', instructions: 'Confirm the booking with the collected date, then end.', next: () => ({ end: 'done' }),});
const getDate = collect({ id: 'get_date', schema: z.object({ date: z.string() }), required: ['date'], instructions: (missing) => `Ask the user for: ${missing.join(', ')}`, onComplete: () => confirm,});
const agent = defineAgent({ id: 'booking', instructions: 'You are a booking agent.', model: openai('gpt-4o-mini'), flows: [ defineFlow({ name: 'booking', description: 'Book an appointment', start: getDate, nodes: [getDate, confirm], }), ],});