Workflows
Steps the agent walks through, and intents that interrupt them from anywhere in the call.
A workflow is a graph the agent walks while it talks: named steps, each with a line to say and the branches leading out of it. It replaces the flat system prompt as the source of truth for what happens next.
The difference shows up under pressure. A flat prompt asks a model to remember its agenda while improvising. A workflow makes the agenda structural: the same call runs the same way twice, and every branch is inspectable before you dial.
Steps
Each step carries a say line delivered verbatim and an instruction for what to do with the answer. Most also declare slots — small typed values the model extracts from the caller's reply.
{
"id": "confirm_time",
"kind": "message",
"say": "Does Thursday at 2pm still work for you?",
"slots": [
{ "key": "keeps_slot", "type": "enum", "enum": ["yes", "no", "question"] }
]
}A say line is delivered verbatim, so no instruction can make it come out in another language. A step that has to speak in more than one carries sayByLanguage and instructionByLanguage alongside the base text — see Multilingual agents.
Slots are what the step routes on. An edge is a condition plus a destination:
{ "id": "e_ask", "from": "confirm_time", "to": "answer_question",
"condition": { "type": "expression", "expr": "keeps_slot == 'question'" } }Each step also has one unconditional edge — where the call goes when nothing more specific matches.
Branching on call direction
Three call-context values are in the state store before the first turn, with no slot to declare: direction (inbound, outbound or scheduled_callback), caller_number and dialed_number. They carry the same values as the {{system.direction}}, {{system.caller_number}} and {{system.dialed_number}} prompt variables, and {{caller_number}} renders in a step's say line too. On a browser session with no phone leg they are absent, so !caller_number is true and any comparison against them is false.
An expression edge out of the entry step chooses the opening path. One agent can run a scripted outbound call and, when the same person calls back after a missed call, open by asking for a callback code instead:
{ "id": "e_callback", "from": "entry", "to": "ask_callback_code",
"condition": { "type": "expression", "expr": "direction == 'inbound'" } }The entry step's unconditional edge remains the outbound path. A slot or variable you declare under one of these names takes precedence over the builtin.
Globals: intents from any step
A normal edge belongs to one step. A global edge has no from, so it is evaluated from every step in the workflow. Declare an intent once and it is live for the whole call.
{
"globalEdges": [
{ "id": "g_cancel", "to": "cancel_booking",
"condition": { "type": "llm", "when": "asks to cancel the appointment" } },
{ "id": "g_human", "to": "transfer",
"condition": { "type": "llm", "when": "asks to speak to a person" } }
]
}Without one, a caller can only cancel at the step that happens to ask about it:
agent Please arrive ten minutes early and bring your insurance card…
caller Actually, I need to cancel it.
agent Parking is behind the building, and the entrance is on Oak Street… ← kept reading its script
caller I need to cancel the appointment.
agent Does Thursday at 2pm still work for you? ← finally the step that asksWith g_cancel declared, the same sentence lands wherever it is said:
agent Please arrive ten minutes early and bring your insurance card…
caller Actually, I need to cancel it.
agent No problem — Thursday at 2pm is cancelled. You'll get a text confirming it.A workflow may declare up to 8 global edges.
Which wins, the step or the global
The step's own edges are resolved first, but only on a turn that actually filled one of its slots. If the caller answered the step's question, the step keeps the turn. If they said something else, a matching global takes it.
That ordering matters in both directions. Globals evaluated last are unreachable mid-script. Globals evaluated first steal turns the step understood perfectly well — a caller answering "no" to do you have a referral? is not cancelling the appointment.
An expression edge pointing at the same step your unconditional edge already points at changes no routing, but it does claim the turn — which silently suppresses every global from that step. Delete it; the default already goes there.
Excluding a step
Where a global would misread a step's own answers, name that step in exceptNodeIds and the global stops firing there.
{ "id": "g_cancel", "to": "cancel_booking",
"condition": { "type": "llm", "when": "asks to cancel the appointment" },
"exceptNodeIds": ["answer_question", "cancel_booking"] }Branching on what a tool returned
A tool step can branch on the values its call returned, not only on whether it succeeded. After a successful call, the step's expression edges are checked first, in edge order, against the values its outputBindings captured. The first match wins. If none match, the tool_result success edge runs.
A question fetch that answers {"question": ""} when the list runs out can go straight to the wrap-up:
{ "id": "e_no_more", "from": "get_q", "to": "submit",
"condition": { "type": "expression", "expr": "question == ''" } }A binding the call did not return counts as missing for this check, so !question also matches a response with no question at all. Failure still takes the failure edge. Your tool doesn't need to throw to end the list.
Lines the caller must hear
A caller can talk over a step's say line, or the line can fail to reach them. The agent re-reads it: up to three times if the caller talked over it, once if it never played. After that it gives up, counts the line as asked, and takes the step's usual edges.
For an interview question, giving up means the call moves on to the next question with this one never heard. Set mustBeHeard on the step. When the agent gives up on that line, the call goes to unheardFallback and never takes the step's own edges:
{
"id": "say_q",
"kind": "message",
"say": "{{question}}",
"mustBeHeard": true,
"unheardFallback": "line_trouble"
}Point the fallback at a step that records the missed contact and ends the call:
{
"nodes": [
{ "id": "line_trouble", "kind": "tool", "label": "Log no contact",
"toolName": "No_Contact", "toolArgs": { "conversation_id": "{{session_id}}" } },
{ "id": "line_end", "kind": "end", "label": "Call back later",
"say": "Sorry, the line keeps breaking up. We'll call you back. Goodbye." }
],
"edges": [
{ "id": "e_lt_ok", "from": "line_trouble", "to": "line_end",
"condition": { "type": "tool_result", "on": "success" } },
{ "id": "e_lt_fail", "from": "line_trouble", "to": "line_end",
"condition": { "type": "tool_result", "on": "failure" } }
]
}mustBeHeard is off by default. A line the caller hears routes as usual. Without an unheardFallback, or with one that names no step, the agent gives up as before, and the write returns a warning naming the step.
How long an answer a step expects
With AssemblyAI as the speech-to-text provider, a step can set how long the caller may pause before their turn ends. Set answerLength on a message step:
open: a free answer, like an interview question. A pause after a full sentence does not end the turn (AssemblyAI turn silence min 1300 ms, max 3600 ms).short: a yes/no or one-word reply. The agent's own STT turn settings apply, so the reply comes fast.
{
"id": "say_q",
"kind": "message",
"say": "{{question}}",
"answerLength": "open"
}Unset, the step picks for itself: a free-text slot makes it open, slots that are all choices make it short, and a step with no slots whose line asks the caller to tell, describe or explain is open. Everything else is short. The setting switches when the step starts, before its line plays. Other STT providers ignore it. Pair it with turnHandling.turnDetection: "stt" so the STT's end of turn decides when the caller is done.
Reading and writing a workflow
GET /v1/agents/{id}/graph returns the live workflow, its version, and a revision you send back on write. PUT publishes a new one:
curl -X PUT https://api.speko.ai/v1/agents/$AGENT_ID/graph \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "version": 12, "ifRevision": 3, "graph": { "entryNodeId": "intro", "nodes": [], "edges": [] } }'version is the version you edited from. Publishing cuts a new agent version, so a workflow change moves the same number a config change does — which is how rollback can reach a previous workflow. A stale ifRevision answers 412 rather than overwriting whoever published in between.