Approvals & Questions
An agent can pause a turn to ask a person for something: a tool approval ("may I run this command?") or a question ("which environment?"). The turn comes back as requires_action, listing what to answer. client.agents.sessions.actions reads and answers those requests, and the paused turn continues from where it stopped.
New in 3.1.0
sessions.actions.list() and sessions.actions.resolve(), the requires_action status on responses and runs, the agent_run.requires_action callback, and the action error codes. They need a Console with the agent runtime phase 1 release.
When to use
- An agent runs risky tools (shell, deploys, payments, deletes) and a human must confirm each call.
- An agent needs a decision it cannot make alone and asks the user a structured question.
- You build an approval inbox, a chat UI with approve/deny buttons, or a Slack bot that answers on behalf of a person.
Turn on approvals and questions
Pausing is configured on the agent in the Console, not in the SDK:
- Open Agents, pick the agent and go to Configure.
- In Approvals & questions, switch on Let the agent ask the user questions and/or Ask before tools run (tool approval: a tool call waits for a person to approve it; read-only built-ins are not asked about unless set).
- Save, and publish again: the settings are saved with the draft, and a published version keeps the settings it was published with.
How a pause looks
A synchronous turn that pauses answers with status: 'requires_action' (not an error):
const res = await client.agents.responses.create({ model: 'ops-agent', input: 'Clean up staging' });
if (res.status === 'requires_action') {
const conversationId = res.id.replace(/^resp_/, '');
for (const action of res.required_action!.actions) {
console.log(action.id, action.kind); // 'act_…', 'tool_approval' | 'user_question'
}
}The same pause shows up everywhere a session is observed:
| Where | What you see |
|---|---|
responses.create() | status: 'requires_action', stop_reason: 'requires_action', required_action: { type: 'agent_actions', actions }, empty output text |
| A background run | run.status === 'requires_action' (runs.wait() returns), run.result.required_action.actions, run.stop_reason |
sessions.get(id) | runtime_status: 'requires_action', pending_actions |
sessions.actions.list(id) | The same pending actions |
| Event log | action.required (and later action.resolved) |
| Webhooks | action.required; for background runs also agent_run.requires_action |
agents.chat() (legacy) | status: 'requires_action', pendingActions, empty content |
The PendingAction object
type PendingAction = ToolApprovalPendingAction | UserQuestionPendingAction;
interface ToolApprovalPendingAction {
id: string; // 'act_…' — pass it to actions.resolve()
kind: 'tool_approval';
status: 'pending' | 'resolved' | 'expired' | 'cancelled';
tool_name?: string;
args?: unknown; // the arguments the model passed (approvers may edit them)
requested_at?: string; // ISO
expires_at?: string; // ISO — after this it can no longer be answered
}
interface UserQuestionPendingAction {
id: string;
kind: 'user_question';
status: 'pending' | 'resolved' | 'expired' | 'cancelled';
questions?: Array<{
question: string;
header?: string;
multi_select?: boolean;
placeholder?: string;
required?: boolean;
options?: Array<{ label: string; value?: string; description?: string }>;
}>;
allow_free_text?: boolean; // whether an answer other than the listed options is accepted
requested_at?: string;
expires_at?: string;
}A question has no tool_name.
Resolve an action
A tool approval
const next = await client.agents.sessions.actions.resolve(conversationId, action.id, {
decision: 'allow', // or 'deny'
comment: 'Staging only', // optional
approved_args: { env: 'staging' }, // optional: replaces the arguments
remember_for_session: true, // optional: stop asking for this tool in this session
});| Field | Type | Description |
|---|---|---|
decision | 'allow' | 'deny' | Required for an approval |
comment | string | Recorded with the decision |
approved_args | object | Replacement arguments. Must be a JSON object and valid for the tool; the Console re-validates them (400 invalid_resolution) |
remember_for_session | boolean | Approve this tool for the rest of the session. Only meaningful with decision: 'allow' |
A question
await client.agents.sessions.actions.resolve(conversationId, action.id, {
answers: { 'Which environment?': 'staging' }, // keyed by the question text
// multi_select: { 'Which regions?': ['eu-west-1', 'eu-central-1'] }
});
// …or skip it:
await client.agents.sessions.actions.resolve(conversationId, action.id, { cancelled: true });answers is keyed by the question text (action.questions[i].question); a value is a string or, for multi_select, a list of strings.
Options and return values
resolve(conversationId, actionId, body, { background?: boolean; signal?: AbortSignal })POST /api/client/v1/agents/sessions/{conversationId}/actions/{actionId}
| Answer | When |
|---|---|
AgentResponse (200) | The continuation finished: completed, incomplete, or requires_action when the continuation paused anew |
AgentActionsPendingResponse (200) | Other actions of the same pause are still open — also when you passed background: true. A bare requires_action body: empty output, no usage. The turn continues when the last action is answered |
AgentRun (202) | With { background: true }, when this answer completes the pause: the continuation is queued and the run comes back right away. Follow it with runs.wait() |
for (const action of res.required_action!.actions) {
if (action.kind === 'tool_approval') {
console.log('Agent wants to run', action.tool_name, action.args);
const next = await client.agents.sessions.actions.resolve(conversationId, action.id, {
decision: 'allow',
remember_for_session: true,
});
// `next` is the continuation. While other actions of the same pause are still open it is a bare
// `requires_action` body (empty `output`, no `usage`).
} else {
await client.agents.sessions.actions.resolve(conversationId, action.id, {
answers: { [action.questions![0].question]: 'staging' },
});
}
}A non-background resolve waits for the continuation, under the same ceiling a synchronous responses.create() call has (504 timeout when exceeded). For long continuations pass { background: true } — it needs background execution enabled for the agent (400 agent_background_disabled otherwise). With background: true, check result.object === 'agent.run' before waiting on it: while other actions are open you get the bare body above instead of a run. For a background run, runs.wait() returns when the run is requires_action; read run.result.required_action.actions and run.stop_reason. The agent_run.requires_action callback/webhook event fires at the same moment.
The body may also carry runtime_context (downstream headers and metadata for the continuation).
List the pending actions
const pending = await client.agents.sessions.actions.list(conversationId); // PendingAction[]The Console has no client route that lists actions, so this reads pending_actions from the session record (sessions.get()). Answered and expired actions are not listed — the event log (action.resolved) has them.
Who may answer
| Request | Who may answer |
|---|---|
| Tool approval | The Console's agent.approve permission — the person who started the session always may. On a Console without the permission engine, the agent's approvers setting decides |
| Question | Follows session ownership |
An API token acts for its owner, so the token owner must hold the permission. A caller who may not answer gets 403 action_forbidden. If the Console could not decide right now, it answers 503 authorization_unavailable — nothing changed, retry after Retry-After (the SDK already retries 503 with backoff; see Error Handling).
What can go wrong
| Status | errorCode | Meaning | What to do |
|---|---|---|---|
| 403 | action_forbidden | This caller may not answer | Use a token whose owner holds agent.approve, or hand it to an approver |
| 404 | action_not_found / session_not_found | No such action / conversation in this project | Check the ids; list pending actions again |
| 409 | action_not_pending | Already answered (by somebody else or an earlier call) | Read error.response.error.action_status; refresh state |
| 409 | action_cancelled | Cancelled before it was answered (a new message withdrew it, a run cancel, the agent's timeout policy) | Nothing is left to answer |
| 409 | agent_run_conflict | A run is active on the conversation | Wait for it, or cancel it |
| 410 | action_expired | The approval request ran out of time (the agent's approval timeout) | Ask the agent again |
| 400 | invalid_resolution | The answer does not fit the action (bad decision, answers, approved_args) | Fix the body |
| 400 | agent_background_disabled | background: true on an agent with background execution disabled | Drop background |
| 503 | authorization_unavailable | The Console could not decide whether you may answer | Retry |
| 409 | pending_action | You sent a new message while approvals/questions are open | Resolve them first (ids in error.response.error.pending_action_ids) |
error.response.error.action_status carries the final state (resolved, cancelled, expired) of a request that is no longer open. Test with isAgentActionErrorCode:
import { CognipeerAPIError, AgentActionErrorCodes, isAgentActionErrorCode } from '@cognipeer/console-sdk';
try {
await client.agents.sessions.actions.resolve(conversationId, actionId, { decision: 'allow' });
} catch (error) {
if (isAgentActionErrorCode(error, AgentActionErrorCodes.NotPending)) {
console.log('Someone else answered first');
} else if (isAgentActionErrorCode(error, AgentActionErrorCodes.Expired)) {
console.log('Too late: the request timed out');
} else if (isAgentActionErrorCode(error, AgentActionErrorCodes.Forbidden)) {
console.log('You may not approve this');
} else {
throw error;
}
}A new message while an action is open
Sending a new user message to a session that is waiting for an answer is rejected with 409 pending_action (AgentActionErrorCodes.PendingAction) — unless the agent is configured to cancel pending requests when a new message arrives, in which case they become cancelled and the message proceeds.
import { AgentActionErrorCodes, isAgentActionErrorCode } from '@cognipeer/console-sdk';
try {
await client.agents.responses.create({ model: 'ops-agent', session_id: id, input: 'Never mind' });
} catch (error) {
if (isAgentActionErrorCode(error, AgentActionErrorCodes.PendingAction)) {
// `error` is narrowed to CognipeerAPIError here
const body = error.response as { error?: { pending_action_ids?: string[] } };
console.log('Resolve first:', body.error?.pending_action_ids);
}
}Security notes
- Show the approver what will run. An approval carries
tool_nameandargs; display them in full.approved_argslets an approver change what runs, so validate it against what the tool allows. remember_for_sessionremoves the prompt for the rest of the session: offer it deliberately, not as a default.- Approvals are audited:
action.resolvedrecordsresolved_byand thechannel(for exampleapi). - Do not auto-approve. An integration that answers every approval with
allowdefeats the control. Usedeny, or leave the request to expire. - Keep the token that answers approvals separate from the token that runs agents when you can: the answering token's owner needs
agent.approve.
Troubleshooting
403 action_forbidden. An API token acts for its owner, so the Console checks the token owner, not the end user in your app. Check that the owner holds agent.approve (or started the session), and that the token's scope allows it.
409 action_not_pending right after I clicked approve. Two approvers answered, or your client retried. Treat it as success if action_status is resolved, and refresh with sessions.get().
resolve() returned status: 'requires_action' with no output. Other actions of the same pause are still open. Answer them; the turn continues when the last one is answered.
The turn never continued after resolve() with background: true. It is a queued run: follow it with runs.wait(run.id) or a webhook.
410 action_expired. Nobody answered within the agent's approval timeout. Read the log (action.resolved with expired: true) and send a new message to the session.

