Approvals & Questions
An agent can pause a turn to ask a human before it goes on: approve a tool call, or answer a question. The server persists the paused turn, tells the client what is pending, and continues the turn when the client answers.
Everything here is additive. An agent that never pauses produces the same responses and streams as before.
New in 0.3.0
Pending actions, the resolve route, the stream events and the storage that keeps a pause across restarts arrived in 0.3.0. Requests that lapse, who may answer, edited-argument checks, remembered tools, client tools and the durable record on the message were completed in the same release. See Upgrading to 0.3.0 for what changed for existing servers.
When to use it
- A tool must not run without a person's say-so: a payment, a deployment, a data export. Give the agent-sdk tool
needsApprovaland the server does the rest. - The agent needs information only a person has: which environment, which account. An agent-sdk agent with
humanInTheLoop: { askUser }asks; a custom handler or a host-backed agent returns auser_question. - The page that embeds the chat must do something the server cannot: read the selected rows of a table, open a record. That is a client tool.
The server stores the pause, so the person can answer later (a request lapses after hitl.timeoutMinutes, a day by default), from another tab or device, and an approver who is not the conversation's owner can be allowed to answer.
The flow
- The client sends a message (
POST /conversations/:id/messages, streaming or not). - The agent pauses. The turn ends with
status: "requires_action"andpendingActions:- streaming:
stream.action_required, thenstream.donewithstatus: "requires_action"; - JSON: the message response carries
statusandpendingActions.
- streaming:
- The client shows the request and answers with
POST /conversations/:id/actions/:actionId. - The server resumes the agent and returns (or streams) the continuation as a new assistant message. It can pause again.
A reloaded UI finds the open request in GET /conversations/:id (pendingActions, omitted when empty).

A tool approval rendered by @cognipeer/chat-ui against the standalone example server, with the arguments being edited. What the client sends back is the resolution: decision, and optionally approvedArgs (the edited arguments), rememberForSession ("don't ask again for this tool in this chat") and a comment.
Pending actions
type PendingAction =
| { id: string; type: 'tool_approval'; toolName: string; toolCallId?: string;
title?: string; summary?: string; args?: Record<string, unknown>;
argsSchema?: Record<string, unknown>; expiresAt?: string }
| { id: string; type: 'user_question'; questions: UserQuestion[];
allowFreeText?: boolean; expiresAt?: string }
| { id: string; type: 'client_tool'; toolName: string; toolCallId?: string; title?: string;
args?: Record<string, unknown>; argsSchema?: Record<string, unknown>;
timeoutSeconds?: number; requestedAt?: string; expiresAt?: string };titleis the tool'sapprovalPrompt;summaryis a one-line description of what approving does (metadata.summaryof the agent-sdk entry).argsSchemais the JSON Schema of the tool's arguments, when the server knows it: an agent-sdk tool's zod schema (converted) or JSON Schema (as it is), looked up inagent.__runtime.tools; a host agent can put its own on the action. A client that lets the person editargsvalidates the edit against it before sendingapprovedArgs. It is absent when the agent does not say what the tool takes. The server checksapprovedArgsagainst the tool's schema either way (Edited arguments) - for a host agent, against theargsSchemait gave.- A question's options can carry a
preview(agent-sdk'sUserQuestionOption.preview: a code or ASCII sketch shown while the option is focused). expiresAtis when the request lapses.
A paused assistant message also carries what it asked, in metadata.pendingActions - see The record an answer leaves.
Resolving
POST /conversations/conv_1/actions/ap_9
Content-Type: application/json
{ "resolution": { "type": "tool_approval", "decision": "allow" }, "stream": true }| Action | Resolution |
|---|---|
tool_approval | { type, decision: 'allow' | 'deny', comment?, approvedArgs?, rememberForSession? } |
user_question | { type, answers: { '<question text>': 'value' | ['v1', 'v2'] }, cancelled? } |
client_tool | { type, output } (any JSON) or { type, error } - see Client tools |
approvedArgsmust be a JSON object; it replaces the tool arguments when allowing, and is checked against the tool's schema.rememberForSession(SDK agents): later pauses on the same tool in this conversation are approved automatically - see Remembered tools.- Answers are matched to options by value or label; anything else counts as free text (only valid when the question allows it).
Errors:
| Status | Code | Meaning |
|---|---|---|
400 | VALIDATION_ERROR | malformed or invalid resolution (nothing is consumed - answer again) |
400 | invalid_approved_args | the edited arguments are not arguments the tool accepts |
402 | budget_reached | the answer would let the agent run again and the conversation's budget is used up (nothing was recorded) |
403 | FORBIDDEN / action_forbidden | not your conversation / you may not answer this request |
404 | NOT_FOUND | conversation not found, or the agent cannot resolve actions |
409 | action_not_pending | unknown or already resolved action |
409 | conversation_busy | the conversation is running a turn (message or continuation) |
410 | action_expired | the request lapsed |
The order is fixed: not found, then not pending (409), then lapsed (410), then who-may-answer (403), then the answer itself (400, 402).
Answers do not lose each other
A pause can ask several things at once (pendingActions has more than one entry). Each answer is recorded against the conversation as it is when the answer is written: the decision is applied to the stored state with a compare-and-set (StorageProvider.updateConversationIf, on every built-in provider), and an answer that meets another one being recorded waits for it - up to hitl.resolveWaitMs (3 s) - instead of failing with 409 conversation_busy. So the answers to the actions of one pause can be sent at the same time, from one client, two tabs or two server instances: every decision is kept, and exactly one of the answers (the one that closes the pause) carries the turn on. A second answer to the same action finds it answered (409 action_not_pending). A message or a continuation takes as long as the model does, so an answer does not wait for those: 409 conversation_busy.
Answering is a run like a message is: it runs detached from the request (a client that disconnects does not stop it), it can be cancelled, and it leaves action.resolved, session.status_running, ... session.status_idle in the conversation's event log.
Client tools
A third kind of request is not for a person: it is for the page that embeds the chat. A host-backed agent (or a custom handler) that wants the host page to do something - read the selected rows of a table, fill a form field, open a record - pauses the turn with a client_tool request instead of calling a tool itself:
return {
content: '',
pendingActions: [
{ id: 'ct_1', type: 'client_tool', toolName: 'get_selected_rows', toolCallId: 'call_7',
args: { max: 20 }, argsSchema: { type: 'object', properties: { max: { type: 'integer' } } }, timeoutSeconds: 60 },
],
};The request travels like any other (stream.action_required, action.required with kind: "client_tool", pendingActions in GET /conversations/:id). A chat client shows no card for it: it hands toolName and args to the host page (the embedded chat does it with a postMessage bridge) and, when the page is done, answers the way it answers an approval:
POST /conversations/conv_1/actions/ct_1
{ "resolution": { "type": "client_tool", "output": { "rows": [ ... ] } } }outputis any JSON value, at mosthitl.clientToolMaxOutputBytes(default 65 536 bytes; more is400 invalid_client_tool_result).error(a message the model reads) says the page failed, has no such tool, or did not answer:{ "type": "client_tool", "error": "..." }. One of the two is required.- The turn then continues in your
resolveAction, with the whole resolution; the model gets the output - or the error - as the result of its tool call. timeoutSecondsis for the client to enforce: a page that has not answered by then is answered for with an error. The server's own deadline is the net under that for a page that went away:expiresAtdefaults totimeoutSeconds+ 30 s after the request was recorded (requestedAt), orhitl.timeoutMinuteswithout atimeoutSeconds. A client tool that lapses is always answered with an error the model reads ("The host page did not answer."), whateverhitl.onTimeoutsays for approvals, and the agent carries on; with an agent that cannot resolve actions it is dropped like any other request.- Approval still applies first: a host that wants the person to confirm a client tool asks for a
tool_approvalbefore it asks for theclient_tool. - The event log records the call:
action.required(kind: "client_tool",tool_name,args,timeout_seconds) andaction.resolved(tool_name,ok,error?,duration_ms,status: "answered"or"failed"). The output is not in the log or in the paused message: the message'smetadata.pendingActionskeepsresolution: { type: "client_tool", outputBytes }(or theerror), so a history never holds the page's data. - An agent-sdk agent has no such pause (it cannot produce one), and a resolution of another type than the request's is a
400. Advertise the support withcapabilities.clientTools: trueon the agent.
Requests that lapse
A request nobody answers does not wait for ever. Every action gets an expiresAt: the one the agent gave its entry, else now + hitl.timeoutMinutes.
createAgentServer({
basePath: '/api',
storage,
hitl: {
timeoutMinutes: 60, // default 1440 (a day); 0 = never
onTimeout: 'deny', // default; or 'cancel'
},
});
// one agent can differ
server.registerSDKAgent('ops', agent, {}, { hitl: { timeoutMinutes: 5, onTimeout: 'cancel' } });After the deadline the action answers 410 action_expired and it is the server's turn (the recovery sweep runs every runs.sweepIntervalMs, 60 s by default, and at the first request; await server.expirePendingActions() does it now):
onTimeout | What happens |
|---|---|
deny (default) | a lapsed approval is answered as a refusal (comment: "timed out", decidedBy: "system:timeout"), a lapsed question as skipped. The ones still open stay open. Once nothing is open the agent carries on as a run of its own (session.status_running with trigger: "timeout"): the tool call fails, the agent decides what to do next, and the continuation is a new assistant message. |
cancel | the pause is dropped - the lapsed requests as expired, the others as cancelled - the agent is not called, and the turn ends with stopReason: "cancelled" (session.status_idle with detail: "action_expired"). The conversation takes a new message. |
The policy is the one the pause was taken with: changing hitl.onTimeout later does not change a pause that is already waiting. If the agent can no longer be found, or the budget is used up, a deny pause is cancelled instead (there is nobody to carry on) - and when it is the agent that cannot be found, the server logs a warning that says so.
The sweep runs outside any request
The sweep finds the agent with a context of its own: { user: <the conversation's owner>, data: { system: true, timeout: true } } - it has no cookie, header or tenant. An agents registry that is a function of the request (agents: (ctx) => tenantAgents(ctx.data.tenant)) and storage that is scoped by ctx.data therefore have to also resolve for that context: return the agent (and reach the data) when ctx.data.system is true, e.g. by remembering the tenant on the conversation (metadata) and reading it back here. A registry that returns nothing for it makes every deny pause end as a cancel, without the agent being told (the host's resolveAction / cancel are not called).
A task whose request lapses under cancel ends cancelled; under deny it carries on.
The events say it: action.resolved with expired: true, resolved_by: "system:timeout", channel: "timeout" (status: "rejected" for a refusal, "expired" for a dropped request).
The sweep finds the lapsed requests through the storage provider: Conversation.actionDueAt holds the earliest deadline of a conversation's open actions (cleared when nothing waits) and getConversations({ actionDueBefore }) lists the due ones. Every built-in provider has it (capabilities().actionDeadlines); a custom provider without it is still refused with 410, and server.expirePendingActions({ conversationId }) handles a conversation by name.
Who may answer
By default the owner of the conversation - the rule that guards every conversation route. To let others answer (an on-call approver, a manager), give the server a hook:
createAgentServer({
basePath: '/api',
storage,
resolveContext,
hitl: {
async authorizeResolve(ctx, { conversation, action }) {
// ctx is the RequestContext of the person answering
return ctx.data.role === 'admin' || ctx.user?.id === conversation.userId;
},
},
});When the hook is set it decides for everyone - the owner too - and the owner check is not applied to the resolve route. false is 403 action_forbidden. The conversation it gets has the server's internal state removed. Answers by the server itself (a timeout) do not ask the hook. Who answered is on the record: resolved_by on the event, resolvedBy on the message, decidedBy in the SDK state.
The record an answer leaves
The assistant message that asked keeps what it asked and how it ended, so a history can show approved, denied, answered, expired or cancelled:
// GET /conversations/:id -> messages[1].metadata
{
"requiresAction": true, // gone once nothing on the message is open
"pendingActions": [
{ "id": "ap1", "type": "tool_approval", "toolName": "deploy", "title": "Deploy 1.0?", "args": { "version": "1.0" },
"expiresAt": "2026-10-12T10:00:00.000Z",
"status": "resolved", // pending | resolved | cancelled | expired
"resolution": { "type": "tool_approval", "decision": "allow", "comment": "ship it" },
"resolvedBy": "alice", "resolvedAt": "2026-10-11T10:00:12.000Z" }
]
}
Because the paused message keeps how each request ended, a reloaded client can show "Approved" or "Denied" on the cards (here a host-backed agent in the standalone example).
resolved covers approved, denied (resolution.decision), answered and skipped (resolution.cancelled) - the answer itself is resolution. cancelled is a request withdrawn by a new message (pendingActionPolicy: 'cancel', resolvedBy: "system") or by POST .../cancel; expired a request that lapsed under onTimeout: 'cancel'. The record is written with StorageProvider.updateMessage, which every built-in provider has. A provider without it still works; its history just keeps showing the request as it was asked (the event log has the decisions either way).
Edited arguments are checked
approvedArgs replaces the arguments of the tool call, so the server checks them before the agent sees them. For an agent-sdk agent it finds the tool in agent.__runtime.tools and checks the arguments against its schema: a zod schema (tool.schema) or a JSON Schema (parameters, inputSchema, input_schema; type, required, properties, additionalProperties, enum, const, bounds, pattern, items, anyOf / oneOf / allOf are understood). A mismatch is 400 invalid_approved_args naming the field, and the request stays pending.
Nothing is checked when the arguments are the very ones that were asked, when the answer is a refusal, when the tool declares no schema, or when hitl.validateApprovedArgs (server or agent) is false.
Remembered tools
rememberForSession: true on an approval makes the agent skip the approval for later calls of that tool in the conversation (by tool name; a tool whose needsApproval is a per-call predicate is never remembered; allowRememberApproval: false turns it off). It is no longer silent:
- every call that went through on that memory is an
action.resolvedevent withresolved_by: "remembered",channel: "remembered",remembered: trueand thetool_name; GET /conversations/:id/remembered-toolslists them (alsoconversation.rememberedTools);DELETE /conversations/:id/remembered-tools/:toolforgets one: the next call asks again (404when it is not remembered). A tool forgotten while a turn runs stays forgotten.
Events
A pause and its resolution are in the event log, so a client that was not connected can follow them (GET /conversations/:id/events/stream):
action.required { action_id, kind: 'tool_approval' | 'user_question' | 'client_tool', tool_name?, summary?, args?, args_schema?, questions?, allow_free_text?, timeout_seconds?, requested_at?, expires_at? }
session.status_idle { stop_reason: 'requires_action', pending_action_ids: [...] }
action.resolved { action_id, kind, decision?, cancelled?, expired?, remembered?, edited_args?, remember_for_session?, tool_name?, ok?, error?, duration_ms?, status: 'approved' | 'rejected' | 'answered' | 'failed' | 'cancelled' | 'expired', resolved_by, channel: 'api' | 'system' | 'timeout' | 'remembered' }GET /conversations/:id has runtime.status: "requires_action" and runtime.pendingActionIds while a pause is open.
Cancelling a pause
POST /conversations/:id/cancel on a conversation that waits on a human cancels what it waits on: an action.resolved event (cancelled: true) for each open action, then session.status_idle with stop_reason: "cancelled"; the paused message records each as cancelled. The conversation is free for a new message; resolving the old actions is a 409 action_not_pending.
A message while an action is pending
By default the server answers 409 action_pending (with error.details.pendingActionIds) and does nothing. Set pendingActionPolicy: 'cancel' to cancel the pending actions instead (each one gets a action.resolved event with cancelled: true, resolved_by: "system", and shows as cancelled on the paused message) and process the message normally:
createAgentServer({ basePath: '/api', storage, pendingActionPolicy: 'cancel' });SDK agents
registerSDKAgent works unchanged. When invoke returns a state with pending approvals (tools created with needsApproval) or pending user questions (humanInTheLoop: { askUser }), the server:
- stores the paused state (
result.state, without live objects) in the conversation state - hidden from every API response; - resolves with the agent's own
resolveToolApproval/resolveUserQuestionand callsinvokeagain with the same streaming hooks (it does not useresume).
AgentInfo.capabilities ({ toolApproval?, askUser? }) is derived from the agent instance when possible, or pass capabilities in the registration info.
The paused state lives in Conversation.state, so use a persistent storage provider if a pause must survive a restart. Every built-in provider keeps it across a restart (a new server on the same database resolves a pause started by the old one).
It has to fit. A long transcript or big tool results make a big state, and a store holds only so much (MongoDB: 12 MiB by default, see Storage Providers). When the state would not fit - limits.maxStateBytes, or the provider's own limit, whichever is smaller - the server does not pause the turn. It finishes instead: the assistant message gets metadata.warnings with a pause_state_too_large entry (also warnings on the response and on stream.done), no action is left pending, and the tool call is not carried out. A turn that gets that far is never stranded half-written: if the store refuses the write, the assistant message is taken back out.
Custom handlers
Return status and pendingActions from processMessage, and implement resolveAction:
agentServer.registerCustomAgent('payments', {
async processMessage({ message }) {
return {
content: 'I need your approval first.',
status: 'requires_action',
pendingActions: [{ id: 'pay_1', type: 'tool_approval', toolName: 'wire', args: { amount: 5 } }],
};
},
async resolveAction({ actionId, resolution }) {
if (resolution.type === 'tool_approval' && resolution.decision === 'allow') {
return { content: 'Sent.' };
}
return { content: 'Cancelled.' };
},
}, { name: 'Payments', capabilities: { toolApproval: true } });pendingActions is the complete set still waiting after the call: return the remaining ones from resolveAction if you paused on several, or nothing when the turn is done. A handler without resolveAction cannot be resolved (404).
Host-backed agents
An agent whose work runs in your own backend returns pendingActions from invoke (or emits action_required) and implements resolveAction, which is called once per answered action with the whole answer. The server stores the pause, applies the timeouts and authorizeResolve, records who decided what on the message, and writes the same events. What it leaves to the host: remembering a tool for the session (rememberForSession arrives in the resolution) and checking approvedArgs against a schema. See Host-backed agents.
Not covered
Chat Completions and Responses API agents never pause. A pause that has a deadline needs the expiry sweep to be running (the default) or server.expirePendingActions() called from your own scheduler; without either, a lapsed request is only refused with 410.
A handler that does not honour signal is abandoned runs.cancelGraceMs after a cancel - see Runs, Cancel & Reattach.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
404 NOT_FOUND "This agent does not support resolving actions" | The agent cannot continue a pause. An agent-sdk agent has to expose resolveToolApproval and resolveUserQuestion (use a current agent-sdk); a custom handler or host agent needs resolveAction; Chat Completions and Responses agents never pause. |
| The turn never pauses, the tool just runs | The agent-sdk tool has no needsApproval, or the agent was built without humanInTheLoop: { askUser } for questions. GET /agents/:id shows capabilities.toolApproval / askUser when the server can derive them. |
The turn finishes with a pause_state_too_large warning instead of pausing | The paused state is bigger than limits.maxStateBytes or the provider's limit (MongoDB: 12 MiB). Nothing is pending and the tool was not run. See Storage Providers. |
409 conversation_busy on an answer | A message or a continuation is running in that conversation. Answers to the several actions of one pause may be sent together; they wait for each other (hitl.resolveWaitMs). |
410 action_expired | The request lapsed (expiresAt). The sweep applies hitl.onTimeout; make sure it runs (runs.sweepIntervalMs is not 0), or call server.expirePendingActions(). |
403 action_forbidden | hitl.authorizeResolve returned false for this caller. |
400 invalid_approved_args | The edited arguments do not satisfy the tool's schema; the request stays pending, so answer again. Validate against argsSchema first. |
| A pause is gone after a restart | The storage provider does not persist (memory). Every other built-in provider keeps the pause. |
A lapsed deny pause ended as a cancel | The agent could not be found for the sweep's own context. See the warning under Requests that lapse. |
Next steps
- Runs, Cancel & Reattach - how a paused conversation is followed and cancelled
- Usage & Budgets - what an answer that lets the agent run again counts against
- Host-backed agents - pausing from your own backend
- REST Endpoints - the resolve route

