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
}
}
| Field | Meaning |
|---|---|
valid | true when the graph will run. |
errors | Exactly what makes a call discard the graph (Errors). Fix every one before saving. |
warnings | The 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. |
multiContext | false 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 inmetadata.flow. - Change:
PUT /api/agents/{id}withmetadata. The Agents API replacesmetadatawhole — read the agent, changemetadata.flow, and send the completemetadataback, or other settings stored there (variables, analysis, memory, tools) are lost. - Single → flow: add
metadata.flow. Flow → single: setmetadata.flowtonull. - Drafts:
metadata.flowDraftis the flow builder's unpublished copy. It never runs; onlymetadata.flowdoes. Writingflowis publishing. Thestatus=draftlist filter shows agents that have aflowDraft(orconfigDraft). - 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.flowis ignored and every agent runs single-context.
The graph
metadata.flow:
| Key | Type | Default | Meaning |
|---|---|---|---|
schema | integer | 1 | The format version. Must be a number — "v1" discards the graph. |
startNodeId | string | — | Required. The step every call starts on. |
nodes | array | — | Required, at least one. The steps (Steps). |
edges | array | [] | The exits (Exits). |
routerModel | string or object | the platform model | The model that decides ai exits and global jumps — see below. |
navigateInline | boolean | follow the platform | false 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:
| Value | Router |
|---|---|
absent, "platform", or any other string | The 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
| Key | Type | Default | Meaning |
|---|---|---|---|
id | string | — | Required, unique. What exits point at. |
kind | string | "conversation" | One of the 13 kinds. |
name | string | — | 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. |
prompt | string | — | 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. |
entryMessage | string 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). |
continueAfterEntry | boolean | false | After 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. |
extract | array | — | Variables to capture from the conversation (Captured variables). Any kind. |
model | object | inherit | This step's language model: { provider, model, temperature?, maxTokens?, extra? }. A provider change drops the agent's own credentials for the old provider. |
voice | object | inherit | This step's voice: { provider, voice, extra? }. |
s2sVoice | string | inherit | This step's realtime voice, on an agent that already runs a realtime (speech-to-speech) model. |
behavior | object | inherit | Replaces the agent's whole behavior block on this step — see Behaviour. |
knowledgeBaseIds | array of ids | inherit | Which of the agent's knowledge bases this step searches. [] = none. Only narrows: a base the agent is not attached to is ignored. |
knowledgeDocumentIds | object | — | Per base: { "<kbId>": ["<docId>", …] } — only these documents; [] = none from that base; a base left out = all of it. |
tools | array of tool specs | inherit | Replaces the agent's tools on this step ([] = no tools). Same shape as metadata.tools (Tools & function calling). |
guards | object | — | Per-step call handling — see Guards. |
allowSelfNavigate | boolean | false | Gives 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, globalPreventRetriggerSteps | Make the step reachable from anywhere — see Global steps. | ||
saves | array 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). |
sends | array of names | all | tool steps: the only variables sent to the API. |
waitMode, waitLines, endOfCallPolicy | tool steps: how the step waits — see Function steps. | ||
config | object | — | 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
| Kind | Builder name | Waits for the caller? | What it does | config |
|---|---|---|---|---|
conversation | Conversation | Yes | The agent talks with the caller on this step's instructions. | — |
subagent | Subagent | Yes | The same as conversation; give it its own tools. | — |
extract | Extract Variable | Yes | The same as conversation, meant for collecting extract variables (any step can collect). | — |
dtmf | Press Digit | Yes | A keypad menu: put the menu in entryMessage, route with dtmf exits. | — |
static | Say | No | Says entryMessage, waits for it to finish, moves on. | — |
play_audio | Play Audio | No | Says entryMessage (if any), plays a recording from your audio library, moves on. | trackId |
tool | Function | No | Calls an API, stores the result as variables. | a tool spec |
code | Code | No | Runs sandboxed JavaScript over the variables. | source, … |
router | Logic Split | No | Branches. Says its entryMessage if it has one (usually none); its exits route. | — |
transfer | Call Transfer | Ends the flow | Transfers the call to a phone number. | destinations, message, noAnswerMessage |
agent | Agent Transfer | Ends the flow | Hands the call to another AI agent. | agents / targetAgentId, message |
end | Ending | Ends the call | Says entryMessage, then hangs up. | — |
note | Note | — | 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
dtmfexit 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, nostoreAs— 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.keypadwithdigitLimitandstoreAs.
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 key | Meaning |
|---|---|
trackId | Required. 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:
| Key | Where | Meaning |
|---|---|---|
sends | step | The variables sent as arguments (default: every variable the call holds, except names starting with _). |
config.config.argSources | tool | Per 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.responseMap | tool | [{ "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):
| Key | Values | Meaning |
|---|---|---|
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 key | Default | Meaning |
|---|---|---|
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. |
timeoutMs | 2000 | 100–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 key | Meaning |
|---|---|
destinations | Required. [{ "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. |
message | Said before the transfer (cascade calls). |
noAnswerMessage | What 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 key | Meaning |
|---|---|
agents | [{ "agentId": "<uuid>", "label"?, "mode"?: "transfer" | "connect" }] — the first valid id is used. |
targetAgentId | The older single-target form. |
message | Said 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 kind | What happens |
|---|---|
conversation, subagent, extract | Nothing more — the step's own entryMessage is not said; the conversation starts on its instructions. |
dtmf | The menu (entryMessage) is read right after the greeting. |
static, play_audio, tool, code, router | Run 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:
| Key | Meaning |
|---|---|
bargeInSensitivity | 0.0–1.0: how easily the caller can interrupt on this step. |
directedSpeechGate | true: noisy-room mode on this step. |
silenceHangupSecs | Hang 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. |
reasoning | The step's reasoning setting for reasoning models ({ enabled, effort } or a token budget), as in llmConfig. |
delayedResponse | true/false, or the agent-level delayedResponse object — the "thinking" hold on this step. |
s2sVoice | Same as the step's s2sVoice. |
dtmfRouteOnly, keypadOnly | Keypad 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
| Key | Type | Default | Meaning |
|---|---|---|---|
id | string | — | Required, unique. |
source | string | — | Required. A step id, or "*" — an exit from every step. |
target | string | — | Required. A step id. |
label | string | — | The option's name — what navigate calls it and what campaign exports show for a keypress. |
priority | integer | 0 | Lower is tried first; equal priorities keep the array's order. Exits that could both match should not share a priority (the checker warns). |
condition | object | { "kind": "always" } | When this exit is taken — below. |
replyContext | string | — | 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
kind | Needs | Taken when |
|---|---|---|
always | — | Always — a straight-through step. |
equation | expr | The expression on variables is true (Equations). |
tool_result | match (optional) | The step's call ended a given way (Tool results). |
fallback | — | No always / equation / tool_result exit matched. |
dtmf | digit | The caller's latest keypress is this digit. |
ai | describe | The 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 wordscontains,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} >= 18works, but to branch on an answer use anenumand quotes —{patient_type} == 'new'.
Tool results
match is a string:
match | Taken 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
endin 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
extractvariable markedrequired, one tested by the step's ownequationexits, or one a Function step'sargSourcesreads. The agent asks for it, up to twice per value. Keypad exits, global jumps and exits intoend,transferandagentare never held.
Global steps
- An exit with
source: "*"is an exit from every step. - A step with
isGlobal: trueadds itsaianddtmfexits to every other step. globalCondition(with optionalglobalExamples) 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/fallbackexits 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? }].
| Key | Meaning |
|---|---|
name | The variable's name. |
type | string (default), number, boolean or enum. Values are stored as text whatever the type. |
values | With enum: the allowed answers. The caller's words are matched to one of them and stored in your spelling. |
description | What to capture — the more precise, the better. |
required | The step owes it: forward exits wait until it is filled (When an exit waits). |
format | email, 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
| Source | Variables |
|---|---|
| The call | The 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 keypad | guards.keypad.storeAs. |
| Function and code steps | Their results; _tool_status, _tool_timed_out, _code_status, _code_error. |
| Transfers and hand-offs | transfer_failed_reason, transfer_failed_error on a failure. |
| Always | current_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:
| Message | Fix |
|---|---|
flow has no nodes | Add 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 + model | A step's model needs both. |
transfer node '…' needs at least one destination phone number | config.destinations[].numbers. |
agent-handoff node '…' needs at least one target agent | config.agents[].agentId. |
play audio node '…' needs an audio track selected | config.trackId, a recording's id. |
startNodeId is not set / startNodeId '…' does not match any node | Point 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 node | source 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 digit | Fill 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 }:
| Code | What it means |
|---|---|
dead_step | A non-waiting step has no exit to another step — the call stops there. |
strandable_step | A non-waiting step's exits are all variable tests that might all be false, with no fallback. |
unreachable_exit | An ai/dtmf exit on a non-waiting step that also has an always/fallback exit — it can never be taken. |
silent_arrival | A conversation step with no entryMessage is reached right after a silent step — the caller hears nothing. |
end_message_never_spoken | An end step with config.message but no entryMessage — it hangs up in silence. |
keypad_menu_without_a_catch_all | A route-only keypad menu with no fallback and nothing to re-read. |
continue_after_entry_without_line / …_on_passthrough / …_over_a_question | continueAfterEntry with no line, on a step that does not wait, or after a line that asks a question. |
enum_without_values | An enum capture without values. |
rule_value_not_an_option | An equation compares an enum capture with a value it does not list. |
rule_on_free_text | An equation compares a free-text capture with exact text — likely never equal. |
unknown_variable, unwritten_variable, edge_tests_unproduced_variable | A variable is read that nothing collects, declares or returns. |
missing_braces | An equation uses a variable's name without braces. |
variable_name_has_space | An equation names a variable with a space. |
empty_string_compare | An equation compares with '' — true until the value is set. |
ignored_expression | An always/fallback exit carries an expr, which is ignored. |
vague_ai_condition | An ai description under three words. |
tied_priorities | Two rule exits from one step that could both match share a priority. |
describe_expr_divergence | An equation exit's description mentions a variable its expression does not test. |
tool_result_without_a_tool | A tool_result exit on a step that never calls anything. |
timeout_exit_off_a_function_step | A "timeout" exit that does not leave a Function step. |
unbound_required_param | A tool's required parameter has no source. |
fixed_arg_type_mismatch | A fixed argument's type differs from the parameter's. |
pinned_money_amount | A payment action with a fixed amount. |
generic_response_variable | A result stored under a generic name (id, status, error, …) — likely to collide. |
prompt_names_other_tool | A Function step's prompt names another step's tool. |
reply_context_too_long / reply_context_not_listed / reply_context_asks_before_a_line | replyContext too long, on an exit never shown, or asking a question where none should be asked. |
stt_override_does_nothing / skip_response_does_nothing | A field that has no effect. |
Limits
| Limit | Value |
|---|---|
| Steps / exits per graph | 100 / 300 |
| Steps run between two caller turns | 25 |
| Code steps run between two caller turns | 5 |
| Failed transfers or hand-offs followed in a row | 3 |
| Hand-offs per call | 5 |
| New variables per call | 256 |
| AI router decisions | rate-limited per call (60 a minute by default) |
| Function step timeout | 1–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 calls | Realtime (speech-to-speech) | Chat, WhatsApp | |
|---|---|---|---|
| Routing | After each caller turn, as above | Only when the realtime model calls navigate (it is offered every exit) | As on calls |
| Lines | Spoken | Spoken by the realtime model | Sent as text |
transfer / agent / end | Run | Run | A 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.