Skip to content

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.

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] }).

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.

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.

Runs a side effect (tool call, API request, state update) without a user-facing reply. The node executes and then transitions immediately.

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.

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 |

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 is
correct — "yes", "looks good", "that's correct", "go ahead" — call complete_order
immediately 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.

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.

booking-flow.ts
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],
}),
],
});