Agent Approvals

A tool call can run on its own or wait for a person. This page covers the policy, how a paused turn is decided, and the conversation mode a chat runs under.

The policy is part of an agent version (hitlPolicy), set with the calls on Agents. Every field: API reference.

Approvals

Human-in-the-loop approval lets a person confirm a tool call before it runs. You declare the policy on the agent; 2kw.ai pauses the turn, hands you the pending call, and continues once someone decides.

The policy

{
  "version": 1,
  "default": "deny",
  "classes": { "read": "allow", "write": "approve", "destructive": "approve" },
  "tools": { "erp_orders_*": "write", "erp_orders_delete": "destructive", "lookup_order": "read", "erp_orders_post": "approve" },
  "conversation_modes": { "offered": ["plan", "ask", "auto"], "default": "ask" }
}
KeyMeaning
classesThe action per class. read: allow, approve, block or deny. write and destructive: approve, block or deny. Defaults: read: allow, write: approve, destructive: block
toolsRules by tool name or glob (*), assigning a class (read, write, destructive) or an action (approve, deny; never allow). Every matching rule applies; a rule can only tighten, never loosen
defaultAn explicit action for unclassified tools: allow, approve, block or deny. Can only tighten the platform's own handling
conversation_modesWhich conversation modes the agent offers and which one a new conversation starts in. plan is always offered, and ask whenever auto is. Absent: {"offered": ["plan", "ask"], "default": "ask"}. The server enforces it: a request that names a mode the agent does not offer is refused
ActionEffect
allowThe call runs, or is handed to your client, without a decision
approveThe turn pauses until a person decides, or, in Auto mode, until the automatic approver or a person has (see Automatic approval). One exception: the built-in Bash under approval: sandbox runs instead (see Offline auto-run)
block, denyThe call is refused without execution or release

Actions are ordered allow < approve < block = deny. When several rules and classes apply to one call, the most restrictive action wins.

A tool's class floor comes from its annotations when it has them; the built-in catalog supplies the floor only for a tool that carries none. Your matching tools rules apply on top of that floor — every rule that matches, not just the most specific one — and the most restrictive action among them wins.

For a tool with no class and no matching rule, an explicit default combines with how the platform treats unclassified tools (normally it lets them run). The stricter action wins: deny and block refuse, approve pauses, and allow permits execution or relay. An agent's allow cannot override a platform deny. Omit default to keep the platform's behaviour; the schema default deny is not applied implicitly.

A tool with no class can still carry an action rule. approve pauses it for a person, the same as on any other tool, but it cannot override a platform deny. deny blocks it.

The policy is validated when you save it. write: allow, destructive: allow, auto as a class or tool action, default: auto, a conversation_modes.offered without plan or with auto but without ask, a default mode that is not offered, judge.hard_deny, and unknown keys are all refused. A stored policy that fails validation refuses every run of that version with 400 invalid_hitl_policy.

The same save validates every tool's annotations. Annotations that are not an object, or that omit readOnlyHint or destructiveHint or give either a non-boolean value, are refused with 400 INVALID_TOOL_ANNOTATIONS naming the tool and the offending key. This applies to every tool that carries the key, whichever plane runs it. Leaving annotations out is still valid, and an explicit null counts as leaving it out. Agents stored before this check keep the fail-closed reading: malformed annotations classify the tool as write, so every call to it pauses for approval.

What is enforced

The policy gate applies to tools executed by 2kw.ai and by your client or host. An approve action withholds the call until a person decides, or in Auto the automatic approver; block and deny refuse it without execution or release. The one exception is the built-in Bash of an agent whose shell says approval: sandbox: it runs in its offline sandbox without a pause, within a per-turn allowance (Offline auto-run). To have a person decide its commands, set approval: pause.

Automatic approval

In Auto mode an automatic approver takes some of the calls that need approval off the person. It reads the agent's instructions, the tool's description and annotations, the call's arguments and the recent conversation, and answers one of two ways: approve, and the call executes in the same turn with no pause; or escalate, and the turn pauses for a person exactly as under Ask, with the approver's reason on the pending item. The approver never rejects: a rejection is a person's to make. Ask and Plan never use it.

Which calls it sees. In Auto, a call whose action is approve goes to the approver only when all of these hold:

  • it is not destructive;
  • it is not human-only (Deliver, SkillsApply);
  • it is a read, or the tool is declared harmless: it declares destructiveHint: false and openWorldHint: false, both as JSON booleans. A function tool declares them in its annotations, an installation-catalog tool in its module's manifest, a connector tool through the MCP server's own annotations.

A missing hint is not a declaration: under MCP's own defaults a tool that says nothing is destructive and open-world. Built-in tools that write, such as Bash or memory, and tools with no class never reach the approver, so for an agent whose writes are all built-in or undeclared Auto behaves like Ask for writes. Every other call waits for a person as under Ask.

Tool and levelPlanAskAuto
Read, allowrunsrunsruns
Read, approvea persona personthe approver
Write, approve, declared harmlessrefuseda personthe approver
Write, approve, not declared harmlessrefuseda persona person
Destructive, approverefuseda persona person
Human-only built-ins (Deliver, SkillsApply)refuseda persona person
Any tool, block or denyrefusedrefusedrefused

Why a call waited. A call that pauses for a person while the conversation is in Auto carries pause_reason on its backbone:approval_request item and approval record, so your client can say why. It is omitted for a pause outside Auto.

pause_reasonWhy the call waits for a person
not_declared_harmlessThe tool does not declare the call harmless
destructiveThe call is destructive
person_only_toolOnly a person can allow this tool
approver_offThe automatic approver is not available
approver_unsureThe approver escalated; reason carries its sentence
approver_failedThe approver could not decide
approver_limitThe approver reached its limit for this turn

Fail-closed. Every way the approver can fail escalates: it is not available, it times out, it errors, it answers in a form that cannot be read, or the turn has more calls for it than it takes. An argument that carries an e-mail address, link, bank account number or encoded text that neither the user, the agent's instructions nor the approver context contains also escalates. A failure never approves a call and never runs it. The pending item's reason then names the cause in one operator-facing sentence, never a provider's error text.

Telling the approver about your environment. The approver cannot know that your staging tenant resets nightly or that invoices under 500 EUR are routine for you. An optional judge block on the policy says so in your own words:

{
  "version": 1,
  "classes": { "read": "allow", "write": "approve", "destructive": "block" },
  "conversation_modes": { "offered": ["plan", "ask", "auto"], "default": "ask" },
  "judge": {
    "environment": ["Organization: Northwind Fabrication, a machine shop."],
    "allow": ["Creating draft notes on existing third parties is routine.", "Invoices under 500 EUR to existing customers are routine."],
    "soft_deny": ["Never post to the production tenant.", "Anything touching payroll needs a person."]
  }
}

Three lists of plain sentences. environment is background, allow describes what is routine here, and soft_deny sends a call to a person even when the user asked for it. Where they conflict, soft_deny wins over allow. The lists never loosen the policy: a call the policy blocks stays blocked, and a soft_deny match escalates to a person rather than refusing outright. There is deliberately no hard_deny, and writing one is refused: use the destructive class or a tools rule such as {"purge_archive": "deny"}, which are enforced without a model.

Each list holds at most 20 entries of at most 500 characters, at most 4000 characters across all three. Over a limit, the save is refused with 400 invalid_hitl_policy naming the entry.

Keep every call whose harm a person cannot undo afterwards under a person: irreversible sends, payments and anything that leaves your systems. Do not offer Auto on an agent where that is most of what it does.

Every automatic decision creates the same approval record as a human decision, with the approver as the decider and its reasoning as the reason, so it is listed, audited and retained exactly like one; see Who decided below.

Offline auto-run

A Bash call of an agent whose backbone.bash envelope says approval: sandbox runs without a pause and without the automatic approver, where it would otherwise wait for a person or for the approver. This is the one exception to "a write needs a person or the approver", and it rests on where the command runs: the conversation's own sandbox, which has no network access, no credentials and nothing of any other conversation.

It applies only when all of these hold:

  • the call runs the built-in Bash. A function tool or connector tool that you named Bash is never auto-run;
  • Bash is enabled for your organization;
  • the policy and the conversation mode would pause the call or hand it to the approver. A deny rule, classes.write: block and plan mode still block it; ask does not stop it, because no approver is involved;
  • automatic approval is available. When it is not, these calls pause for a person again; remembered approvals still apply.

Per-turn allowance. One turn runs at most 24 such calls and reserves at most 1800 seconds of command time. Each call reserves its whole timeout (120 seconds when it sets none) before it starts, in call order, and its command is cut off at that timeout. A call that would be the 25th, or whose timeout no longer fits in what is left, pauses for a person, and its backbone:approval_request carries the reason in reason, with no automatic decider. A remembered approval for Bash still covers it. Approving or rejecting starts a new turn, with a new allowance.

Audit. An auto-run call leaves no approval record and no backbone:approval_request item. Its function_call_output carries approval_route: "sandbox_offline", in the response and in the conversation's items, so a client can tell it from a call the policy simply allowed; no other call carries the field, and a request that sends it has it dropped. The effective policy (GET /v1/agents/{agentId}/versions/{versionId}/policy) answers EXECUTE for such a Bash and names builtin.sandbox_offline among its matched rules.

Keep outbound tools away from a held agent

Offline auto-run covers the sandbox's network, and outputs: held covers file downloads. Neither restricts the agent's other tools: what the model read in this conversation, from a held file too, can leave through a connector or function tool under that tool's own approval. When an agent handles content that must not leave before a person reviews it, give it no outbound tools.

The pause

When the model calls a tool whose action is approve, the response ends with status: "requires_action" and a backbone:approval_request item. The call itself is not on the wire; nothing can execute it before the decision.

{
  "type": "backbone:approval_request",
  "id": "apreq_…",
  "call_id": "call_…",
  "tool": "erp_orders_create",
  "arguments": "{\"customer\":\"C-1042\",\"lines\":[…]}",
  "policy_class": "write",
  "hmac": "5c1a…",
  "status": "in_progress",
  "decided_by": null,
  "reason": null,
  "decision": null
}

Show the tool and its arguments to the person who decides. hmac binds the approval to exactly these arguments; echo it back unchanged. reason is null unless the automatic approver escalated the call, in which case it carries the approver's reason; show it to the person, it is the most useful sentence on the card. pause_reason is set only when the call waits for a person although the conversation is in Auto, and names why (see Automatic approval).

A call decided without a pause, by the automatic approver or by a remembered approval (see The decision below), still appears in the output: the same item with status: "completed", placed before the function_call it authorized.

{
  "type": "backbone:approval_request",
  "id": "apreq_…",
  "call_id": "call_…",
  "tool": "erp_orders_create",
  "arguments": "{\"customer\":\"C-1042\",\"lines\":[…]}",
  "policy_class": "write",
  "hmac": "5c1a…",
  "status": "completed",
  "decided_by": "auto:approver",
  "reason": "The user asked for exactly this order and the customer exists.",
  "decision": "approve"
}

Render a completed item as a fact, not a prompt: no buttons, the decider and the reason.

The decision

Add "remember": "conversation" to an approve decision to approve later calls to the same tool in this conversation automatically, including calls with different arguments. The grant applies only to the same agent on the same request surface and never overrides destructive or blocked calls. Each automatic decision still appears in the approval audit and transcript. Omit remember to approve only this call. remember is refused with 400 invalid_approval_decision on a reject and on a destructive tool, and an approval decided by a grant cannot be decided again — a backbone:approval_response naming one is refused with the same code.

Continue the run with previous_response_id and one backbone:approval_response per pending approval:

{
  "model": "agent/support-assistant@production",
  "previous_response_id": "resp_…",
  "input": [
    { "type": "backbone:approval_response", "approval_id": "apreq_…", "decision": "approve", "hmac": "5c1a…" }
  ]
}
decisionEffect
approveThe call executes in the continuation and its result goes back to the model
rejectThe model is told the call was rejected, with your optional reason, and continues

Decisions are recorded once, and a paused response is continued once. Sending the same decision again after its continuation was created is refused with 409 approval_already_decided, and nothing runs again; the message names the continuation's response id, so a client whose stream dropped fetches that response instead. If the earlier continuation failed, the same decision may be sent again: the call does not execute a second time, and the model gets its recorded outcome. Every pending approval, and every pending client tool call, must be answered on the same continuation.

ErrorWhen
400 unknown_approval_idThe id is not pending on this run's chain, or the item was sent on a first turn
400 approval_hmac_mismatchThe echoed hmac does not match the stored call
400 invalid_approval_decisiondecision is not approve or reject, the continuation names a different agent or none, or the approval was already decided by a remembered approval or by the automatic approver
400 incomplete_tool_outputsAn approval or a client tool call was left unanswered
409 approval_already_decidedEvery decision was already made, and the paused response already has a continuation

Connecting as yourself

A turn can also pause because it needs a server that requires a sign-in the caller cannot use yet: they have not connected, have not allowed this agent, or their connection has expired. Until the connection is usable, the model sees one tool for that server, mcp__<server_label>__connect. When it calls it, the response ends with status: "requires_action", a backbone:connector_auth_request item and the connect call as a function_call with status: "in_progress":

{
  "type": "backbone:connector_auth_request",
  "id": "cauth_call_…",
  "call_id": "call_…",
  "server_label": "erp",
  "host": "erp.example.com",
  "reason": "connect",
  "status": "in_progress"
}
reasonWhat the user does
connectConnects their account
allowIs connected already and allows this agent to use the connection. destinations lists the hosts the agent reaches now that it did not reach when they last allowed it
reconnectSigns in again; their connection expired or was revoked

There is one item per server per response. Other tool calls from the same round are not run, and the model is told they waited for the connection. The item names no token or code, and the conversation's backbone.pause_reason (GET /v1/conversations/{id}) is connector_auth while the turn waits.

The user acts in the chat app, not in your client: a chat shows a card with Connect, Allow or Reconnect, and any other client sends the user to the Connectors page, https://chat.2kw.ai/connectors. Once they are done, continue the paused response:

{
  "model": "agent/support-assistant@production",
  "previous_response_id": "resp_…",
  "input": []
}

A message in input instead of [] works too, for example when the user decides to go on without connecting.

  • Never answer the connect call yourself. 2kw.ai answers it on the continuation; a function_call_output you send for it is dropped and audited. Do not send the backbone:connector_auth_request item back either: it is output only, and a request that carries it is refused with 400.
  • Continue with previous_response_id. A request that continues a connect pause by conversation alone is refused with 400.
  • Continue under the agent that paused. Another agent is refused with 400.

On the continuation, 2kw.ai checks the caller's access again. If the server is usable now, the model reads connected — the tools of erp are available now and the server's tools join the run. If not, it reads not connected — the user declined or did not finish. The conversation keeps the item with status: "completed" or "incomplete". After two incomplete outcomes for one server, the model is no longer offered its connect tool in that conversation. When the user did connect but the server did not answer in time, the model is told so and the attempt does not count.

A call to a connected server that needs approval pauses with an mcp_approval_request instead of a backbone:approval_request: id, server_label, name (the tool as the server names it), arguments and an optional policy_class (read, write and so on; a client can use it to tell a read from a write in plan mode, and one that does not know it can ignore it). Answer it on the continuation with an mcp_approval_response:

{ "type": "mcp_approval_response", "approval_request_id": "apreq_…", "approve": true, "reason": "Checked with the customer", "remember": "conversation" }

reason and remember are optional, and remember works as described under The decision. There is no hmac on these items. An approved call runs as the member who raised it, with their connection, whoever approved it.

Who decided

Every approval record and every backbone:approval_request item names its decider. One convention covers people and machines:

decided_byDecider
auto:approverThe automatic approver
grant:<approval id>A remembered approval: the earlier human decision named by the id stands in for this one
any value without a colonA person with an organization account, by user id
surfaceA person using an embedded surface, identified to 2kw.ai only by their session
nullNobody yet; the item is pending

A value containing a colon is a machine decision, and the part before the colon says which machine. decision is the verb, approve or reject, on a completed item and null while pending; a machine only ever approves. reason is the decision's reason once decided, and the automatic approver's escalation reason while an escalated item is pending.

Listing approvals

GET /v1/agents/{agentId}/approvals

Approvals raised by the agent, newest first. Filter with ?status=pending, approved, rejected, cancelled or expired. Each entry carries the call, the decision, who decided and when, and after release the execution status (OK, FAILED, TIMEOUT) and output. An entry the automatic approver judged also carries judgedBy (the model) and judgeReason (its reasoning, kept even when a person later decided an escalated call). Deleting a conversation cancels its pending approvals; the records stay for audit.

Conversation Mode

The person using an agent can narrow what it may do in one conversation, without touching the agent: look only, decide the calls that need approval themselves, or let the automatic approver decide the ones the agent allows it. The mode belongs to the conversation, not to the agent.

ModeWhat happens
PlanRead only. Anything that could change something is refused. Every agent offers it.
AskNo automatic approver. Calls that need approval wait for you.
AutoThe automatic approver decides reads and writes the tool declares harmless where the agent needs approval; every other call waits for you. See Automatic approval.

Ask does not mean every call waits. A call the conversation has already approved with Remember runs without asking again, and an unclassified tool the operator lets run still runs under Ask. The built-in Bash of an agent whose shell says approval: sandbox runs too, in its offline sandbox within a per-turn allowance (Offline auto-run), because no automatic approver is involved. Only Plan refuses these.

A mode only tightens, with one exception. Plan and Ask never run a call that the policy would not. Auto is the one mode that moves a call from a person to the automatic approver, and only for calls that are not destructive, not human-only and a read or declared harmless. A destructive, block or deny call stays refused or with a person in every mode. When automatic approval is not available, Auto pauses like Ask.

Which mode a request runs under

A request runs under the first of these that exists:

  1. the mode a backbone:mode item in its input names; "mode": null means the agent's default for this request;
  2. the mode stored on the conversation;
  3. the default mode of the agent version the turn runs on (conversation_modes.default, ask when the agent sets none).

If that mode is not one the agent version offers, the request is refused before the model is called with 400 conversation_mode_not_offered. The error's detail names the mode and the modes on offer. Nothing is substituted: a conversation stored in Ask does not fall back to Auto when the author removes Ask. Removing a mode from an agent affects turns admitted afterwards; a turn already running keeps the mode it started with. A continuation that only carries decisions and names no mode is never refused for this reason.

To recover, read the agent's offered modes again, let the person pick one and send the turn again, naming it, or send "mode": null for the agent's default. The 2kw.ai chat does this itself: it reopens the mode chip, keeps what the person typed and the approvals still waiting, and sends in the mode they pick.

Setting the mode

Send a backbone:mode item in input:

{
  "model": "agent/support-assistant@production",
  "conversation": "conv_…",
  "input": [
    { "type": "message", "role": "user", "content": "Check the open orders of C-1042." },
    { "type": "backbone:mode", "mode": "plan" }
  ]
}
  • The item is read only from the current request's top-level input. The last one wins.
  • A mode you name is stored on the conversation and applies to later requests that carry no item. A null mode, and a mode taken from the agent's default, are not stored.
  • It is never taken from history, message text or tool results.
  • Send it on the requests a person starts: a new turn or a decision. Never send it on a continuation that returns your client's tool output; that continues the same run.
  • A request that is already running keeps the mode it started with.
  • A request refused before it runs, for example by validation or with conversation_mode_not_offered, leaves the stored mode unchanged. A request that fails later, for example at the model provider, keeps the new mode.

Reading the mode

  • The response's conversation_mode is the mode this request ran under, or null for a request that has none.
  • The conversation's backbone.mode (GET /v1/conversations/{id}) is the mode the person last picked, or null when none was picked and the agent's default applies.
  • A backbone:approval_request item's mode and an approval record's conversationMode are the mode the pause happened under.

An error response carries no conversation_mode. Read the conversation when you need the mode after an error.

Plan refusals and decisions

A call refused by Plan reaches the model as the tool output {"error": "blocked_by_policy", "mode": "plan", …}. An operator's block carries no mode, so a client can tell "switching the mode would help" from "switching would not help".

Deciding under Plan:

  • An approve is accepted only for a call that is read-class on its approval record and still read-only when checked against the tools that would run now. A tool that has since become a write is refused.
  • A refused approve returns 400 conversation_in_plan_mode, and the approval stays pending.
  • A reject always works.
  • A backbone:mode item with ask on the same continuation leaves Plan and approves in one request.

Previewing a mode

The effective policy (GET /v1/agents/{agentId}/versions/{versionId}/policy) takes ?mode=plan|ask|auto and answers as a conversation in that mode would be gated, without running the agent. The answer carries effectiveMode and effectiveModeSource (request when you passed mode, default otherwise). A mode the version does not offer is refused with 400 conversation_mode_not_offered. A mode that changed a tool's answer is listed among its matched rules as conversation_mode.<mode>. The same question is backbone agents policy <agent> --mode plan in the CLI and the mode argument of 2kw_get_agent_version_policy in the MCP server.

What the operator controls

hitlPolicy.conversation_modes chooses which modes the agent offers and which one a new conversation starts in:

{ "conversation_modes": { "offered": ["plan", "ask", "auto"], "default": "ask" } }

Plan is always on offer, and Ask whenever Auto is. Without the setting, Plan and Ask are offered and Ask is the default. The server enforces the list on every request that starts a turn; see Which mode a request runs under.

Was this page helpful?

A request failed? See Errors for every status and code.