Human in the loop
A long-running agent eventually wants to ask a human a question, or run a tool that you'd rather sign off on first. Both are the same primitive on the wire: an input request. The run pauses, you respond, the run resumes.
- Question: the agent itself asks (
ask_question). The request carries a rich payload: a prompt, optional structured options, free-form text. - Approval: a tool wants to fire and your hook parked it. An approval is just an input request whose options are
[approve, deny].
Both pauses look identical on your side: a data-novo-input-requested chunk arrives mid-stream, the run's status flips to waiting_for_input (or waiting_for_approval), and one call to the SDK resumes it. You resolve both with the same inputResponses body: for an approval you select the approve or deny option; for a question you answer per the request's options.
The request shape
Every pause emits a data-novo-input-requested chunk. Its request describes what's being asked, and its shape depends on request.kind:
request.kind:'approval_request'or'question'.request.display: how to render it, one of'confirmation'(approvals),'select'(pick an option), or'text'(free-form).
Approval (kind: 'approval_request') carries the choices and tool call inline:
request.options: the choices you may select byid, always exactly[{ id: 'approve', label: 'Approve' }, { id: 'deny', label: 'Deny' }].request.allowFreeform:false.request.inputand an optional renderedrequest.preview: the tool call under review.
Question (kind: 'question') carries the rich ask_question payload on request.details: { title, details?, questions?, actions? }. There are no top-level options or allowFreeform on a question's request: each prompt is an entry in request.details.questions[], and that entry holds its own options, multiline, allowOther, and multiple. Any review buttons live in request.details.actions[].
Resolving
You resolve every pause through the same call. Pass an inputResponses array; select an option by optionId, or supply text when free-form is allowed. The requestId in each entry depends on the kind:
- Approval: use the interaction's own id (
event.data.id; on the stream this also equalsrequest.requestId), withoptionId: 'approve'or'deny'. - Question: use an inner
details.questions[].id(ordetails.actions[].id), one entry per prompt you're answering. The interaction's top-levelrequestIdis not a valid target for a question.
// Approve an approval (requestId is the interaction id).
await novo.interactions.resolve(interactionId, {
inputResponses: [{ requestId, optionId: 'approve' }],
});
To cancel a pause without answering, pass cancel: true. An optional reason is echoed back on the resolution.
Questions
Enable on the agent, then handle the request when it arrives:
const agent = await novo.agents.create({
context: 'You triage support tickets and confirm next steps with the user.',
band: 'core',
capabilities: { userInteraction: true },
});
const stream = await novo.threads.stream(thread.id, { input: 'Refund #ABC?' });
for await (const event of stream) {
if (event.type === 'data-novo-input-requested' && event.data.request.kind === 'question') {
const { details } = event.data.request;
// Each `inputResponses[].requestId` is an INNER id from the payload: a
// `details.questions[].id` (or a `details.actions[].id`), not the
// interaction's top-level `request.requestId`. Pick an option the agent
// offered, or supply free-form text.
const question = details.questions[0];
await novo.interactions.resolve(event.data.id, {
inputResponses: [{ requestId: question.id, text: 'Customer confirmed via phone.' }],
});
}
}
The rich question payload (request.details) carries the agent's question(s) and any context the model attached. Each entry in details.questions[] defines a prompt, an optional list of structured options, and whether it accepts multiple selections (multiple) or free-form text (multiline / allowOther). Address it by its own id when you resolve: select one of its options by optionId, supply every chosen option for a multiple: true question in one optionIds array, or supply a free-form text answer. Each requestId may appear at most once; selecting more than one option for a single-select question returns 400. Addressing the interaction's top-level request.requestId instead resolves to an "Unknown questionId" and returns 400.
A run can pause for a question any number of times in one turn.
Approvals
There are two ways to require approval. The declarative path: set
approval: 'once' | 'always' | 'never' (optionally scoped with gatedTools)
on an HTTP connection or
tool server: no hook to host; gated calls pause for
a human. The dynamic path: a tool approval hook,
a URL you host that decides per tool call. Either way, a pause fires a
data-novo-input-requested chunk with request.kind: 'approval_request'; you
resolve via novo.interactions.resolve(...), or several at once with
novo.interactions.resolveBatch(...). From a hook,
returning deny blocks the call without pausing; returning allow
auto-accepts it (see Auto-accept).
for await (const event of stream) {
if (event.type === 'data-novo-input-requested' && event.data.request.kind === 'approval_request') {
const { id, toolName, request } = event.data;
const ok = await yourTeam.review({ toolName, input: request.input, preview: request.preview });
await novo.interactions.resolve(id, {
inputResponses: [{ requestId: id, optionId: ok ? 'approve' : 'deny' }],
reason: ok ? 'approved by on-call' : 'blocked',
});
}
}
Selecting deny resumes the run and the tool returns a terminal tool-output-denied chunk for its toolCallId. The reason you pass is echoed back verbatim on the resolution; it is not labelled by source.
Hook responses are 'allow' | 'deny' | 'pause'. To hard-fail the run when your hook itself errors (timeout, 5xx, signature mismatch), set onHookError: 'fail-run' on the hook registration. That's a separate policy from the per-call response.
Auto-accept
There are three ways an approval never reaches a human:
- Connection-level
approval: 'never'(orgatedToolsthat omit the tool). The declarative per-connection policy answers before any hook is consulted for that connection's tools. - Hook returns
allow. A tool approval hook that returns{ decision: 'allow' }for a call auto-accepts it: the tool fires without a pause. This is the per-call escape hatch. approvals.autoAllow. A declarative list of Novo canonical tool names that never require approval. Listed tools skip the hook entirely and fire immediately, the analog of an always-allow list.
await novo.agents.update(agent.id, {
approvals: {
toolExecution: { hookId: hook.id },
autoAllow: ['read_file', 'list_directory'],
},
});
Use autoAllow for known-safe, read-only tools so your hook only sees the calls that actually warrant review.
Messages sent while a run is active
A text message sent to a thread whose run is still working is held, not dropped, whether the run is executing or parked on a pause. What happens first depends on the run's state:
- Approval pause: if the message text matches
approve/deny(or a 1-based1/2), Novo resolves the approval with that decision and the run continues; the409 thread_busyresponse carriesdetails.resolved_interaction_idto show the message was consumed. Any other message is queued. - Question pause: the message is always queued, never auto-resolved. A question carries rich, multi-prompt answers, so it is answered only through the structured
inputResponsesresolve endpoint, not by free-text chat. - Running: the message is queued as mid-run steering; it is delivered to the agent at the run's next turn boundary (typically after the current batch of tool calls completes), so "actually, make it vegetarian" arrives while the work is still in flight instead of after the wrong result is finished. Text like
"approve"is just a message here; there is no gate to resolve. - Queued in every case means held and delivered to the agent as its next user turn, so nothing the user typed is lost. A pause never auto-denies.
This means you can drive an approval purely by sending "approve" (or "deny", or "1") as the next message, while a question always waits for a structured resolve. Anything else sent mid-run is preserved and steered in, not bounced. To stop the run rather than steer it, use runs.cancel; a message never cancels.
A queued message is a first-class resource with a full lifecycle you can observe:
- Accepted: the send still returns
409 thread_busy, but withdetails.queued: trueand adetails.followup_idhandle. (Athread_busywithdetails.resolved_interaction_idmeans the message matched an approval and was consumed; a plainthread_busywithout either field means nothing was held, e.g. the message carried attachments, the hold queue was full, or the run was still initializing; re-send when the run finishes.) - Held: until delivered, every held message is listed on the run resource as
queuedFollowups([{ id, text, queuedAt }], oldest-first), so any client, not just the one that sent it, can see what is waiting. - Withdrawn (optional):
DELETE /v1/runs/:runId/followups/:followupId(SDK:novo.runs.followups.withdraw(runId, followupId)) removes a held message before it is delivered. A404 followup_not_foundmeans it is already gone: withdrawn earlier, claimed for delivery, or visible on a terminal run as a read-only undelivered record. - Delivered: at the next turn boundary (a resumed pause, or the next step of a running turn), each held message is folded into the conversation as the next user turn, oldest-first, and the stream emits one
data-novo-followup-replayedchunk per message (carrying the sameidand the verbatimtext). Delivered user turns don't appear as assistant output, so this chunk is your cue to render the message in the transcript. - Never delivered: if the run reaches a terminal state before a boundary (completed, cancelled, or timed out while parked), held messages are not discarded silently: they remain visible in
queuedFollowupson the terminal run, so a client can re-send them on a new run.
Reconnecting through a pause
A paused run does not bill model time. If your stream connection drops mid-pause, the run is still waiting; call stream.reconnect({ lastEventId }) and you'll pick up where you left off. The same data-novo-input-requested chunk replays so your handler is idempotent.
Abandoned pauses time out
A pause waits for a human, but not forever. If a pending approval or question is never resolved, Novo eventually cancels the run so the thread isn't held open indefinitely. The run finalizes with status: 'cancelled' and terminalReason: 'interaction_timeout'; any still-pending interactions on it are cancelled, and the thread is freed for new runs.
The window is deliberately generous, on the order of two weeks, so normal "approve it tomorrow" flows are never affected; it only catches genuinely abandoned pauses. A run that sets a longer maxDurationMs keeps its pause open for at least that long instead. To detect a timed-out pause, read terminalReason on the run (poll GET /v1/runs/:id or read await stream.finalResult) rather than assuming a pause lives forever.
Each pending interaction also carries expiresAt, the exact ISO timestamp this release fires, so a UI can show a live "expires in …" countdown without hardcoding the two-week window (it already reflects a longer maxDurationMs). It is present only while the interaction is pending and becomes null once it resolves; compute the value fresh by re-reading the interaction, since it tracks the run's live park anchor.
Out-of-band resolution
You don't have to resolve from inside the for-await loop. Use the id from the request chunk to resolve from any process. The run sits at waiting_for_input until you do:
await novo.interactions.resolve(interactionId, {
inputResponses: [{ requestId, optionId: 'approve' }],
});
This pattern fits webhook-driven approval flows or Slack interactions where a human approves later.
Listing pending requests
const pending = await novo.interactions.list({
status: 'pending',
});
const approvals = await novo.interactions.list({
status: 'pending',
kind: 'approval_request',
});
Both questions and approvals are interactions; filter by kind to split them. Useful for a "things waiting on a human" inbox in your app.