Skip to content

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 needsApproval and 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 a user_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 ​

  1. The client sends a message (POST /conversations/:id/messages, streaming or not).
  2. The agent pauses. The turn ends with status: "requires_action" and pendingActions:
    • streaming: stream.action_required, then stream.done with status: "requires_action";
    • JSON: the message response carries status and pendingActions.
  3. The client shows the request and answers with POST /conversations/:id/actions/:actionId.
  4. 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 in a chat client, with the arguments being edited before the person approves

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 ​

typescript
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 };
  • title is the tool's approvalPrompt; summary is a one-line description of what approving does (metadata.summary of the agent-sdk entry).
  • argsSchema is 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 in agent.__runtime.tools; a host agent can put its own on the action. A client that lets the person edit args validates the edit against it before sending approvedArgs. It is absent when the agent does not say what the tool takes. The server checks approvedArgs against the tool's schema either way (Edited arguments) - for a host agent, against the argsSchema it gave.
  • A question's options can carry a preview (agent-sdk's UserQuestionOption.preview: a code or ASCII sketch shown while the option is focused).
  • expiresAt is when the request lapses.

A paused assistant message also carries what it asked, in metadata.pendingActions - see The record an answer leaves.

Resolving ​

http
POST /conversations/conv_1/actions/ap_9
Content-Type: application/json

{ "resolution": { "type": "tool_approval", "decision": "allow" }, "stream": true }
ActionResolution
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
  • approvedArgs must 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:

StatusCodeMeaning
400VALIDATION_ERRORmalformed or invalid resolution (nothing is consumed - answer again)
400invalid_approved_argsthe edited arguments are not arguments the tool accepts
402budget_reachedthe answer would let the agent run again and the conversation's budget is used up (nothing was recorded)
403FORBIDDEN / action_forbiddennot your conversation / you may not answer this request
404NOT_FOUNDconversation not found, or the agent cannot resolve actions
409action_not_pendingunknown or already resolved action
409conversation_busythe conversation is running a turn (message or continuation)
410action_expiredthe 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:

typescript
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:

http
POST /conversations/conv_1/actions/ct_1
{ "resolution": { "type": "client_tool", "output": { "rows": [ ... ] } } }
  • output is any JSON value, at most hitl.clientToolMaxOutputBytes (default 65 536 bytes; more is 400 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.
  • timeoutSeconds is 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: expiresAt defaults to timeoutSeconds + 30 s after the request was recorded (requestedAt), or hitl.timeoutMinutes without a timeoutSeconds. A client tool that lapses is always answered with an error the model reads ("The host page did not answer."), whatever hitl.onTimeout says 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_approval before it asks for the client_tool.
  • The event log records the call: action.required (kind: "client_tool", tool_name, args, timeout_seconds) and action.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's metadata.pendingActions keeps resolution: { type: "client_tool", outputBytes } (or the error), 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 with capabilities.clientTools: true on 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.

typescript
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):

onTimeoutWhat 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.
cancelthe 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:

typescript
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:

jsonc
// 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" }
  ]
}

A conversation history in which one approval shows Denied and a later one Approved

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.resolved event with resolved_by: "remembered", channel: "remembered", remembered: true and the tool_name;
  • GET /conversations/:id/remembered-tools lists them (also conversation.rememberedTools);
  • DELETE /conversations/:id/remembered-tools/:tool forgets one: the next call asks again (404 when 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:

typescript
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 / resolveUserQuestion and calls invoke again with the same streaming hooks (it does not use resume).

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:

typescript
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 ​

SymptomCause 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 runsThe 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 pausingThe 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 answerA 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_expiredThe request lapsed (expiresAt). The sweep applies hitl.onTimeout; make sure it runs (runs.sweepIntervalMs is not 0), or call server.expirePendingActions().
403 action_forbiddenhitl.authorizeResolve returned false for this caller.
400 invalid_approved_argsThe 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 restartThe storage provider does not persist (memory). Every other built-in provider keeps the pause.
A lapsed deny pause ended as a cancelThe agent could not be found for the sweep's own context. See the warning under Requests that lapse.

Next steps ​

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub