Skip to content

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:

  1. Open Agents, pick the agent and go to Configure.
  2. 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).
  3. 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):

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

WhereWhat you see
responses.create()status: 'requires_action', stop_reason: 'requires_action', required_action: { type: 'agent_actions', actions }, empty output text
A background runrun.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 logaction.required (and later action.resolved)
Webhooksaction.required; for background runs also agent_run.requires_action
agents.chat() (legacy)status: 'requires_action', pendingActions, empty content

The PendingAction object ​

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

typescript
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
});
FieldTypeDescription
decision'allow' | 'deny'Required for an approval
commentstringRecorded with the decision
approved_argsobjectReplacement arguments. Must be a JSON object and valid for the tool; the Console re-validates them (400 invalid_resolution)
remember_for_sessionbooleanApprove this tool for the rest of the session. Only meaningful with decision: 'allow'

A question ​

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

typescript
resolve(conversationId, actionId, body, { background?: boolean; signal?: AbortSignal })

POST /api/client/v1/agents/sessions/{conversationId}/actions/{actionId}

AnswerWhen
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()
typescript
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 ​

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

RequestWho may answer
Tool approvalThe 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
QuestionFollows 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 ​

StatuserrorCodeMeaningWhat to do
403action_forbiddenThis caller may not answerUse a token whose owner holds agent.approve, or hand it to an approver
404action_not_found / session_not_foundNo such action / conversation in this projectCheck the ids; list pending actions again
409action_not_pendingAlready answered (by somebody else or an earlier call)Read error.response.error.action_status; refresh state
409action_cancelledCancelled before it was answered (a new message withdrew it, a run cancel, the agent's timeout policy)Nothing is left to answer
409agent_run_conflictA run is active on the conversationWait for it, or cancel it
410action_expiredThe approval request ran out of time (the agent's approval timeout)Ask the agent again
400invalid_resolutionThe answer does not fit the action (bad decision, answers, approved_args)Fix the body
400agent_background_disabledbackground: true on an agent with background execution disabledDrop background
503authorization_unavailableThe Console could not decide whether you may answerRetry
409pending_actionYou sent a new message while approvals/questions are openResolve 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:

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

typescript
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_name and args; display them in full. approved_args lets an approver change what runs, so validate it against what the tool allows.
  • remember_for_session removes the prompt for the rest of the session: offer it deliberately, not as a default.
  • Approvals are audited: action.resolved records resolved_by and the channel (for example api).
  • Do not auto-approve. An integration that answers every approval with allow defeats the control. Use deny, 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.

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