Triage Agent Multi-step Intents: API Automation
Triage Agent intents can now collect information from the end user across multiple turns of a conversation. Your webhook decides when it has enough information to finish, or when to ask for more.
This article explains the webhook contract, how state is persisted, the guarantees and limits, and how to migrate an existing single-shot webhook.
Overview
An intent is an action the Triage Agent can perform on behalf of the end user (reset a password, provision a mailbox, restart a device, etc.). Historically each intent was single-shot: the agent gathered all the required arguments up front, called your webhook once, and returned the result to the user.
With multi-step intents your webhook can return a continue response instead of a terminal result. Thread will:
- Persist the state you send back (a continuation token, a list of new fields to collect, and everything gathered so far).
- Ask the user for the new fields in natural language, using its own phrasing.
- Call your webhook again with the newly collected fields plus everything from the earlier turns.
You never manage the AI prompt or the ticket state — you just describe what you need next.
When to use it
Reach for multi-step when:
- You cannot know all the required fields up front (a follow-up depends on the answer to the first question).
- You want to validate one field before asking for another (e.g., confirm the account exists before asking which mailbox to enable).
- Your workflow has branching logic that would be awkward to encode in a single argument schema.
Stay single-shot when the intent has a fixed argument set that can always be collected in one pass. Simpler is better.
The webhook contract
Request payload
Thread sends a JSON POST to your webhook URL. The payload shape is:
{
"intent_name": "reset_password",
"intent_fields": {
"email": "alice@acme.com"
},
"meta_data": {
"ticket_id": 88231,
"thread_id": 987654,
"ticket_board_name": "Support",
"ticket_board_id": "SUP",
"contact_id": "C-42",
"contact_name": "Alice Smith",
"contact_email": "alice@acme.com",
"contact_phones": [ /* ... */ ],
"company_id": "ACME-01",
"company_name": "Acme Corp",
"company_types": ["Managed"],
"configurations": [ /* ... */ ],
"type": "Access", "subtype": "Password", "item": "Reset"
},
"continuation_token": "opaque-token-you-returned-earlier"
}Notes:
intent_fieldskeys are alwayssnake_case.meta_dataincludes ticket, contact, company, and PSA-specific classification fields. Which PSA fields appear (type/subtype/itemfor ConnectWise,issue_type/sub_issue_typefor Autotask,category_1/ticket_typefor HaloPSA) depends on the workspace’s PSA.continuation_tokenis only present on resumed calls. On the first call for a new turn, this key is absent.
Terminal response (complete)
If your webhook has all the information it needs and finishes the action, respond with:
{
"message": "Your password has been reset. A temporary password was emailed to you.",
"internal_message": "Reset completed via Okta at 2026-07-09T14:22:00Z"
}messageis shown verbatim to the end user in chat.internal_messageis an optional note attached to the ticket for the technician. If omitted, Thread falls back toAction {intent_name}: {message}.- To signal an error while still terminating, return
{"error": {"message": "..."}}. The error message is delivered as the customer-facing response. - You may also explicitly set
"status": "complete"— it behaves identically and is useful for clarity.
Continue response (multi-step)
When you need more information, respond with:
{
"status": "continue",
"message": "I found the account, but it's protected by security questions.",
"continuation_token": "sess_8af2f1c9-c0de-4ee0-9b04-3d0d1fb6e2ab",
"form": {
"fields": [
{
"name": "last_login_year",
"description": "The year of your last successful login. Used to verify identity.",
"type": "number",
"is_required": true
}
]
}
}What each field does:
Field | Required | Purpose |
| Yes | Must be |
| Yes | Opaque string. Thread stores it and echoes it back on the next call so you can reload your session state. |
| Yes (non-empty) | The new fields you want collected on this turn. |
| Optional | A short preamble your webhook wants the user to see. Thread weaves it into its question. |
If status is "continue" but continuation_token is missing, or form.fields is empty or malformed, Thread downgrades the response to complete and delivers whatever message you sent. This is a safety fallback — a broken webhook can’t strand a user mid-flow.
Field schema
Each entry in form.fields:
{
"name": "field_name",
"description": "Human-readable description shown to the AI.",
"type": "text",
"type_data": null,
"is_required": true
}name(required) — the key Thread will use inintent_fieldson the next call.type(required) — the argument type to collect. Supported values:text,number,date,email,boolean,multiple.description— a natural-language description of the field. Used to phrase the question and to validate the user’s answer.type_data— optional. Only used bymultiple, where it carries the choice list as a nested array of{"id": "...", "value": "..."}objects. Ignored for other types.is_required— defaults totrueif omitted.
Fields missing a name or type are silently dropped. If all fields are dropped, the response falls back to complete (see above).
Examples per type
text — free-form string. Use for names, ticket subjects, error messages, anything unstructured.
{
"name": "error_message",
"description": "The exact error message shown on the user's screen.",
"type": "text",
"is_required": true
}number — numeric value (integer or decimal).
{
"name": "last_login_year",
"description": "The year of the user's last successful login (e.g. 2023).",
"type": "number",
"is_required": true
}date — a calendar date. User phrasing (“next Monday”, “July 9”) is normalized into a date.
{
"name": "start_date",
"description": "When the new mailbox should become active.",
"type": "date",
"is_required": true
}email — an email address. The shape is validated before it’s sent back.
{
"name": "manager_email",
"description": "Email address of the requester's manager, who must approve the access.",
"type": "email",
"is_required": true
}boolean — a yes/no answer.
{
"name": "is_urgent",
"description": "Whether the user is completely locked out and needs immediate reset.",
"type": "boolean",
"is_required": false
}multiple — one of a fixed set of choices. type_data carries the choice list as a nested array of {id, value} objects.
{
"name": "laptop_model",
"description": "Which laptop model the user wants. Options: MacBook Pro 14, MacBook Pro 16, Dell XPS 13.",
"type": "multiple",
"type_data": [
{ "id": "mbp14", "value": "MacBook Pro 14" },
{ "id": "mbp16", "value": "MacBook Pro 16" },
{ "id": "xps13", "value": "Dell XPS 13" }
],
"is_required": true
}Heads-up on
multiple. The follow-up prompt currently surfaces onlynameanddescription—type_datais stored on the pending step but not injected into the question. Include the choices verbatim indescription(as above) so the question is natural and the user’s answer is constrained.
State and continuation tokens
Thread persists the following per (ticket, intent, variation) on every continue response:
continuation_tokenpending_fields— the fields you just requestedaccumulated_arguments— everything collected across all turns of this run, mergedstep_number— incremented on each turn
You own the meaning of the continuation token. Thread treats it as opaque. Store any session state you need (e.g., the account you looked up, the challenge you issued, the vendor-side transaction ID) keyed by that token in your own datastore.
On the next call, Thread will send you:
continuation_token— the exact value you returned.intent_fields— everything gathered so far, including the new fields the user just answered.
Duplicate keys across turns are merged, with the newer value winning. If the user re-answers a previously-collected field, the newer answer overrides.
Limits and guarantees
Max iterations per intent invocation | 10 |
At the cap | Thread delivers your workspace’s escalation message (or, if none is set, “I’ll pass this to a technician.”), then disengages the agent and hands the ticket to a technician. The pending state is cleared. |
Timeout per webhook call | ~60s (same as single-shot). |
Retries | None. Design your webhook to be idempotent per |
Cross-ticket isolation | Pending state is scoped to |
Abandoned flows | State older than the configured pruning window is garbage-collected. If a user returns after the row is pruned, the intent starts fresh. |
Example: password reset with identity challenge
Turn 1 — user says: “I forgot my password.”
Thread calls your webhook:
POST /hooks/reset-password
Content-Type: application/json
{
"intent_name": "reset_password",
"intent_fields": {
"email": "alice@acme.com"
},
"meta_data": { "thread_id": 987654, "contact_email": "alice@acme.com", ... }
}
Your webhook finds the account, sees it’s protected, and responds:
{
"status": "continue",
"message": "I found your account, but I need to verify your identity before resetting the password.",
"continuation_token": "sess_8af2",
"form": {
"fields": [
{
"name": "last_login_year",
"description": "The year of your last successful login.",
"type": "number",
"is_required": true
}
]
}
}Thread asks the user in chat, e.g. “Before I reset your password, I need to verify your identity — do you remember the year of your last successful login?”
Turn 2 — user says: “I think it was 2023.”
Thread calls your webhook again with everything so far:
{
"intent_name": "reset_password",
"intent_fields": {
"email": "alice@acme.com",
"last_login_year": 2023
},
"meta_data": { /* ... */ },
"continuation_token": "sess_8af2"
}Your webhook uses sess_8af2 to look up the challenge it issued, validates last_login_year, and completes the reset:
{
"message": "Your password has been reset. A temporary password was emailed to you.",
"internal_message": "Reset completed for alice@acme.com after year-of-last-login challenge."
}Thread delivers the message to the user and clears the pending state for this ticket.
Backwards compatibility
Legacy webhooks that don’t know about status continue to work unchanged. Thread treats any response without status, or with status set to anything other than continue, as terminal.
If you’re migrating an existing webhook to multi-step:
- Keep returning your current terminal shape when you have everything you need.
- Add the
continuebranch only where you actually want to defer to another turn. - There is no configuration to flip — the moment your webhook returns
status: continue, Thread handles the rest.
Best practices
- Keep
continuation_tokenopaque and short. A UUID or short random string is ideal. Thread doesn’t inspect it, but it’s stored as text. - Store your session state on your side, keyed by the token. Don’t try to smuggle state through
pending_fields— those are for describing what to collect, not for round-tripping your own data. - Ask for one to three fields at a time. Long forms are a bad fit for a chat experience — the AI will try to gather them all in one message, and users get lost.
- Write field
descriptions for the AI, not for the user. The AI will rephrase — you just need to describe the meaning clearly enough that it can ask a natural question and validate the answer. - Include a
messageon the firstcontinue. Even one sentence helps ground the question in what your system just found. - Make each webhook call idempotent per
continuation_token. If Thread re-invokes you for the same token, the outcome should be the same. - Design for the 10-step cap. If your workflow needs more than 10 turns of back-and-forth, split it — the intent name is what the agent matches against, so multiple related intents work better than one very long flow.
- Return
complete(or a terminal response) as soon as you have enough. Every extracontinuecosts the user an extra chat turn.
FAQ
What happens if the user changes topic mid-flow? The pending state remains persisted. If the same intent is picked again later in the same ticket, it resumes with the accumulated arguments. If the user’s next message triggers a different intent, that one runs independently — the pending state for the first intent is left in place and can still be resumed.
Can I return continue on the very first call? Yes. There is no difference between “first call” and “resumed call” from the response side — return whatever fits the state you’re in.
What if I need to cancel a flow mid-way? Return a terminal response with an appropriate message (e.g., “That request has been cancelled — let me know if you’d like to try again.”). Thread clears the pending state on any terminal response.
The user’s answer to my last question didn’t come through — what did Thread send? Whatever was extracted from the user’s reply for the field names in your last form.fields. If a field is missing from the next intent_fields, a value couldn’t be extracted. Return continue with the same field (and a clearer description or message) to ask again.
Can I re-request a field the user already answered? Yes. Return it in form.fields again — the newer value will overwrite the accumulated one on the next call.
Are ports supported in webhook URLs? Yes. Explicit ports (https://hooks.example.com:8443/webhook) are preserved as configured.