Open in
Agent steps and handoffs
Split an agent into steps with their own instructions, tools and collected values, and hand calls between agents mid-call.
A single prompt gives the model the whole job at once. Steps give it one phase of the call at a time: each step has its own instructions, its own tools, an optional line spoken word for word, and values it must collect before moving on. Handoffs let another agent in your organization take over mid-call, with the transcript intact, so the caller never repeats themselves.
Every agent starts as a single prompt. Nothing about it changes until you add steps or handoffs.
Steps or a single prompt?
| Use steps when | Keep a single prompt when |
|---|---|
| The call has phases that must happen in order: verify, then book. | The conversation is open-ended: FAQ, reception, general support. |
| A value must be captured before the agent moves on. | Nothing has to be collected in a fixed order. |
| A rule must never bend, such as "callers under 18 go to a guardian". | The model's judgment is good enough for every branch. |
| A lookup has to run at a fixed point in the call. | Tools can be called whenever the caller asks. |
| Part of the call belongs to a different agent, with its own prompt and voice. | One persona handles everything. |
Steps are not a scripted phone tree. Inside a step the model runs the conversation as it does with a single prompt. The step decides what it is working on, which tools it can use and what it needs before it can leave.
How a stepped agent is built
When an agent has steps, its prompt holds the shared instructions: the
persona and rules that apply in every step. Each step adds its own
instructions on top. On every turn the model sees the shared instructions, the
current step's name and instructions, and the values collected so far.
In the builder, switch the agent from Single prompt to Steps. You get a card per step with its name, instructions, say line, tools, collected values and transitions, a handoffs picker, and a read-only flow graph of the steps. Switching back to a single prompt offers a flattened prompt that merges the shared instructions and every step.
Over the API, steps and handoffs are fields on the
agent. Like every agent field, a PATCH stages them
in the draft; POST /v1/agents/{agent_id}/deploy makes them live. Sending
"steps": null switches the agent back to a single prompt. Test calls and
Talk run the draft.
Step fields
| Field | Meaning |
|---|---|
id | Required. Lowercase letters, digits and underscores, starting with a letter, up to 40 characters. Unique within the agent. |
name | Required display name, 1 to 80 characters. |
instructions | Required, up to 20,000 characters. Supports {{var}} call variables, like prompt. |
say | Optional line spoken word for word when the step is entered, up to 1,000 characters. Supports {{var}}. |
tools | null gives the step every tool attached to the agent. A list limits the step to those tools, by name. Built-in actions such as end_call and transfer_call follow the agent's settings in every step. |
collect | Up to 20 values the step gathers. Each has a name, a type, a description and required (default true). |
on_enter_tool | Optional tool name, called automatically when the step is entered. See Webhook steps. |
transitions | Ordered list of where the step can go next. See Transitions. |
global_when | Optional. Makes the step reachable from any step. See Global steps. |
voice | Optional voice id for this step. Ignored when the agent uses a custom voice configuration. |
The graph itself is {"start_step": "<step id>", "steps": [...]}. The call
begins in start_step.
Transitions
Each transition has to, a step id, and exactly one of:
when: plain language, and the model decides. The agent gets a built-ingo_to_steptool listing the steps it may move to, with eachwhenas the reason to pick it.condition: a rule on collected values, and code decides. The model cannot skip it or talk its way around it."always": true: move on without a check. Code decides first: as soon as a step is entered, or once its required values are collected, the first matchingconditionoralwaystransition wins, before the model can pick awhen. So putalwaysafter the conditions as their fallback, or alone to chain a step straight into the next one. A step withalwaysnever waits for awhenchoice.
A condition is {"value": <collected name or dotted path>, "op": <op>, "target": <literal>}:
op | True when the value |
|---|---|
eq, ne | equals, or does not equal, target |
lt, lte, gt, gte | is less than, at most, greater than, or at least target |
in, not_in | is, or is not, one of the values in the target list |
exists, missing | has, or has not, been collected (no target) |
eq, ne, in and not_in compare text case-insensitively after trimming
spaces. Numbers compare as numbers and dates as YYYY-MM-DD strings. A value
that was never collected makes every op false except missing.
Write when and global_when as a condition, such as "The caller wants to
book an appointment", not as something to say. The agent sees them as internal
routing notes and is told never to read them out or mention that it is
changing steps.
When the model calls go_to_step:
- Every
requiredvalue in the step must be present. If one is missing, the model is told which one, and asks the caller for it. - The values are stored.
conditionandalwaystransitions are checked in order. The first match wins.- Otherwise the agent moves to the step the model chose.
- If nothing matches and the model named no step, the agent stays where it is.
A jump to a global step is the exception: the caller changed topic, so it goes ahead even when the current step still has required values missing, and before that step's conditions are checked.
condition and always transitions are also checked every time a step is
entered, so a run of code-decided steps plays out in one go. The chain stops:
- at a step it has already visited on this run;
- after 8 hops;
- at a step that still has required values to collect, unless the step has an
on_enter_tool(a webhook step always routes on its result).
Only the say line of the step where the chain stops is spoken.
A step with no transitions is terminal. The agent finishes there and ends or transfers the call with its normal built-in actions.
Collecting values
List what a step needs in collect. The model passes the values when it calls
go_to_step, and they stay available for the rest of the call.
type | Holds |
|---|---|
string | Any text |
number | A number |
boolean | true or false |
date | A date as YYYY-MM-DD |
Collected values are call content. They appear on the call next to the transcript and follow the same retention, deletion and redaction rules. When the agent's call recording is turned off, collected values are neither stored nor sent in webhooks; the step timeline still is. See Where to see it on a call.
Say lines
say is spoken exactly as written when the agent enters the step. The model
then carries on in the new step in the same reply, knowing the line was just
said, so it does not repeat it. Use say for greetings, required disclosures
and fixed wording, and leave the rest of the reply to the model.
The start step's say is the agent's opening line when it speaks first, after
any consent announcement. When the caller speaks first, it opens the agent's
first reply to a person, never to an IVR or a voicemail greeting.
A step's say is not spoken again when the call returns to it through
__previous__ or a hand-back. Write it the way it should sound: spell out
anything the voice could misread. Say lines support {{var}} call variables
but not collected values.
Webhook steps
Set on_enter_tool to one of the agent's tools and it runs as soon as the step
is entered, without waiting for the model. Its arguments are the collected
values whose names match the tool's parameter names. The result is stored as a
collected value named <tool>_result, truncated to 4 KB, and the step's
condition and always transitions are checked straight away, so the lookup
can route the call on its own result.
The stored value has two fields:
| Field | Holds |
|---|---|
status | The HTTP status code your endpoint returned. |
response | The response body. |
Route on a body field with lookup_account_result.response.<field>, and on
the status code with lookup_account_result.status.
The lookup runs for up to 8 seconds, or the tool's own timeout if that is shorter. If it takes longer than 3 seconds, the agent says a short line so the caller is not left in silence. If your endpoint returns a status of 400 or above, or does not answer in time, the agent tells the caller it could not check that just now and carries on with what it can do.
Global steps
A step with global_when can be reached from any step whenever that
description holds. The model decides, as with when. Use it for topics a
caller can raise at any point: billing questions, a request for a human, a
complaint.
A transition with "to": "__previous__" on a global step returns the call to
the step that was active before it, so the caller picks up where they left
off. The step resumes: its on_enter_tool does not run again, its say is
not repeated, and the agent continues it without starting over.
Handoffs
handoffs lists other agents in your organization that can take over the call:
[{"agent_id": 413, "when": "The caller asks about billing or a refund"}]The agent gets a built-in hand_off tool with those agents and reasons. As
with transitions, each when is an internal routing note, never read out.
When it hands off, the agent says one short line telling the caller where they are going, such as "Sure, I'll send you over to our billing team", in its own voice. Then the target agent's prompt, steps, tools and voice take over and the call continues with the same transcript. The new agent introduces itself briefly and answers the caller's last request; it does not announce a transfer. The target's knowledge base is available from then on.
The first time a call reaches an agent, it starts in that agent's
start_step: the step's on_enter_tool runs and its say is spoken. When
the call is handed back to an agent it already visited, that agent resumes the
step it was on, without running on_enter_tool, repeating its say or
introducing itself again. Collected values are shared by every agent on the
call.
- A target must be an agent in the same organization, and an agent cannot hand off to itself.
- Everything call-level stays with the agent that answered: product, languages, who speaks first, voicemail, consent announcement and every other call setting.
- Targets can hand off further, and back. One call can reach up to 8 agents.
- A test call runs the draft of the agent you test. The agents it hands off to run their deployed versions.
- If two agents have a tool with the same name and different definitions, only one is kept for the call, and the answering agent's always wins. The builder warns you when you save.
- An agent can have handoffs without steps. Its whole prompt is used as-is.
go_to_step and hand_off are reserved on agents with steps or handoffs, so
your own tools cannot use those names.
Where to see it on a call
Open the call in Call History. A step timeline next to the transcript shows each step the call entered and why, and the collected values are listed with it.
The call object and the call-complete
webhook (telephony.complete / web.complete) carry
the same data as steps_trace and collected_values. Both are absent on calls
without steps.
"steps_trace": [
{"t_ms": 0, "agent": "a412", "step": "greet", "via": "start"},
{"t_ms": 51230, "agent": "a412", "step": "verify", "via": "model"},
{"t_ms": 98410, "agent": "a412", "step": "minor", "via": "condition"}
],
"collected_values": {"full_name": "Dana Reyes", "dob": "2010-04-02", "age": 15}agent is a followed by the agent id. via says what moved the call:
via | Meaning |
|---|---|
start | The call began in this step. |
model | The model chose the step through go_to_step, a when or a global_when. |
condition | A condition or always transition matched. |
on_enter | A transition matched on a webhook step's result. |
handoff | Another agent took over. |
Where steps run
Steps run natively on Spark, Bolt and every Storm product, on inbound, outbound and campaign calls, test calls, and web and widget calls.
Everywhere else the agent still works, just without the structure: it runs a flattened prompt that merges the shared instructions and every step, in order. That covers:
- the experimental Bolt products;
- Realtime sessions, including sessions for a saved agent;
- inline configurations returned by a dynamic call config
webhook. A webhook that returns an
agent_iduses that agent's steps.
Limits
| Limit | Value |
|---|---|
| Steps per agent | 100 |
| Collected values per step | 20 |
| Handoff targets per agent | 8 |
| Agents reachable in one call | 8 |
Size of the steps JSON | 200 KB |
Step instructions | 20,000 characters |
say line | 1,000 characters |
Stored on_enter_tool result | 4 KB |
A save that breaks a rule returns 400 with the path of the bad field, such
as steps.steps[2].transitions[0].to. Tool names in tools and
on_enter_tool must be tools attached to the agent.
Per-step languages, scripted steps that skip the model, and collected values inside say lines are not supported.
Imported agents
Importing from Retell, Bland, Vapi or ElevenLabs brings multi-prompt agents, flows, pathways and workflows over as steps, and squads and agent transfers over as handoffs between the imported agents. The review page shows both before you import.
Example: dental intake
Three steps in order: greet, collect the patient's details, book.
{
"prompt": "You are Ava, the front desk assistant at Acme Dental. Be warm and brief. Ask one question at a time.",
"steps": {
"start_step": "greet",
"steps": [
{
"id": "greet",
"name": "Greeting",
"instructions": "Greet the caller and ask how you can help.",
"say": "Thanks for calling Acme Dental, this is Ava. How can I help you today?",
"tools": null,
"collect": [],
"transitions": [
{"to": "details", "when": "The caller wants to book an appointment"}
]
},
{
"id": "details",
"name": "Patient details",
"instructions": "Ask for the caller's full name, date of birth and the reason for the visit. Read the date of birth back and ask the caller to confirm it.",
"tools": null,
"collect": [
{"name": "full_name", "type": "string", "description": "Caller's full name", "required": true},
{"name": "dob", "type": "date", "description": "Date of birth", "required": true},
{"name": "reason", "type": "string", "description": "Reason for the visit", "required": true},
{"name": "new_patient", "type": "boolean", "description": "First visit to the practice", "required": false}
],
"transitions": [
{"to": "book", "when": "The caller has confirmed their details"}
]
},
{
"id": "book",
"name": "Book",
"instructions": "Offer the two earliest open slots from book_slot and book the one the caller picks. Confirm the date and time, then say goodbye and end the call.",
"tools": ["book_slot"],
"collect": [],
"transitions": []
}
]
}
}Stage it with PATCH /v1/agents/{agent_id}, try it with Talk, then deploy.
The details step cannot hand over to booking until the name, date of birth
and reason are collected; new_patient is optional.
Example: an age-gated branch
The model can talk to anyone, but code decides who gets booked. Collect the age, then route on it before the model's own choice is considered.
{
"id": "verify",
"name": "Verify age",
"instructions": "Ask for the caller's age.",
"tools": null,
"collect": [
{"name": "age", "type": "number", "description": "Caller's age in years", "required": true}
],
"transitions": [
{"to": "guardian", "condition": {"value": "age", "op": "lt", "target": 18}},
{"to": "book", "when": "The caller has given their age"}
]
}A caller who says fifteen goes to guardian, whatever the model decides.
Everyone else goes to book. Both are ordinary steps elsewhere in the graph.
Example: account lookup webhook step
The caller gives an account number, the lookup runs on entering the next step,
and its result picks the route. The not_found, collections and support
steps are left out for brevity.
[
{
"id": "identify",
"name": "Identify",
"instructions": "Ask for the caller's account number and read it back digit by digit.",
"tools": null,
"collect": [
{"name": "account_number", "type": "string", "description": "Account number", "required": true}
],
"transitions": [
{"to": "lookup", "when": "The caller has confirmed their account number"}
]
},
{
"id": "lookup",
"name": "Look up account",
"instructions": "Tell the caller what you found on their account and ask how you can help.",
"tools": ["lookup_account"],
"collect": [],
"on_enter_tool": "lookup_account",
"transitions": [
{"to": "not_found", "condition": {"value": "lookup_account_result.response.found", "op": "eq", "target": false}},
{"to": "collections", "condition": {"value": "lookup_account_result.response.account_status", "op": "in", "target": ["past_due", "suspended"]}},
{"to": "support", "when": "The caller needs help with an active account"}
]
}
]lookup_account receives account_number because its parameter has that
name. If the response body has "found": false, the call goes to
not_found. If its account_status is past_due or suspended, it goes to
collections. An active account stays in lookup, where the model helps and
moves on to support. To send a failed lookup to its own step, add
conditions on the status first: an error response stores its status code, so
{"value": "lookup_account_result.status", "op": "gte", "target": 400}
catches it, and a timeout or no answer stores "error", so
{"value": "lookup_account_result.status", "op": "eq", "target": "error"}
catches that.
Example: front desk to billing
Two agents. The front desk (agent 412) answers every call and hands billing
questions to a billing agent (agent 413) with its own prompt, tools and voice.
On the front desk:
{
"handoffs": [
{"agent_id": 413, "when": "The caller asks about a bill, a payment or a refund"}
]
}On the billing agent, so it can hand the caller back:
{
"handoffs": [
{"agent_id": 412, "when": "The billing question is resolved and the caller needs something else"}
]
}The front desk says a short line such as "Let me get you to our billing team", and the billing agent introduces itself and picks up the conversation where the front desk left it. When billing hands back, the front desk resumes where it was, without greeting the caller again. The call's product, languages, consent announcement and other call settings stay with the front desk, because it answered.
If billing only needs its own instructions and not its own agent, use a global step on the front desk instead. When the question is answered, the call goes back to whatever step the caller was in:
{
"id": "billing",
"name": "Billing questions",
"instructions": "Answer questions about bills and payments. Offer to send a payment link by email.",
"tools": null,
"collect": [],
"transitions": [
{"to": "__previous__", "when": "The billing question is answered"}
],
"global_when": "The caller asks about a bill or payment at any point"
}