Asking the user
A structured question set the agent puts to the user before it works — and parks on.
The only way a run could pause for a human was awaitApproval — a yes/no about a tool call already
proposed, mid-work. The other direction was missing entirely: collecting the scope, before the
work, while changing course is still cheap.
Two surfaces do it, and they were built to be indistinguishable downstream.
A configured intake
AgentLoopDeps.intake declares the questions; the turn passes through them before its first model
call.
const deps: AgentLoopDeps = {
// …
intake: {
preamble: 'Two questions before I start.',
questions: [
{
id: 'scope',
prompt: 'How wide should I go?',
options: [
{ value: 'module', label: 'This module only' },
{ value: 'repo', label: 'The whole repository' },
],
defaults: ['module'],
},
],
when: 'thread-start',
},
}Because the questions are authored, the intake costs no model call and writes no usage row —
and questions.length is known before the form appears, which is the only honest way a client can
render "Question 1 of 3" rather than discovering a fourth halfway through.
when: 'thread-start' (the default) asks once per thread; 'every-turn' asks before each one.
A model-callable ask
ask: true offers the model a built-in ask tool, for the case an intake cannot anticipate. Its
input schema requires a pre-picked defaults on every question: "I have pre-picked what I would
choose, so confirming is enough" is the claim this whole surface rests on, and a schema is the only
place to make that mandatory rather than aspirational.
A malformed question set comes back as an ordinary tool failure carrying the validation issues, so the model fixes its own mistake instead of failing the run or parking a person.
One shape, one resume path
Both surfaces:
- write a single pending tool-call row named
ask(toolType: 'action',status: 'pending_approval'), so a question set surfaces in the approvals inbox a deployment already has; - emit the same
elicitationstream frame, carrying the whole request and therunIdto answer it against; - park on the same
tool:<runId>:<callId>signal a HITL approval already waits on.
POST /agent/tool-call/answer and /skip mirror approve/reject, with the same ownership check.
AgentLoopHooks.awaitAnswers is optional: a host that only implemented awaitApproval still
completes an elicitation, reading approve as "confirmed the pre-picked answers" and reject as
"skipped".
The reduction runs one way only. One channel carrying two shapes means a client can address
answer at a tool call that is in fact waiting on an approve/reject. A yes/no can be read as
answers; a set of answers cannot be read as a verdict on proposed work, and its absent approved
is not a no — so it is refused, never recorded. The inline runner knows which wait is parked
and replies 409 (HumanReplyMismatchError); under the durable runner a signal carries no such
clue, so the loop discards the reply and the run stays parked on the approval it was always waiting
for. Either way the tool-call row keeps pending_approval, and no rejection is attributed to
someone who only submitted a form.
Defaults, and what a skip means
An omitted question takes its own default, resolved server-side against the request the run
already holds rather than in the client — so "just pressed enter" and "picked exactly the defaults"
persist identically, and a client that never rendered the defaults cannot submit a blank. A
present-but-empty array is an explicit "none of these" and does not fall back. The settled row
records defaulted: string[], so an auditor can still see which questions a human touched.
A skip is not a confirmation. It lands on the same values and persists as rejected rather than
executed, because proceeding on an assumption the user declined to confirm is a different fact from
proceeding on one they chose. Nobody answering parks the run indefinitely, exactly as an approval
does — there is no intake timeout, because a timeout that applied the defaults would manufacture
consent from silence.
A delegated sub-agent's question set parks too. Its turn runs behind another agent's tool call,
so the stream a person subscribed to belongs to the ancestor — which is where the frame is
forwarded, carrying the child's own runId to answer against. See
Answering a sub-agent.
Determinism
ToolKind gains a fourth member, 'ask'. No ToolSpec carries it: ask is never registered, has no
handler, and is offered to the model straight from module config — so the branch that decides whether
a call parks on a human can never be settled by a process-local registry lookup. As with the other
kinds, the value is resolved inside the already-journaled persist:toolcall:<callId> checkpoint
and read back from there on every replay.
An intake spends one position for its verdict (intake:ask) plus one more on the turns it asks
(intake:answers); an ask reuses the approval path's own names and adds one
(stream:elicitation:<id>). The intake's verdict is returned from intake:ask rather than
recomputed, because by the time a resume replays the turn the first attempt has already appended the
intake's own assistant message to the thread — recomputing "has this thread been asked?" would answer
no on the way in and yes on the way back, and land the model call where the history holds the wait.
No patched marker is spent for either surface: an intake is reachable only through new config and an
ask only through a journaled kind no existing run recorded. Declare neither and a turn's checkpoint
sequence is byte-identical.
Governed SQL (data satellite)
Give the agent a single, fail-closed read-only SQL tool — dataTool validates one SELECT, enforces a table allow-list, rewrites in a tenant scope, injects a LIMIT, and truncates oversized results.
Input and output processors
A seam on each side of the model call — rewrite the prompt going out, gate the answer coming back.