ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Using the dashboard

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 whenKeep 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

FieldMeaning
idRequired. Lowercase letters, digits and underscores, starting with a letter, up to 40 characters. Unique within the agent.
nameRequired display name, 1 to 80 characters.
instructionsRequired, up to 20,000 characters. Supports {{var}} call variables, like prompt.
sayOptional line spoken word for word when the step is entered, up to 1,000 characters. Supports {{var}}.
toolsnull 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.
collectUp to 20 values the step gathers. Each has a name, a type, a description and required (default true).
on_enter_toolOptional tool name, called automatically when the step is entered. See Webhook steps.
transitionsOrdered list of where the step can go next. See Transitions.
global_whenOptional. Makes the step reachable from any step. See Global steps.
voiceOptional 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-in go_to_step tool listing the steps it may move to, with each when as 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 matching condition or always transition wins, before the model can pick a when. So put always after the conditions as their fallback, or alone to chain a step straight into the next one. A step with always never waits for a when choice.

A condition is {"value": <collected name or dotted path>, "op": <op>, "target": <literal>}:

opTrue when the value
eq, neequals, or does not equal, target
lt, lte, gt, gteis less than, at most, greater than, or at least target
in, not_inis, or is not, one of the values in the target list
exists, missinghas, 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:

  1. Every required value in the step must be present. If one is missing, the model is told which one, and asks the caller for it.
  2. The values are stored.
  3. condition and always transitions are checked in order. The first match wins.
  4. Otherwise the agent moves to the step the model chose.
  5. 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.

typeHolds
stringAny text
numberA number
booleantrue or false
dateA 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:

FieldHolds
statusThe HTTP status code your endpoint returned.
responseThe 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:

viaMeaning
startThe call began in this step.
modelThe model chose the step through go_to_step, a when or a global_when.
conditionA condition or always transition matched.
on_enterA transition matched on a webhook step's result.
handoffAnother 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_id uses that agent's steps.

Limits

LimitValue
Steps per agent100
Collected values per step20
Handoff targets per agent8
Agents reachable in one call8
Size of the steps JSON200 KB
Step instructions20,000 characters
say line1,000 characters
Stored on_enter_tool result4 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"
}

Next steps