Async human interaction — ask, keep working, sync later
ADR: 081-async-human-interaction
An interaction: async agent can pose questions to the operator without stopping its work — like a Claude Code session where you answer whenever you're ready. The answer is delivered into the agent's message queue as soon as the operator replies; the run only blocks at a sync point, deterministic (the await_answers node) or at the LLM's discretion (the await_answers tool).
DSL
agent draft:
interaction: async # grants ask_user_async + await_answers (+ blocking ask_user)
system: draft_sys
output: draft_out
await_answers gate: # deterministic sync point
from: draft # optional: only this node's questions ("" = whole run)
timeout: "30m" # mandatory — the no-silent-infinity invariant
workflow w:
entry: draft
draft -> gate
gate -> finalizeinteraction: async is valid on agent/judge nodes only (C240 on a human node — a human node IS the blocking question). await_answers requires a positive timeout: (C241); a from: naming a missing or non-async node warns C242 (the await could only ever time out).
Runnable demo: examples/async-questions/main.bot.
The tools (identical on claw, claude_code and pi)
ask_user_async(question, options?, allow_free_text?)— persists a pending interaction (Kind: "async",interactions/<id>.json), emitshuman_input_requested{async:true}, and returns immediately ("question posted; keep working"). Never cancels the stream.await_answers()— all posted questions answered → returns the collected answers inline. Any pending → the run pauses (paused_waiting_human, interactionKind: "await"listing the pending IDs); answering the last question auto-resumes, and the agent's paused tool_use receives the aggregated answers.ask_user— the blocking variant stays available for hard stops (destructive/irreversible decisions).
The system prompt of an async node carries an [ASYNC QUESTIONS] protocol section: front-load questions, keep working, sync only when truly blocked.
Answering
While the run is running or paused:
- Studio — the run conversation shows a non-blocking question card (the run keeps executing); answering posts to the API below.
- REST —
POST /api/runs/{id}/interactions/{iid}/answer{"answer": "…"}→{queued, resumed};GET /api/runs/{id}/interactions/pendinglists pending questions. - CLI —
iterion runs questions <run-id>theniterion runs answer <run-id> <interaction-id> "<answer>"(direct store access — works cross-process against a liveiterion run).
Delivery rides the existing operator-message queue, node-scoped to the asking node: claw injects between tool iterations (plus a final end-of-turn drain so a late answer forces one more turn instead of being lost), claude_code via its PostToolUse/Stop hooks, pi via native steer on the RPC transport (drained on a 2s tick; pi delivers a steered message at the agent's next turn). The message shape is [Answer to question <id>] Q: "…" — A: ….
On pi the three tools are registered by the embedded iterion extension rather than over MCP — pi ships no MCP client — and the decisions stay in Go: the extension reports to iterion, which owns the interaction store and is the only side that can suspend a run. The text the model reads back after posting comes from iterion too, so the prompting is identical to the other two backends. RPC transport only: a print-mode pi node has no control channel and gets none of this.
Semantics & guarantees
- Parallel branches are never frozen by pending questions. Inside a fan-out branch, an
await_answersnode parks only its branch (releasing its semaphore slot, likewait); theawait_answersTOOL called from inside a branch returns an explicit error (a pause is run-global by construction — put the sync point in the graph). - Level-triggered, store-backed: the await predicate is "no pending
Kind: asyncinteraction in scope", re-checked on an in-process doorbell (immediate) and a 5s poll (cross-process answers). Answers that arrived while the process was down are honoured on resume. - Bounded: the node's
timeout:fails the branch with an explicit list of unanswered questions; the tool escalation rides the normal pause/resume machinery (no idle CLI session, survives restarts). - Node output:
{answers: [{interaction_id, node, question, answer}, …]}— reference it as{{outputs.<gate>.answers}}. - Events:
human_input_requestedwithdata.async=trueon post;interaction_answered(with the answer text) on reply. - Answering the same question twice is a 409 conflict (
ErrInteractionAlreadyAnswered) — never a silent overwrite.
Limits (v1)
- Sandboxed claude_code nodes now get the full ask-user tool set (
ask_user,ask_user_async,await_answers) over the per-run HTTP MCP transport (ADR-082 Phase 3): the engine binds a gateway-reachable listener at/api/v1/mcp/ask-user(pkg/askusermcp, token-authenticated viaX-Iterion-Run) and the delegate registers it in place of the stdio__mcp-ask-usersubcommand, whose host binary path is invisible in-container. The PreToolUse hooks run host-side on both transports, so the interaction-store paths and studio pause/answer UX are identical. If the listener fails to bind, the tools are disabled with a loud per-node warning (never silently). - An in-flight
await_answersnode keeps the run statusrunning(in-process park bounded bytimeout:); the durablepaused_waiting_event-style parking is deferred (see ADR-051/081).
