Flow agents API

Build multi-context (flow) agents over the API: every step, condition and setting the flow builder has, as one JSON graph on the agent.

A flow agent is an ordinary agent whose metadata.flow holds a graph: steps (nodes), each one part of the conversation with its own instructions, and exits (edges) between them, each with a condition. A call starts at one step and moves along the exits whose conditions hold. Everything else — voice, model, speech-to-text, call behaviour, analysis — is the agent's own setting, which every step inherits and any step may override.

You build one with the same endpoints as any agent (Agents API): POST /api/agents to create, PUT /api/agents/{id} to change, plus POST /api/agents/flow/validate to check a graph first. The flow builder writes exactly this JSON, so a graph made by API opens in the builder and the other way round. From ChatGPT, Claude or Grok the same graph is built with create_flow_agent (Multi-step calls).

Quick start

A front desk that finds out whether the caller is new or existing, takes the right details, then says goodbye.

curl -X POST https://api.telenow.ai/api/agents \
  -H "X-API-Key: $TELENOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Clinic front desk",
    "systemPrompt": "You are the front desk of Sunrise Clinic. Be warm and brief.",
    "llmProvider": "openai",
    "llmModel": "gpt-4o-mini",
    "sttProvider": "deepgram",
    "ttsProvider": "elevenlabs",
    "ttsVoice": "21m00Tcm4TlvDq8ikWAM",
    "sessionConfig": {},
    "telephonyConfig": { "firstResponse": "agent", "agentMsg": "Hello, Sunrise Clinic. How can I help?" },
    "metadata": {
      "flow": {
        "schema": 1,
        "startNodeId": "greet",
        "nodes": [
          { "id": "greet", "kind": "conversation", "name": "Greet",
            "prompt": "Find out whether the caller is a new or an existing patient.",
            "extract": [{ "name": "patient_type", "type": "enum", "values": ["new", "existing"],
                          "description": "whether this is their first visit" }] },
          { "id": "new", "kind": "conversation", "name": "New patient",
            "prompt": "Ask for their full name and the day they would like to come in." },
          { "id": "existing", "kind": "conversation", "name": "Existing patient",
            "prompt": "Ask for their date of birth to find their record, then what they need." },
          { "id": "bye", "kind": "end", "name": "Goodbye",
            "entryMessage": "Thank you for calling Sunrise Clinic. Goodbye." }
        ],
        "edges": [
          { "id": "to-new", "source": "greet", "target": "new", "priority": 0,
            "condition": { "kind": "equation", "expr": "{patient_type} == \"new\"" } },
          { "id": "to-existing", "source": "greet", "target": "existing", "priority": 1,
            "condition": { "kind": "equation", "expr": "{patient_type} == \"existing\"" } },
          { "id": "new-done", "source": "new", "target": "bye",
            "condition": { "kind": "ai", "describe": "the name and preferred day are both given" } },
          { "id": "existing-done", "source": "existing", "target": "bye",
            "condition": { "kind": "ai", "describe": "the caller has said what they need" } }
        ]
      }
    }
  }'

The answer is the created agent (201), with metadata.flow as sent. It runs as a flow from its next call.

Validate before you save

POST /api/agents and PUT /api/agents/{id} store metadata.flow exactly as sent — they do not check it. A graph that breaks a rule below is discarded on every call, and the agent quietly runs as a single-context agent on its base settings. Check the graph first:

curl -X POST https://api.telenow.ai/api/agents/flow/validate \
  -H "X-API-Key: $TELENOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "flow": { "startNodeId": "greet", "nodes": [ … ], "edges": [ … ] },
        "variables": [{ "name": "name", "required": true }] }'
{
  "success": true,
  "data": {
    "valid": false,
    "errors": ["edge 'new-done' target 'goodbye' is not a node"],
    "warnings": [
      { "code": "vague_ai_condition", "at": "existing-done", "message": "…" }
    ],
    "multiContext": true
  }
}
FieldMeaning
validtrue when the graph will run.
errorsExactly what makes a call discard the graph (Errors). Fix every one before saving.
warningsThe graph runs, but something in it may misbehave on a call (Warnings). variables — the agent's metadata.variables — lets the checker know the names your calls pass in.
multiContextfalse for one step with no exits: that runs exactly like a single-context agent.

Same authentication as the rest of the Agents API (an API key, or a user token with X-Org-Id).

Read, change, convert

  • Read: GET /api/agents/{id} returns the graph in metadata.flow.
  • Change: PUT /api/agents/{id} with metadata. The Agents API replaces metadata whole — read the agent, change metadata.flow, and send the complete metadata back, or other settings stored there (variables, analysis, memory, tools) are lost.
  • Single → flow: add metadata.flow. Flow → single: set metadata.flow to null.
  • Drafts: metadata.flowDraft is the flow builder's unpublished copy. It never runs; only metadata.flow does. Writing flow is publishing. The status=draft list filter shows agents that have a flowDraft (or configDraft).
  • Versions: every update makes a version you can restore (Agent versioning).
  • A call reads the graph once, when it starts; a change applies from the next call.
  • A deployment can switch flow agents off as a whole; there, metadata.flow is ignored and every agent runs single-context.

The graph

metadata.flow:

KeyTypeDefaultMeaning
schemainteger1The format version. Must be a number — "v1" discards the graph.
startNodeIdstring—Required. The step every call starts on.
nodesarray—Required, at least one. The steps (Steps).
edgesarray[]The exits (Exits).
routerModelstring or objectthe platform modelThe model that decides ai exits and global jumps — see below.
navigateInlinebooleanfollow the platformfalse opts this graph out of letting a step's own model pick among all-ai exits inline; routing then always goes through the router model.

routerModel forms:

ValueRouter
absent, "platform", or any other stringThe platform's routing model.
"agent" or "node" (the same)The model of the step the call is on — including a step's own model override.
{ "provider": "openai", "model": "gpt-4o-mini" }That model, on the platform's key. Providers: openai, groq, anthropic, openrouter, and on-premise models; anything else uses the platform model.

Limits: 100 steps and 300 exits; more discards the graph. A graph with one step and no exits is not run as a flow — the call uses the agent's own settings (only that step's knowledge-base scope applies).

Unknown keys anywhere in the graph are ignored. A wrong type on a structural field — schema, startNodeId, a step's id / kind / name / prompt, the true/false fields, extract, model, voice, an exit's id / source / target / priority / condition, null on any of them — discards the whole graph, so validate before saving. Spoken lines, guards, config, tools, behavior, routerModel, the waiting controls and replyContext are read leniently: a value of the wrong shape counts as absent.

Steps

Fields every step takes

KeyTypeDefaultMeaning
idstring—Required, unique. What exits point at.
kindstring"conversation"One of the 13 kinds.
namestring—Shown in the builder, call traces and exports; names a keypad option when its exit has no label.
position{x, y}—Where the builder draws it. Never read on a call.
promptstring—This step's instructions, added to the agent's systemPrompt while the call is on the step. Rendered with {variables} when the call arrives. Not used by tool and note steps.
promptMode"append" | "replace""append""replace" uses prompt instead of the agent's systemPrompt on this step.
entryMessagestring or spoken line—Said the moment the call arrives. On a static step it is the step's whole content; on an end step, the goodbye. Never said on transfer and agent steps (they use config.message).
continueAfterEntrybooleanfalseAfter entryMessage is heard, the agent carries on in its own words instead of waiting — for a line that hands over ("Great, let's get you booked.") rather than asks. Conversation, subagent, extract and keypad steps.
extractarray—Variables to capture from the conversation (Captured variables). Any kind.
modelobjectinheritThis step's language model: { provider, model, temperature?, maxTokens?, extra? }. A provider change drops the agent's own credentials for the old provider.
voiceobjectinheritThis step's voice: { provider, voice, extra? }.
s2sVoicestringinheritThis step's realtime voice, on an agent that already runs a realtime (speech-to-speech) model.
behaviorobjectinheritReplaces the agent's whole behavior block on this step — see Behaviour.
knowledgeBaseIdsarray of idsinheritWhich of the agent's knowledge bases this step searches. [] = none. Only narrows: a base the agent is not attached to is ignored.
knowledgeDocumentIdsobject—Per base: { "<kbId>": ["<docId>", …] } — only these documents; [] = none from that base; a base left out = all of it.
toolsarray of tool specsinheritReplaces the agent's tools on this step ([] = no tools). Same shape as metadata.tools (Tools & function calling).
guardsobject—Per-step call handling — see Guards.
allowSelfNavigatebooleanfalseGives the step's own model a navigate tool over all its exits, so it chooses the exit itself in the same turn.
isGlobal, globalCondition, globalExamples, globalGoBack, globalPreventRetriggerStepsMake the step reachable from anywhere — see Global steps.
savesarray of {name, type?, description?}—tool steps: the variables the API is expected to return. Only informs the checker and the builder's pickers; the stored names come from the response (or responseMap).
sendsarray of namesalltool steps: the only variables sent to the API.
waitMode, waitLines, endOfCallPolicytool steps: how the step waits — see Function steps.
configobject—Kind-specific settings (below). Ignored on kinds that do not read it.

Two fields are accepted for old graphs but do nothing: stt (a step's own speech-to-text — the recogniser is fixed for the whole call) and skipResponse (use a static step instead). The checker warns about both.

Step kinds

KindBuilder nameWaits for the caller?What it doesconfig
conversationConversationYesThe agent talks with the caller on this step's instructions.—
subagentSubagentYesThe same as conversation; give it its own tools.—
extractExtract VariableYesThe same as conversation, meant for collecting extract variables (any step can collect).—
dtmfPress DigitYesA keypad menu: put the menu in entryMessage, route with dtmf exits.—
staticSayNoSays entryMessage, waits for it to finish, moves on.—
play_audioPlay AudioNoSays entryMessage (if any), plays a recording from your audio library, moves on.trackId
toolFunctionNoCalls an API, stores the result as variables.a tool spec
codeCodeNoRuns sandboxed JavaScript over the variables.source, …
routerLogic SplitNoBranches. Says its entryMessage if it has one (usually none); its exits route.—
transferCall TransferEnds the flowTransfers the call to a phone number.destinations, message, noAnswerMessage
agentAgent TransferEnds the flowHands the call to another AI agent.agents / targetAgentId, message
endEndingEnds the callSays entryMessage, then hangs up.—
noteNote—A comment on the canvas. Never visited.—

Steps that don't wait (static, play_audio, tool, code, router) run and immediately follow their first matching rule exit (always, equation, tool_result, then fallback). If none matches but the step has ai or dtmf exits of its own, the call stays on it and the caller's next words (or keypress) decide; with neither, the call is stuck there (a dead end — the checker warns).

Keypad menus (dtmf)

  • Any dtmf exit anywhere in the graph turns keypad capture on for the whole call, one digit at a time, unless the agent already configures keypad input.
  • guards.dtmfRouteOnly: true — a keypress goes straight to its exit with no model turn (recommended for real menus). On a plain key menu — one key per press, only keypad exits, no AI exits, no storeAs — a key that matches no exit plays the menu (entryMessage) again; anywhere else the press is routed as usual.
  • guards.keypadOnly: true — anything the caller says on this step is ignored (still in the transcript). With both, the step never reaches the model.
  • As the start step, its menu is read right after the agent's greeting (unless the greeting already says it).
  • To collect several digits (an account number), use guards.keypad with digitLimit and storeAs.

Say (static)

Says entryMessage in full, then follows its exits. Wire it to an end step and the line is heard before the goodbye.

Play Audio (play_audio)

config keyMeaning
trackIdRequired. The id of a recording in your organisation's audio library (uploaded WAV or MP3).

Says entryMessage first if there is one, plays the recording to the end (the caller can interrupt it), then follows its exits — give it an always exit. On chat channels nothing plays.

Function steps (tool)

config is a complete tool spec, the same object as an entry of metadata.tools (Tools & function calling): name (required), description, parameters, endpoint (an https webhook), bearer, kind (http default, mcp, connector, app), config, request (a request template), handoff (a line said while it runs), timeoutSecs (1–30, default 15), requiresConfirmation, effect, piiEgress. Native kinds (transfer, end_call, …) are not run by a Function step — use the transfer, agent and end step kinds.

What the step sends and keeps:

KeyWhereMeaning
sendsstepThe variables sent as arguments (default: every variable the call holds, except names starting with _).
config.config.argSourcestoolPer parameter: { "kind": "fixed", "value": "…" } (a fixed string; {placeholders} are filled), { "kind": "variable", "value": "<name>" } (a variable — including system ones like caller_number), or { "kind": "agent" } (leave as is).
config.config.responseMaptool[{ "path": "data.items.0.status", "variable": "order_status" }] — keep only these values, under these names. Without it, every top-level text, number or true/false value of the response is stored under its own name.

After the call: _tool_status is "ok" or "error", _tool_timed_out is "true" or "false", and the results are variables for later exits, prompts and lines. Route on them with tool_result or equation exits.

Waiting (Function steps only):

KeyValuesMeaning
waitMode"auto" (default), "wait", "detach"auto/detach: the call moves on while the request runs, unless one of the step's own exits reads the result (a tool_result exit, or an equation on its outputs) — then it waits. wait: always waits. A later step that reads a pending result waits for it.
waitLines{ "explain"?: line, "offerOut"?: line }What the caller hears during a long wait: a "one moment" line after ~2 s; explain at 40% of the timeout or 6 s, whichever is sooner; offerOut ("shall I call you back?") at 70% or 15 s, whichever is sooner, and only when that is at least 8 s in. Absent: the platform's own lines.
endOfCallPolicy"finish" (default), "cancel", "followup"A request still running at hang-up: let it finish; cancel it; or retry it after the call (5 attempts in all).

Confirm first: with requiresConfirmation: true (and always for payment, billing and checkout actions) the agent reads the action back and waits for a yes. A no sets _tool_status: "declined"; three unclear answers count as no.

A request that already succeeded with the same arguments on this call is not sent twice.

Code (code)

config keyDefaultMeaning
language"js"JavaScript is the only language.
source—A function body: read variables as dv.<name>, return an object. Up to 16 KB. No network, timers or async.
timeoutMs2000100–5000.
storeFields—[{ "field": "total", "variable": "order_total" }] — keep these returned fields under these names. Without it, every top-level text, number or true/false value is stored under its own name.
talkWhileWaiting, talkAfterCompleted—Lines said before and after it runs.

Sets _code_status (ok, error, timeout, oom, invalid, …), _tool_status (ok only when the code succeeded) and _code_error. At most five code steps run between two caller turns.

Logic Split (router)

It exists to branch, and does nothing else — though, like any step, it says its entryMessage on arrival if it has one. Its rule exits run at once. With only ai (or keypad) exits, the call waits on it and the caller's next words decide — and that turn gets a normal reply from the agent on this step.

Call transfer (transfer)

config keyMeaning
destinationsRequired. [{ "label"?, "numbers": ["+91…", …], "mode"?: "serial" | "parallel", "betweenMessage"?: line }]. The first destination with a number is used. Up to 10 numbers each: serial (default) rings them in turn, saying betweenMessage between attempts; parallel rings all at once. Numbers on your Do-Not-Call list are skipped.
messageSaid before the transfer (cascade calls).
noAnswerMessageWhat the agent says when nobody answers; blank = end the call instead.

A successful transfer ends the flow. If it cannot even start (no usable number, all numbers blocked, a carrier error), the call stays on the step with _tool_status: "error" and transfer_failed_reason set (no_destination, dnc_blocked, unknown_destination, guard_refused, provider_init, carrier_error, invalid_config), and the step's tool_result / fallback exits run — use them for a "sorry, nobody is free" path.

Hand-off to another agent (agent)

config keyMeaning
agents[{ "agentId": "<uuid>", "label"?, "mode"?: "transfer" | "connect" }] — the first valid id is used.
targetAgentIdThe older single-target form.
messageSaid before the hand-off.

The other agent takes the call with everything said and captured so far; if it is a flow agent, it starts at its own start step. At most five hand-offs per call; the target must be active and in the same organisation. Failure is handled like a transfer's.

Ending (end)

Says its entryMessage (the goodbye), waits for a caller who is still talking, then hangs up. config.message is not said on an end step — the checker warns. An exit into end in the same turn the agent asked a question is dropped for that turn; the caller's next turn routes again.

Note (note)

A comment on the canvas. Never point an exit at it.

The start step

The call's opening line is the agent's own (telephonyConfig.agentMsg / agentMsgOutbound). Then:

Start kindWhat happens
conversation, subagent, extractNothing more — the step's own entryMessage is not said; the conversation starts on its instructions.
dtmfThe menu (entryMessage) is read right after the greeting.
static, play_audio, tool, code, routerRun as the call opens; a rule exit is followed at once, ai/dtmf exits wait for the caller.

Spoken lines

entryMessage, waitLines, config.message, noAnswerMessage, betweenMessage, a tool's handoff, a code step's talk lines and guards.silenceCheckin.message are spoken lines: a plain string, or an object with translations:

{
  "source": "Welcome to Sunrise Clinic.",
  "variants": {
    "hi-IN|Hindi": { "text": "सनराइज़ क्लिनिक में आपका स्वागत है।", "sourceHash": "…", "origin": "reviewed" }
  }
}

The caller hears the variant for their language when it matches the current source (sourceHash), otherwise the source. "verbatim": true on the object uses only reviewed variants. entryMessage, config.message, a tool's handoff and a code step's lines are filled with {variables} when said; a transfer's betweenMessage and noAnswerMessage are said as written.

Behaviour

behavior on a step replaces the agent's whole block for that step (keys you leave out go back to their defaults): tone ("professional" or "professional_conversational"), naturalFillers, highEmpathy, echoVerification, natoPhonetic, speechNormalization, smartMatching, scopeBoundaries (true/false), aiDisclosureWhenAsked (on unless false), mustDeliverMarkers (array of strings).

Guards

guards — per-step call handling:

KeyMeaning
bargeInSensitivity0.0–1.0: how easily the caller can interrupt on this step.
directedSpeechGatetrue: noisy-room mode on this step.
silenceHangupSecsHang up after this much silence; 0 = never.
silenceCheckin{ enabled?, secs?, message? } — the "are you there?" check-in (5–120 s; 10 s when enabled without secs). On a keypad step without entryMessage, message is also the menu that is re-read.
keypad{ enabled, timeoutMs?, terminationKey?, digitLimit?, storeAs? } — keypad input on this step: wait timeoutMs (default 2500, 500–15000) after the last key, end on terminationKey (#, * or a digit), take up to digitLimit keys (up to 32), and store them in the variable storeAs.
reasoningThe step's reasoning setting for reasoning models ({ enabled, effort } or a token budget), as in llmConfig.
delayedResponsetrue/false, or the agent-level delayedResponse object — the "thinking" hold on this step.
s2sVoiceSame as the step's s2sVoice.
dtmfRouteOnly, keypadOnlyKeypad steps only — see Keypad menus.

Speech-to-text, the opening line and the other sessionConfig settings apply to the whole call and cannot be changed per step.

Exits

Fields

KeyTypeDefaultMeaning
idstring—Required, unique.
sourcestring—Required. A step id, or "*" — an exit from every step.
targetstring—Required. A step id.
labelstring—The option's name — what navigate calls it and what campaign exports show for a keypress.
priorityinteger0Lower is tried first; equal priorities keep the array's order. Exits that could both match should not share a priority (the checker warns).
conditionobject{ "kind": "always" }When this exit is taken — below.
replyContextstring—Guidance for what the departing step should say when it takes this exit (up to 300 characters; the model still writes the words). Used where exit guidance is enabled for your deployment.

Conditions

kindNeedsTaken when
always—Always — a straight-through step.
equationexprThe expression on variables is true (Equations).
tool_resultmatch (optional)The step's call ended a given way (Tool results).
fallback—No always / equation / tool_result exit matched.
dtmfdigitThe caller's latest keypress is this digit.
aidescribeThe router model decides the caller's words match this description.

Order. After each caller turn the step's exits are tried in classes: keypad first, then the rules (always, equation, tool_result, then fallback), and the AI router only if neither produced a target. So a step with an always or fallback exit never reaches its ai exits — use one or the other on a step.

Equations

{age} >= 18 && {plan} == 'pro'
{intent} == 'billing' || {intent} == 'refund'
{city} contains 'mumbai'
  • Variables in braces: {name}. A bare word is text, not a variable — status == 'ok' compares the word status.
  • Text in single or double quotes; numbers; true / false.
  • Operators: ==, !=, >, <, >=, <=, and the words contains, starts_with (case-insensitive).
  • && and || (&& binds tighter). No parentheses and no !.
  • == / != compare numbers as numbers, everything else as case-sensitive text. > < >= <= need numbers on both sides.
  • A variable that is not set is empty: {x} == '' is true until something sets it. An expression that cannot be read is simply false.
  • Captured (extract) values are text: {age} >= 18 works, but to branch on an answer use an enum and quotes — {patient_type} == 'new'.

Tool results

match is a string:

matchTaken when
"ok" (default)The step's call succeeded.
"error"It failed (a timeout too, unless the step also has a "timeout" exit).
"timeout"It took too long (Function steps).
"declined"The caller said no to a confirm-first action.
any other text_tool_status equals it.

Out of a transfer or agent step, tool_result and fallback exits are taken only when the transfer or hand-off failed.

AI routing

ai exits (and global jumps) go to the router model (routerModel) with their describe text and the recent conversation; it picks one only when the caller's latest turn clearly fits. Write describe as a full sentence ("the caller wants to reschedule an existing appointment") — the checker warns about descriptions under three words.

A step's own model can choose instead, in the same turn, through a navigate tool: when the step sets allowSelfNavigate, when every exit is ai (if your deployment enables inline routing and the graph does not set navigateInline: false), and always on realtime (speech-to-speech) calls — where navigate is the only way the call moves.

AI routing is rate-limited per call (by default 60 router decisions a minute). Rule exits need no model at all; a keypad exit needs no router call, but unless the step sets guards.dtmfRouteOnly, the key press still gets a model reply on the step (Keypad menus).

When an exit waits

  • After a question: if the agent's reply asked something, a rule exit out of a conversation step waits for the caller's answer; an exit into end in that turn is dropped, and the next turn routes again.
  • Owed values: a forward exit out of a conversation step waits while a value the step owes is still empty — an extract variable marked required, one tested by the step's own equation exits, or one a Function step's argSources reads. The agent asks for it, up to twice per value. Keypad exits, global jumps and exits into end, transfer and agent are never held.

Global steps

  • An exit with source: "*" is an exit from every step.
  • A step with isGlobal: true adds its ai and dtmf exits to every other step.
  • globalCondition (with optional globalExamples) makes the step itself a jump target from anywhere: when the router model sees the caller's words fit, the call jumps there — "the caller asks for a person", "the caller describes an emergency".
  • globalGoBack: true — after the caller's next turn on the global step, the call returns to the step it came from (without repeating that step's line). Armed only by an AI jump.
  • globalPreventRetriggerSteps: N — after a jump, the step is not a jump target again for the next N routing turns.
  • A global step's always / fallback exits are its own: they apply only while the call is on it.

Variables

All variables of a call share one map — available to every step's prompt, entryMessage, exits, tools, code and post-call analysis.

Captured variables

extract on a step: [{ name, type?, description?, values?, required?, format? }].

KeyMeaning
nameThe variable's name.
typestring (default), number, boolean or enum. Values are stored as text whatever the type.
valuesWith enum: the allowed answers. The caller's words are matched to one of them and stored in your spelling.
descriptionWhat to capture — the more precise, the better.
requiredThe step owes it: forward exits wait until it is filled (When an exit waits).
formatemail, phone, pan, gstin, pincode, vehicle or alphanumeric — the value is rebuilt from the caller's words and checked against the format (phone: an Indian mobile; pincode: 6 digits; vehicle: an Indian registration). A value that fails is kept but flagged, and the agent asks again.

Capture runs for the step the caller is leaving and for every non-waiting step on the way, using the recent conversation.

Other variables

SourceVariables
The callThe variables a call is started with (Sessions & calls) or a campaign target carries — declared in the agent's metadata.variables as [{ name, required, default }].
The keypadguards.keypad.storeAs.
Function and code stepsTheir results; _tool_status, _tool_timed_out, _code_status, _code_error.
Transfers and hand-offstransfer_failed_reason, transfer_failed_error on a failure.
Alwayscurrent_date, current_time, current_weekday, current_datetime, today_date, tomorrow_date, day_after_tomorrow_date, current_timezone, current_utc_offset, current_ist_datetime, current_ist_weekday, caller_channel, session_id. With caller identity on: caller_number, caller_identifier.

{name} in a prompt or line is replaced by its value (empty when unset); {name|digits} applies a filter (digits, trim, lower, upper, urlencode, …); {{ and }} are literal braces. A step's prompt is filled when the call arrives at it; lines when they are said. Variables starting with _ are never sent to your APIs.

Errors

A graph with any of these is discarded on every call. POST /api/agents/flow/validate returns them word for word:

MessageFix
flow has no nodesAdd at least one step.
too many nodes (N > 100) / too many edges (N > 300)Split the flow, or hand off to another agent.
a node has an empty id / duplicate node id '…'Every step needs a unique id.
node '…' has unknown kind '…'Use one of the 13 kinds.
node '…' model override needs provider + modelA step's model needs both.
transfer node '…' needs at least one destination phone numberconfig.destinations[].numbers.
agent-handoff node '…' needs at least one target agentconfig.agents[].agentId.
play audio node '…' needs an audio track selectedconfig.trackId, a recording's id.
startNodeId is not set / startNodeId '…' does not match any nodePoint startNodeId at a step.
an edge has an empty id / duplicate edge id '…'Every exit needs a unique id.
edge '…' source '…' is not a node / edge '…' target '…' is not a nodesource and target must be step ids (source may be "*").
edge '…' has unknown condition kind '…'Use one of the six condition kinds.
ai edge '…' needs a description / equation edge '…' needs an expression / dtmf edge '…' needs a digitFill describe, expr or digit.

A field of the wrong type ("schema": "v1", "priority": "1", null where a value must be) also discards the graph; the validate endpoint reports it as not a flow graph: ….

Warnings

The graph runs, but may misbehave on a call. Returned by the validate endpoint and to AI assistants as { code, at, message }:

CodeWhat it means
dead_stepA non-waiting step has no exit to another step — the call stops there.
strandable_stepA non-waiting step's exits are all variable tests that might all be false, with no fallback.
unreachable_exitAn ai/dtmf exit on a non-waiting step that also has an always/fallback exit — it can never be taken.
silent_arrivalA conversation step with no entryMessage is reached right after a silent step — the caller hears nothing.
end_message_never_spokenAn end step with config.message but no entryMessage — it hangs up in silence.
keypad_menu_without_a_catch_allA route-only keypad menu with no fallback and nothing to re-read.
continue_after_entry_without_line / …_on_passthrough / …_over_a_questioncontinueAfterEntry with no line, on a step that does not wait, or after a line that asks a question.
enum_without_valuesAn enum capture without values.
rule_value_not_an_optionAn equation compares an enum capture with a value it does not list.
rule_on_free_textAn equation compares a free-text capture with exact text — likely never equal.
unknown_variable, unwritten_variable, edge_tests_unproduced_variableA variable is read that nothing collects, declares or returns.
missing_bracesAn equation uses a variable's name without braces.
variable_name_has_spaceAn equation names a variable with a space.
empty_string_compareAn equation compares with '' — true until the value is set.
ignored_expressionAn always/fallback exit carries an expr, which is ignored.
vague_ai_conditionAn ai description under three words.
tied_prioritiesTwo rule exits from one step that could both match share a priority.
describe_expr_divergenceAn equation exit's description mentions a variable its expression does not test.
tool_result_without_a_toolA tool_result exit on a step that never calls anything.
timeout_exit_off_a_function_stepA "timeout" exit that does not leave a Function step.
unbound_required_paramA tool's required parameter has no source.
fixed_arg_type_mismatchA fixed argument's type differs from the parameter's.
pinned_money_amountA payment action with a fixed amount.
generic_response_variableA result stored under a generic name (id, status, error, …) — likely to collide.
prompt_names_other_toolA Function step's prompt names another step's tool.
reply_context_too_long / reply_context_not_listed / reply_context_asks_before_a_linereplyContext too long, on an exit never shown, or asking a question where none should be asked.
stt_override_does_nothing / skip_response_does_nothingA field that has no effect.

Limits

LimitValue
Steps / exits per graph100 / 300
Steps run between two caller turns25
Code steps run between two caller turns5
Failed transfers or hand-offs followed in a row3
Hand-offs per call5
New variables per call256
AI router decisionsrate-limited per call (60 a minute by default)
Function step timeout1–30 s (default 15)

When a limit stops a call's routing, flow_limit_reached is set (hops, terminal_failures or ai_budget).

Channels

Phone and web callsRealtime (speech-to-speech)Chat, WhatsApp
RoutingAfter each caller turn, as aboveOnly when the realtime model calls navigate (it is offered every exit)As on calls
LinesSpokenSpoken by the realtime modelSent as text
transfer / agent / endRunRunA note is recorded; the chat stays on the step

More examples

A keypad menu without the AI

{
  "schema": 1,
  "startNodeId": "menu",
  "nodes": [
    { "id": "menu", "kind": "dtmf", "name": "Main menu",
      "entryMessage": "For appointments press 1. For billing press 2. To hear this again press 9.",
      "guards": { "dtmfRouteOnly": true, "keypadOnly": true } },
    { "id": "appointments", "kind": "conversation", "prompt": "Help the caller book or change an appointment." },
    { "id": "billing", "kind": "conversation", "prompt": "Answer billing questions from the knowledge base." }
  ],
  "edges": [
    { "id": "k1", "source": "menu", "target": "appointments", "label": "Appointments", "condition": { "kind": "dtmf", "digit": "1" } },
    { "id": "k2", "source": "menu", "target": "billing", "label": "Billing", "condition": { "kind": "dtmf", "digit": "2" } },
    { "id": "k9", "source": "menu", "target": "menu", "label": "Repeat", "condition": { "kind": "dtmf", "digit": "9" } }
  ]
}

Look up an order, then branch on the result

{
  "schema": 1,
  "startNodeId": "ask",
  "nodes": [
    { "id": "ask", "kind": "conversation", "prompt": "Ask for the order number.",
      "extract": [{ "name": "order_id", "type": "string", "required": true, "description": "the order number" }] },
    { "id": "lookup", "kind": "tool", "name": "Look up order", "sends": ["order_id"],
      "config": {
        "name": "lookup_order",
        "endpoint": "https://api.example.com/orders/lookup",
        "parameters": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] },
        "handoff": "Let me check that for you.",
        "config": { "responseMap": [{ "path": "order.status", "variable": "order_status" }] }
      },
      "saves": [{ "name": "order_status" }] },
    { "id": "tell", "kind": "conversation", "entryMessage": "Your order is {order_status}.",
      "prompt": "Answer the caller's questions about their order, which is {order_status}." },
    { "id": "sorry", "kind": "conversation", "entryMessage": "Sorry — I can't reach our order system right now.",
      "prompt": "Offer to take a message with the order number and a callback number." }
  ],
  "edges": [
    { "id": "go", "source": "ask", "target": "lookup", "condition": { "kind": "equation", "expr": "{order_id} != ''" } },
    { "id": "found", "source": "lookup", "target": "tell", "priority": 0, "condition": { "kind": "tool_result", "match": "ok" } },
    { "id": "failed", "source": "lookup", "target": "sorry", "priority": 1, "condition": { "kind": "fallback" } }
  ]
}

A person on request, from anywhere — with a fallback when nobody answers

{
  "id": "front_desk", "kind": "transfer", "name": "Front desk",
  "isGlobal": true,
  "globalCondition": "the caller asks to speak to a person",
  "globalExamples": ["can I talk to someone", "put me through to reception"],
  "config": {
    "destinations": [{ "label": "Reception", "numbers": ["+918045678900", "+918045678901"], "mode": "serial",
                       "betweenMessage": "Still trying — one moment." }],
    "message": "Of course — connecting you now.",
    "noAnswerMessage": "Sorry, nobody is free right now. Can I take a message?"
  }
}

Add an exit { "source": "front_desk", "target": "take_message", "condition": { "kind": "fallback" } } to handle a transfer that cannot start at all.

From an AI assistant

ChatGPT, Claude and Grok build this graph with create_flow_agent and change it with update_agent — checked exactly as above, with the warnings returned to the assistant. Steps that transfer calls or call APIs need the owner's Give agents tools permission. See Multi-step calls.