Await Reply Node
This content is not available in your language yet.
The await_reply node asks the user something, ends the current turn, and continues the same workflow from that node’s successor when the next message on the session arrives. Nothing is held open while it waits — no job, no worker, no socket — and the front half of the graph does not re-run.
This is the node to reach for whenever a flow genuinely needs an answer before it can continue: picking one of several records a partner API returned, confirming a destructive action, collecting one missing field. Before this node existed, every inbound message restarted the graph at start, so “where were we?” had to be re-derived every turn — usually by paying an LLM node to read the conversation history back and guess.
See Workflow Overview for the node/edge envelope this fits into.
Config fields
Section titled “Config fields”AwaitReplyNodeConfig:
| Field | Type | Required | Description |
|---|---|---|---|
promptTemplate | string | Yes | The question, as a Handlebars template — rendered and spoken exactly like a response node’s messageTemplate, so it is a real, persisted assistant message that shows up in the transcript. |
saveAs | string | Yes | The context variable the answer lands in when the run resumes. Validated against the reserved-key list, same as any other node’s outputVar. |
matchMode | 'free_text' | 'options' | No (default free_text) | free_text accepts any reply as the answer. options matches the reply against a fixed list — see below. |
optionsFrom | string | options mode only | Dot-path into the run context naming the array to build the option list from, e.g. jobPostings.data. |
optionIdField | string | No (default id) | Field on each array element holding its identifier. |
optionLabelField | string | No (default label) | Field on each array element holding its display label. Labels are truncated to 80 characters when the list is rendered. |
expiresAfterSeconds | number | No (default 86400) | How long the wait stays resumable. 24 hours by default, clamped to a hard ceiling of 7 days. |
mood | string | No | Passed through to the outbound message, same as ResponseNodeConfig.mood. |
Edge labels: answered and unanswered
Section titled “Edge labels: answered and unanswered”Routing lives on the edges, the same way it does for condition’s true/false and tool/action’s success/failure:
answered— a reply arrived, and inoptionsmode it matched one of the offered options. Required on everyawait_replynode, in both modes.unanswered—optionsmode only: a reply arrived and matched nothing unambiguously. Required whenmatchModeis'options'.
Both rules are enforced when the workflow is saved or published, not discovered mid-conversation. Flipping a node from free_text to options without adding an unanswered edge is rejected with:
AWAIT_REPLY node "ask_which_order" uses matchMode 'options' but has no outgoingedge labeled 'unanswered' — a reply that matches none of the offered optionsmust have somewhere to gounanswered is not required in free_text mode because it is unreachable there — every reply is by definition the answer, so the edge would be permanently dead.
What saveAs holds when the run resumes
Section titled “What saveAs holds when the run resumes”| Mode / outcome | ctx[saveAs] is… |
|---|---|
free_text | The reply text, as a string. |
options, answered edge | The matched option object: { "slot": 2, "id": "19", "label": "Solution Architect" }. |
options, unanswered edge | The reply text, as a string — what the user actually typed, which is the only thing worth reasoning about on that branch. |
So with "saveAs": "chosenOrder", the answered branch of an options node templates {{chosenOrder.id}} into an outbound call and {{chosenOrder.label}} to echo the choice back; on the unanswered branch the same variable holds the raw text to re-ask against.
Rendering the option list in your question
Section titled “Rendering the option list in your question”In options mode the rendered list is exposed to promptTemplate as awaitReplyOptions, so the numbers the user sees are the same 1-based slots the matcher accepts:
Which role are you applying for?
{{#each awaitReplyOptions}}{{slot}}. {{label}}{{/each}}Reply with the number or the role name.awaitReplyOptions exists for this one render only — it is never written into the run context and cannot be read by a later node.
How a reply is matched (options mode)
Section titled “How a reply is matched (options mode)”The option list is rendered once, at the moment the question is asked, and stored server-side. Matching on resume reads only that stored list — never the live context, and never anything the inbound message claims. That ordering is the point: the set of acceptable answers is fixed when the question is asked, so a later message cannot widen it.
Matching runs in tiers, each of which requires exactly one winner — zero matches, or two or more, is “unmatched”, never a guess:
- Slot number — the whole message is a number matching a shown position (
2). “I’ll take 2 of them” is not a slot pick. - Exact id, case-insensitively. No punctuation folding —
POD-40andPOD40are different ids. - Normalised label equality — case, accents, punctuation and trailing plurals folded.
- Unique whole-token containment, either direction, with a minimum length — “I’d like the Solution Architect role” matches the
Solution Architectoption, and so does a bare “architect”.
Whole tokens, never raw substrings, and no abbreviation or synonym expansion: "N/A" must not match "Solutio[n A]rchitect", "IT" must not match "arch[it]ect", "POD-40" must not match "POD-401". A clean trip down the unanswered edge — where your graph can re-ask — is always better than a confident write against a record nobody chose.
Practical limits: at most 20 options per question (extra entries are dropped), labels truncated to 80 characters. An optionsFrom path that resolves to a missing value, a non-array, or an empty array fails the node rather than silently degrading to a free-text question — put a condition node in front if “offer options if there are any, otherwise ask freely” is what you want.
Expiry: 24 hours, and nothing happens when it lapses
Section titled “Expiry: 24 hours, and nothing happens when it lapses”A wait is resumable for expiresAfterSeconds (default 24 hours, ceiling 7 days), enforced at read time. Past that, the wait is closed and the next message simply starts a fresh run from start — exactly the behaviour you get today without this node.
An expired wait does not take the unanswered edge. The platform will not speak unprompted hours later into an abandoned conversation. unanswered means exactly one thing: a reply arrived and matched none of the options offered.
If your flow depends on a tighter guarantee — “chase the user after 10 minutes”, “cancel the held order if they don’t confirm” — build that timeout on your side (a scheduled job of your own that checks pendingAwaitReplies, then acts, e.g. via runWorkflowTask). Do not size a business SLA off the expiry window.
Worked example
Section titled “Worked example”A shipping assistant that finds several orders for a customer and asks which one they mean before looking up its status. (All data below is fictional.)
{ "nodes": [ { "id": "start", "type": "start", "label": "Start", "config": {}, "position": { "x": 0, "y": 0 } }, { "id": "find_orders", "type": "action", "label": "Find Open Orders", "config": { "customActionId": 42, "outputVar": "orderLookup", "bodyTemplate": "{\"customerRef\": \"{{clientExternalId}}\"}" }, "position": { "x": 0, "y": 100 } }, { "id": "ask_which_order", "type": "await_reply", "label": "Which Order?", "config": { "promptTemplate": "I found a few open orders on your account:\n\n{{#each awaitReplyOptions}}{{slot}}. {{label}}\n{{/each}}\nWhich one would you like an update on? Reply with the number.", "saveAs": "chosenOrder", "matchMode": "options", "optionsFrom": "orderLookup.data", "optionIdField": "order_id", "optionLabelField": "description", "expiresAfterSeconds": 86400, "mood": "helpful" }, "position": { "x": 0, "y": 200 } }, { "id": "fetch_status", "type": "action", "label": "Fetch Order Status", "config": { "customActionId": 43, "outputVar": "orderStatus", "bodyTemplate": "{\"orderId\": \"{{chosenOrder.id}}\"}" }, "position": { "x": -150, "y": 320 } }, { "id": "report_status", "type": "response", "label": "Report Status", "config": { "messageTemplate": "{{chosenOrder.label}} is currently {{orderStatus.data.state}}, expected {{orderStatus.data.eta}}.", "mood": "helpful" }, "position": { "x": -150, "y": 420 } }, { "id": "reask", "type": "response", "label": "Could Not Match", "config": { "messageTemplate": "Sorry — I couldn't tell which order you meant. Could you reply with just the number from the list?", "mood": "apologetic" }, "position": { "x": 150, "y": 320 } } ], "edges": [ { "id": "e1", "source": "start", "target": "find_orders" }, { "id": "e2", "source": "find_orders", "target": "ask_which_order", "label": "success" }, { "id": "e3", "source": "find_orders", "target": "reask", "label": "failure" }, { "id": "e4", "source": "ask_which_order", "target": "fetch_status", "label": "answered" }, { "id": "e5", "source": "ask_which_order", "target": "reask", "label": "unanswered" }, { "id": "e6", "source": "fetch_status", "target": "report_status", "label": "success" }, { "id": "e7", "source": "fetch_status", "target": "reask", "label": "failure" } ]}Turn 1 runs start → find_orders → ask_which_order, speaks the question, and the run persists as AWAITING_INPUT. Turn 2 (the user replying “2”) re-enters at fetch_status with chosenOrder already populated — find_orders does not run again, and the partner API is not called twice.
Note reask above deliberately does not loop back into ask_which_order: the graph asks once, and a mismatched reply ends the turn with guidance instead. Routing it back to ask again is supported — see the ask-again note in Gotchas for what bounds that loop.
What carries across the pause, and what doesn’t
Section titled “What carries across the pause, and what doesn’t”The resumed run seeds its context as { ...snapshot, ...freshTurnFacts, [saveAs]: answer } — fresh facts always win over stored ones.
Carried: the workflow’s own output variables (whatever your nodes wrote via outputVar/resultVar), capped at 256 KB serialized. A graph carrying more than that fails the node with a named error rather than silently truncating — if you’re extracting a large document mid-flow, drop it into a variable you don’t need after the pause, or pause before the extraction.
Not carried, because the resuming turn supplies them fresh: userMessage, conversationHistory, sessionId/tenantId/agentId, turn counters, and the per-turn attachment/channel facts (attachment, channel, attachmentUrl, attachmentMime, attachmentFilename). If your graph needs the attachment that arrived before the pause, copy the pieces you need into your own variable first.
Observing a paused conversation
Section titled “Observing a paused conversation”A paused run has its own status, so “waiting on a human” is queryable rather than inferred:
workflowRun/workflowRunsreportstatus: "AWAITING_INPUT"for the run that asked the question — it is notCOMPLETED.- The resumed run is a separate
WorkflowRunwhoseresumedFromRunIdpoints back at the paused one, so the two halves of one logical step are followable. pendingAwaitReplies(sessionId: Int)lists what is waiting right now, withnodeId,saveAs,matchMode, the exactoptionsoffered, andexpiresAt. OmitsessionIdfor every open wait in your tenant.
The stored context a paused run will resume with is deliberately not exposed over the API, on any query.
Gotchas
Section titled “Gotchas”- The question ends the turn — by design, and it always sets
turnComplete: true. Anawait_replynode’s outbound message is published withturnComplete: trueregardless of what follows it in the graph, because the turn genuinely is over until a human replies. Any client deciding “the agent has finished” should watchturnComplete, not a quiet-period/silence timer — a timer that fires while an earlier node is still working will end the turn before the question is even spoken, and one that keeps waiting afterturnCompletewill hang until it times out. - An “ask, validate, ask again” loop is bounded by the person replying, not by
maxVisits. The resumed turn is a new run, andmaxVisitsis a per-run bound, so its counters start empty — anunansweredbranch routing back into theawait_replynode that just asked will re-ask, once per turn, indefinitely. That is the intended behavior (a human can always stop answering), but it means the graph itself will not cut the loop off for you. If you want a hard “I’ve asked three times, escalate” rule, count the attempts yourself in a workflow variable and gate the back-edge on aconditionnode. Within a single run the question always ends the turn, so the node itself never runs twice in one run regardless of whatmaxVisitssays. - One open question per session. A session can hold exactly one unanswered
await_replyat a time — this is enforced at the database level, not merely unlikely. A second one is rejected with a clear error rather than two questions racing for the same reply. - Not usable inside a
sub_agentdispatch. A sub-agent runs on a throwaway child session no human is watching, so a question asked there could never be answered. The node refuses up front and the parent records it as an error for that target. Put the question in the master workflow, before or after the dispatch. - Identity changes cancel the wait. If the session’s signed-in identity changes between the question and the reply — including gaining or losing one — the wait is cancelled rather than resumed, and the reply starts a fresh run. Someone else’s session never inherits an in-flight question.
- Ending the session cancels any open wait, on every path that ends one.
- Editing a published graph can strand a live wait. If you remove the
answered/unanswerededge (or the node) while a conversation is paused on it, that conversation’s reply fails with an explicit “cannot resume … it has no outgoing edge labeled …” error rather than guessing. Ship graph edits when few conversations are mid-question, or accept that in-flight waits will fail once. - Regression scenarios can’t answer a question. The workflow regression harness has no scripted-reply format yet, so a scenario that reaches an
await_replynode stops there atAWAITING_INPUT(cleanly — it no longer hangs to a misleading timeout). Cover the post-answer branches with their own scenario for now.
Next steps
Section titled “Next steps”- Response Node — the one-way version of speaking to the user, for when you don’t need an answer back.
- Condition Node — the usual gate in front of an
options-mode question (“do I actually have a list to offer?”). - Action Node — typically the node that produces the array an
optionsquestion offers, and the one that consumes the chosen id afterwards. - Workflow Overview — full node/edge reference, including run limits.
- Workflows API Reference —
pendingAwaitReplies, run statuses, andresumedFromRunId. - Events & Subscriptions —
isFinalvsturnComplete.