Agents
Define an assistant once — model, instructions, tools, knowledge bases and approval policy — version it like a prompt, and run it through the OpenAI-compatible Responses API or embed it in your own application.
Snippet setup
The Python and TypeScript snippets on this page assume BASE_URL = "https://api.2kw.ai" and your API key. Python: import requests, api_key = "sk_your_api_key" and headers = {"Authorization": f"Bearer {api_key}"}. TypeScript: const BASE_URL = "https://api.2kw.ai" and const apiKey = "sk_your_api_key".
How It Works
An agent is a stored configuration: which model to call, what instructions to give it, which tools it may use, which knowledge bases it may search, and which tool calls need a human to approve them. Every change creates an immutable version; labels such as production point at the version your callers get.
You run an agent with POST /v1/responses by naming it in the model field. 2kw.ai then runs the whole turn server-side: it calls the model, executes the tools the model asks for (your webhooks, knowledge search, tool discovery), pauses when a call needs approval, and returns the answer with citations.
The workflow is: create an agent → label a version → call /v1/responses with model: "agent/{name}" → handle the answer, or a pause.
The agent is authoritative
When model names an agent, instructions and tools come from the agent version. instructions and tools on the request apply only to direct provider/model calls.
Creating an Agent
POST /v1/agents
Request
curl -X POST https://api.2kw.ai/v1/agents \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_your_api_key" \
-d '{
"name": "support-assistant",
"description": "Answers product questions from the manuals",
"models": ["openai/gpt-4o", "gpt-4.1-mini"],
"instructions": "You support field technicians. Answer from the manuals and cite your sources.",
"options": { "temperature": 0.2, "maxTokens": 1200 },
"tools": [
{ "type": "file_search", "config": { "knowledgeBaseIds": ["kb_123"] } }
]
}'
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique within the organization. You can address the agent by this name |
description | string | No | What the agent is for, up to 4000 characters |
models | array | Yes, unless model is sent | Ordered list of models the agent may run on. The first entry is the default; the others are selectable per request. Each entry is provider/model such as openai/gpt-4o or azure-openai/my-deployment, or a platform model name. Up to 10, no duplicates |
model | string | No | Legacy scalar; when sent it must equal models[0]. Read back as the default. A body that sends only model sets models to that single entry — a legacy GET-then-PUT of { name, model } shrinks a multi-model agent to one |
instructions | string | No | The system instructions, up to 20 000 characters |
options | object | No | Model parameters: temperature, maxTokens, topP |
tools | array | No | Built-in and function tools, see Tools |
hitlPolicy | object | No | Human-in-the-loop policy, see Approvals |
skills | array | No | Skill bindings [{ name, ref }] loaded on demand at run time, see Skills |
Creating an agent stores its configuration. It cannot run until it has a version: create one with POST /v1/agents/{agentId}/versions, which also points the latest label at it. PUT /v1/agents/{id} changes the stored configuration and does not create a version. name is required on every update and description is written as sent, so omitting it clears it. The other fields keep their stored value when the body omits them, so { "name": "invoice-triage" } renames an agent without touching its configuration.
The console offers the same form under Agents in the sidebar, with a knowledge base picker for file_search and a per-agent Embed page.
Members can also talk to a published agent in the chat app.
Versions and Labels
Agents follow the same version and label model as prompts and schemas.
| Operation | Endpoint |
|---|---|
| Create a version | POST /v1/agents/{agentId}/versions with models (required, ordered; first = default), instructions, options, tools, hitlPolicy, changeDescription |
| List versions | GET /v1/agents/{agentId}/versions |
| Get a version | GET /v1/agents/{agentId}/versions/{versionId}, or /versions/latest |
| Re-activate a version | PUT /v1/agents/{agentId}/versions/{versionId}/activate creates a copy at the head |
| Deactivate a version | DELETE /v1/agents/{agentId}/versions/{versionId} |
| Create a label | POST /v1/agents/{agentId}/labels with name and agentVersionId |
| Move a label | PUT /v1/agents/{agentId}/labels/{labelName} with agentVersionId |
| Delete a label | DELETE /v1/agents/{agentId}/labels/{labelName} |
Label names are lowercase letters, digits and hyphens. latest is system-managed and always points at the newest version.
A run is pinned to the version it started on. Publishing a new version while a turn is paused for approval does not change what the paused turn executes.
Running an Agent
POST /v1/responses
The Responses API endpoint. Address the agent in model:
model | Runs |
|---|---|
agent/support-assistant | The agent at its latest label |
agent/support-assistant@production | The agent pinned to the production label |
agent/agt_0123… | The same, by id |
agent/support-assistant@production#gpt-4.1-mini | The agent pinned to production, run on gpt-4.1-mini for this request only. The model must be one of the version's models, else 400 |
agent/support-assistant#openai/gpt-4o | The same at latest |
openai/gpt-4o | A direct gateway call, no agent |
Request
curl -X POST https://api.2kw.ai/v1/responses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_your_api_key" \
-d '{
"model": "agent/support-assistant@production",
"input": [
{
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "What torque do the M8 mounting bolts need?" }
]
}
]
}'
| Field | Type | Description |
|---|---|---|
model | string | Agent reference (optionally with a #{model} suffix from the version's list) or provider/model |
input | array | Input items: message items with input_text, input_image or input_file parts, plus the continuation items described below, and a backbone:mode item (see Conversation Mode) |
conversation | string | A conversation id from POST /v1/conversations. The turn is appended to it and the reply is stored there |
previous_response_id | string | Continue from an earlier response instead of, or in addition to, a conversation |
max_output_tokens, temperature, top_p | Per-call overrides | |
metadata | object | Key/value metadata, stored with the response and echoed back |
store | boolean | Accepted for compatibility. Responses are always persisted so they can be chained and read back from their conversation |
tool_choice | string or object | Honoured on both paths. It can restrict which tool is called, never add one |
instructions, tools | Direct provider/model calls only. Ignored for agents |
Streaming
"stream": true returns Server-Sent Events in the Open Responses format: response.created, the
output items as they happen (text arrives as response.output_text.delta, tool calls and approvals as
whole items), then one of response.completed, response.incomplete or error + response.failed,
and data: [DONE]. A paused turn ends with response.completed whose status is requires_action.
The official OpenAI SDKs' stream helpers consume it as is. If the connection drops, the turn still
runs to its end and is stored with its conversation.
Switching models
An agent version lists the models it may run on; the first is the default. A caller with an organization credential picks another entry by appending #{model} to the reference. The switch applies to that request only: the conversation history is preserved, later turns without a suffix run on the default again. The echo carries the suffix back ("model": "agent/support-assistant@4#gpt-4.1-mini"), so the response says which model answered.
Two things to know before switching mid-conversation:
- Every switch drops the provider's cached prompt prefix, so frequent switching costs more, not less.
- No context-window check is made on a switch. If the accumulated conversation exceeds the new model's window, the provider's error is returned as-is.
Embedded surface visitors cannot switch; a # suffix from a surface token is refused as an unknown agent.
curl https://api.2kw.ai/v1/responses \
-H "Authorization: Bearer $BACKBONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "model": "agent/support-assistant@production#gpt-4.1-mini",
"input": "Summarise the last three tickets.",
"conversation": "conv_…" }'
Response
{
"id": "resp_…",
"object": "response",
"status": "completed",
"model": "agent/support-assistant@4",
"output": [
{
"type": "message",
"role": "assistant",
"status": "completed",
"phase": "final_answer",
"content": [
{ "type": "output_text", "text": "Tighten the four M8 bolts to 25 Nm in a cross pattern [h_0]." }
]
},
{
"type": "backbone:citation",
"handle": "h_0",
"knowledge_base_id": "kb_123",
"document_id": "doc_…",
"document_version_id": "ver_…",
"chunk_id": "chk_…",
"document_name": "service-manual-2026.pdf",
"page_start": 14,
"page_end": 14,
"status": "completed"
}
],
"usage": { "input_tokens": 1874, "output_tokens": 42, "total_tokens": 1916 },
"conversation": { "id": "conv_…" },
"conversation_mode": null
}
model echoes the concrete version that ran (agent/{name}@{versionNumber}), even when you addressed a label, plus the #{model} suffix when your request carried one.
conversation_mode is the conversation mode this request ran under: plan, ask, auto, or null when none is set.
status | Meaning |
|---|---|
completed | The turn finished with an answer |
requires_action | The turn is paused: a tool call is waiting for your client to answer it, for an approval, or for the user to connect a connector. Continue with previous_response_id |
incomplete | Stopped early, for example at max_output_tokens or the tool-round limit. incomplete_details says why |
cancelled | Your client stopped the turn (see Stopping a turn). output holds what finished before the stop. The turn stays in the conversation and later turns see it |
A request that fails before or during the run does not produce a response object; it returns an HTTP error in the OpenAI error envelope (see Errors).
Items whose type starts with backbone: are 2kw.ai extensions. Branch on item.type and ignore types you do not know; the OpenAI SDKs tolerate them.
A turn can carry more than one message item. When the model says something on its way to a tool call, for example "Let me look that up.", that text is a message with "phase": "commentary", placed before the calls it introduced. The answer is the last message, with "phase": "final_answer". A turn that pauses or stops early can end on commentary. To show only the answer, read the last message item; joining the text of every message shows the commentary and the answer together.
Conversations
A conversation stores the turns of one chat so you do not have to replay the transcript yourself.
| Operation | Endpoint |
|---|---|
| Create | POST /v1/conversations with optional metadata and initial items. Returns { "id": "conv_…", "object": "conversation", "created_at": … } |
| Use | Pass the id as conversation on POST /v1/responses. Each response is appended |
| Read back | GET /v1/conversations/{conversationId}/items returns the items in replay order, oldest first |
| Get, update | GET /v1/conversations/{conversationId}, POST /v1/conversations/{conversationId} replaces metadata |
| Delete | DELETE /v1/conversations/{conversationId} deletes the conversation and the responses recorded against it. Pending approvals in it are cancelled |
| Stop a turn | POST /v1/conversations/{conversationId}/cancel with { "turn_id": "…" } asks a running turn to stop. Answers 204. See Stopping a turn |
Chaining by previous_response_id works with or without a conversation. The response carries conversation.id, so a client that only kept the last response id can find its conversation again.
Stopping a turn
A chat client can stop a turn that is still running, as the Stop button in chat apps does.
- Mint an id for each turn, for example a UUID, and send it on every
POST /v1/responsesof that turn in theBackbone-Turn-Idheader. A tool-output continuation belongs to the same turn and carries the same id; an approval decision starts a new turn with a new id. The id is 1–64 characters ofA-Z,a-z,0-9,_and-. The turn needs a conversation id you already know: passconversation(create one withPOST /v1/conversationsfirst), or chain withprevious_response_id. A turn whose conversation the server creates cannot be stopped until you know its id. - To stop it, call
POST /v1/conversations/{conversationId}/cancelwith that id as{ "turn_id": "…" }.
204 means the stop was accepted, not that the turn stopped. The turn's own POST /v1/responses still returns, and its status says what happened: cancelled, or completed when the stop arrived after the answer was ready.
- The turn stops at its next step boundary: before or after a model call, or after a batch of tool calls. Work already in flight finishes, including a running tool call. Its results stay in
output. - A model round that returns after the stop is billed and discarded: its text is not in
output, and its tokens are inusage. - Closing the connection does not stop a turn. Without the header a turn cannot be stopped.
- A stop applies to the id, in this conversation: every later request carrying it stops at its first check, a tool-output continuation included. A stop for an id no request carries again changes nothing, and repeating a stop changes nothing. Mint a fresh id per turn and never reuse one.
- A malformed
Backbone-Turn-Idrefuses the whole turn with400. The stop call answers400for a malformedturn_id,404for an unknown conversation or one the caller may not address, and403for aVIEWER.
Tools
Tools are declared on the agent version in tools. Three kinds exist, plus connectors, which bring in whatever an MCP server you run offers.
Built-in tools
type | What the model can do | Configuration |
|---|---|---|
file_search | Search the agent's knowledge bases and cite passages | config.knowledgeBaseIds: the knowledge bases it may search |
file_search runs inside 2kw.ai. The model never chooses which knowledge base to search beyond what you configured.
The console's tool checklist also offers backbone.extraction (config.schemaIds) and backbone.document_convert. Both envelopes are accepted and stored on the agent version, but no executor ships for them yet, so they are not offered to the model during a run. They become active without any change on your side once their executors land.
The sandboxed shell
backbone.bash gives the model a Bash tool that runs commands in a sandbox of its own conversation: a disposable container with no network access, offered only where the deployment enables it. The envelope takes one setting for who confirms a command, approval:
approval | Effect |
|---|---|
sandbox (default for a version saved now) | Commands run without a pause, within a per-turn allowance; see Offline auto-run |
pause | Commands follow the agent's approval policy like any other write: a person decides, or the automatic approver where auto applies, or a remembered approval |
A version saved without approval is stored with sandbox. A version stored before the setting existed has none and reads as pause, so no deployed agent starts running commands unattended.
Delivered files
An agent with the sandboxed shell (backbone.bash, offered only where the deployment enables it) collects every regular file its commands write under outputs/ as an agent_output file. The function_call_output item of that call lists them in output_files, each with file_id, name, content_type, bytes, kind, delivery and preview. A symlink or another special file is named in the result and not collected, and a call collects at most 100 files and 256 MiB (2 GiB per conversation).
The shell envelope takes one setting for those files, outputs:
outputs | Effect |
|---|---|
immediate (default) | The files can be downloaded as soon as they exist |
held | The files are held: they cannot be downloaded until the person who asked for them approves a delivery |
With outputs: held the server injects a Deliver tool into the conversation. You do not configure it, and a backbone.deliver entry in tools is dropped. The model calls Deliver with 1 to 20 file_ids of held outputs of this conversation and a short note. A call that names anything else fails in the same turn with the reason, and raises no approval.
Deliver is human-only. Every shortcut is off for it: no auto rule, judge, conversation mode or "allow for this chat" grant approves it, and an auto rule that names Deliver is refused when the agent version is saved. A deny rule, classes.write: block and plan mode still block it. It pauses for a person, and the pending backbone:approval_request item carries human_only: true and a deliver card, { files: [{ file_id, name, content_type, bytes, preview }], note }.
Only the person who asked for the files decides it, approve or reject: their own signed-in session, or the surface caller of the same scope. Another member, an admin and an API key, the requester's own included, get 403 deliver_requester_only and the approval stays pending. Approving flips the files to delivered; rejecting leaves them held and tells the model why.
The hold is per conversation and only ever tightens. Once a held turn has run, the conversation stays held: switching the agent to immediate afterwards does not release files already written, and a copy of a held file is held too.
Viewing images
Wherever the sandboxed shell is offered, the server also injects a ViewImage tool, so the model can look at an image its commands produced: a chart it plotted, a rendered page, a screenshot. You do not configure it, and a backbone.view_image entry in tools is dropped.
The model calls ViewImage with one path, relative to the sandbox's working directory, such as outputs/chart.png. An absolute path, a path with .. and anything under .backbone/ are refused. The file must be a PNG, JPEG, WebP or GIF of at most 20 MB, and a command must have run first; a call before any command answers no sandbox yet: run a command first. A call that succeeds answers one line:
image `outputs/chart.png` attached as `file_607fe7c92e2b4d599d8f7b1a789dddd8` (1200×800)
The image is stored as an agent_output file of the conversation, under the same hold as the conversation's other outputs, and the model sees it as a picture from its next step on, next to a line with its path and size. Right after the call's function_call_output, the response carries a backbone:tool_image item with six fields, which a client uses to show the image beside the step:
{
"type": "backbone:tool_image",
"id": "timg_call_5",
"call_id": "call_5",
"file_id": "file_607fe7c92e2b4d599d8f7b1a789dddd8",
"path": "outputs/chart.png",
"width": 1200,
"height": 800
}
The item is output only. The server builds it from its own records, and a request that sends one as input is refused with 400 server_produced_item.
A turn attaches at most five images; the sixth ViewImage call fails with the reason and the model works from what it has. Viewed images share the per-call budget with attached images: every model call carries the ten most recent images of the conversation, viewed and attached together, and an older one keeps its line only. An image whose file has expired or can no longer be read reaches the model as a line saying it is no longer available. If the model does not accept image input, the turn is retried once without pictures, as with attachments: the model then reads only the lines.
Memory
Add a memory envelope to an agent version and the agent remembers things about the
organization member it talks to — preferences, role, recurring context — across
conversations and across agents. The model uses a memory tool with the same commands as
Anthropic's memory tool (view, create, str_replace, insert, delete, rename)
under /memories.
{ "type": "backbone.memory", "access": "read_write" }
access | The agent may |
|---|---|
read | Read the member's memory |
read_write | Read and change it |
There is one memory per member per organization, shared by every agent that has the
envelope. Give an agent that reads untrusted input (web search, connectors, uploaded files)
read, so it cannot write into what your other agents read.
Who has memory. A signed-in member in the console always does. An API-key request only
when it sends the header X-Backbone-Memory: enabled, exactly once — a backend that serves
many people on one member's key would otherwise fill that member's memory with other
people's facts. Surface end users never have memory. A continuation or an approval decision
decides again from its own request.
What is recorded. The model reads the real memory during the run. Everything stored
afterwards holds placeholders: the response, the conversation items, tool spans and approval
outcomes show {"command":"view"} and [memory view: ok], never a path or a file's
content. A member reads their own memory through the memory API.
Retention. A memory file that nobody has read or written for 180 days is deleted
automatically, by a nightly job. A read is an agent opening the file with view, or the
member opening it in My memory, with 2kw memory cat or through the memory API; every
write counts too. Listing a folder does not count: an agent's view of a directory, the
folder tree in My memory and 2kw memory ls leave the file's age unchanged.
Limits of the redaction.
- The agent's own replies may repeat what it remembered, and they are ordinary conversation content.
- When your organization captures prompts in tracing, model-call spans contain the model's full input, memory tool results included.
- A memory call that your approval policy pauses shows its arguments in the approval
request, because the approver has to see what they approve. Memory calls do not pause
unless your
hitl_policyasks for it. - An approved memory call runs only when the member who raised it approves it, from a request
that may use memory. An approval by someone else, or by an API key without
X-Backbone-Memory: enabled, is refused with HTTP 409memory_release_refused, and the approval stays pending, so the member can still approve it. Anyone who may decide the call can still reject it.
Function tools (your webhooks)
A function tool is executed by calling an HTTPS endpoint you operate. The model sees a normal function definition; 2kw.ai calls your endpoint with the arguments and returns your reply to the model.
{
"type": "function",
"name": "lookup_order",
"description": "Look up an order by its number and return status, items and delivery date.",
"parameters": {
"type": "object",
"properties": { "order_number": { "type": "string" } },
"required": ["order_number"]
},
"endpoint": "https://erp.example.com/hooks/lookup-order",
"secret": "a-long-random-string",
"annotations": { "readOnlyHint": true, "destructiveHint": false, "openWorldHint": false }
}
| Field | Description |
|---|---|
name, description, parameters | The function definition the model sees. parameters is a JSON Schema |
endpoint | The URL 2kw.ai POSTs to. A tool without an endpoint fails at call time with a message the model can read |
secret | Optional. When set, every call carries X-Backbone-Signature: sha256={hex}, an HMAC-SHA256 over the exact request body with this secret |
annotations | Optional. readOnlyHint and destructiveHint set the tool's class floor for the approval policy: readOnlyHint: true is read, destructiveHint: true is destructive, anything else is write. A tools rule in the policy can tighten that class, never loosen it. Without annotations the tool is unclassified and runs as before unless a policy rule names it or the policy sets an explicit default; a policy with no default leaves unclassified tools unchanged. openWorldHint is optional and means what it means in MCP: false says the tool does not reach outside the system it belongs to — no e-mail, no payment network, no other company. A write that declares both destructiveHint: false and openWorldHint: false is declared harmless, which is what lets auto hand it to the automatic approver |
Your endpoint receives:
POST https://erp.example.com/hooks/lookup-order
Content-Type: application/json
X-Backbone-Signature: sha256=9f2c…
Idempotency-Key: resp_…:call_…
{
"tool": "lookup_order",
"call_id": "call_…",
"organization_id": "org_…",
"arguments": { "order_number": "SO-2026-0417" }
}
Whatever your endpoint returns with a 2xx status becomes the tool result the model reads, as a string. Timeouts, connection failures and 5xx answers are retried once, then reported to the model as a failed tool so it can tell the user your system was unreachable. A 4xx is taken as a deliberate rejection and is not retried. The per-attempt timeout is 20 seconds.
Idempotency-Key is what identifies one call across attempts: {response_id}:{call_id} normally, and approval:{approval_id} for a call executed after a human approved it. Every attempt of the same call repeats the same key — the retry after a 5xx, and a run that continues after a restart. Delivery is at-least-once, so a receiver that creates records or charges money should store the key and return the first result when it sees it again. call_id alone does not identify a call: it is unique within one response, not across them.
arguments are the model's arguments parsed as JSON. If the model emits arguments that do not parse, you receive the raw string and decide what to do with it.
Client-executed tools
On a direct provider/model call you can pass tools on the request the way you would with OpenAI. 2kw.ai does not execute those: the response comes back with status: "requires_action" and a function_call item per call. Run the tool yourself and continue:
{
"model": "openai/gpt-4o",
"previous_response_id": "resp_…",
"input": [
{ "type": "function_call_output", "call_id": "call_…", "output": "{\"status\":\"shipped\"}" }
]
}
Set "failed": true on a function_call_output to tell the model the call did not succeed. Every pending call must be answered on the continuation, or the request is refused with incomplete_tool_outputs.
The embeddable surface uses the same mechanism to run tools inside the host application under the end user's own session. The approval policy gates these tools exactly like server-side ones: a write-class call is withheld until someone approves it, and the approved function_call is handed to the host on the continuation that carries the decision; a destructive one is refused.
The tool loop
One turn may take up to 8 rounds of tool calls before the model has to answer. Calls in the same round run concurrently. At most 128 tools are offered to the model on a run.
Tool discovery
An agent embedded in a host application can have hundreds of tools available through the host's tool catalog. Offering all of them on every call would cost tokens and confuse the model, so the run starts with a small set and a built-in search_tools tool:
- The model starts with the agent's own tools, the catalog's
coretools and the tools discovered earlier in the conversation. It finds the rest withsearch_tools. The set only changes when the model loads a tool, so the prompt prefix stays cacheable across turns. - The model can call
search_toolswith a plain-languagequery. Matching tools are loaded into the run and can be called on the next round. The result tells the model what was loaded and how many more tools matched. - Tools the model actually calls stay loaded for the rest of the run.
{
"loaded": ["erp_orders_list", "erp_orders_get"],
"already_loaded": [],
"not_loaded": [],
"total_matches": 12,
"hint": "These tools are now available. Call them directly. 12 tools match; showing 2 — search again with other words to see more."
}
Function tools declared on the agent itself are always loaded; discovery applies to catalog tools.
Page context
A client that knows what the user is looking at can say so with a backbone:ctx input item:
{ "type": "backbone:ctx", "ctx_type": "invoice", "ctx_id": "9912" }
The model is told which page the user is on, with your values marked as data rather than instructions. Send the item on every turn: a page that has not changed adds nothing, and each change is added once, where it happened in the conversation, with the newest one in effect.
When the user moves to a page that shows no single object — a list, a dashboard — say so with an empty item, which clears the context:
{ "type": "backbone:ctx" }
Sending no item at all is a different statement: it means the page has not changed, which is what keeps the context alive across the tool calls of one turn.
Invoking a skill
A client can say that the user picked one of the agent's skills for this turn with a backbone:skill input item, placed before the user's message:
{ "type": "backbone:skill", "name": "review" }
The model is told to load that skill before answering. The item applies to the turn it arrives in, including the tool calls that turn makes; the next user message without it is an ordinary turn again. The invocation stays in the conversation's history as a record of that turn and is not repeated.
The name must be one of the skills the agent binds. An unknown name, a missing name or a second backbone:skill item in one request is refused with 422 and the code invalid_skill_invocation, and the message lists the skills the agent does bind. Invoking a skill never changes which skills the run carries.
Connectors (MCP servers)
A connector is an MCP server you run. Declare it on the agent version and its tools join the run: 2kw.ai lists them, offers them to the model, calls the ones the model picks and gates every call.
{
"type": "mcp",
"server_label": "acme",
"server_url": "https://mcp.example.com/mcp",
"server_description": "The acme service desk",
"allowed_tools": { "tool_names": ["search_tickets", "create_ticket"] },
"require_approval": { "never": { "read_only": true } }
}
server_label is 1 to 24 characters of a-z, 0-9 and _, with no doubled __ and no leading or trailing _, and must be unique on the agent: it is what says which server a call belongs to, and the doubled underscore is what separates the label from the tool in the name below — so a label carrying one could make two different servers' tools share a name. The model sees each tool as mcp__{server_label}__{tool} — mcp__acme__search_tickets above. That prefix is reserved, so a function tool or a catalog tool named mcp__… is refused when the agent is saved.
| Field | Meaning |
|---|---|
allowed_tools | Which of the server's tools exist at all. An array of names, or a filter object. Absent allows every tool the server lists |
require_approval | "always", "never", or an object with an always and a never filter. Absent means { "never": { "read_only": true } } — read tools run, everything else asks |
A filter is { "tool_names": [...], "read_only": true } and its two halves combine with AND: that one matches a tool only if it is on the list and the server annotates it read-only. read_only: false filters nothing.
Together they give each tool one of three levels. Left out of allowed_tools is deny — the tool is not offered to the model at all. Matching always is ask: the run pauses and emits an approval request. Matching never is allow. Anything else asks, and a tool matching both sides asks.
That level is the author's ceiling, not the verdict. It joins the approval policy and the platform floor, and the strictest of them wins: a connector can tighten what the policy allows, never loosen it.
The floor reads the server's own MCP annotations, which are claims and not proof. readOnlyHint: true reads; a destructive hint destroys; everything else, including a tool with no annotations at all, is a write. A tool claiming to be both read-only and destructive counts as destructive, because that is the safer way to be wrong. On top of that: a write is never run without a human, and a destructive tool is never run at all. require_approval: "never" does not change either.
One consequence is worth knowing in advance: hitl_policy.classes.write: "auto" sends a connector's write tool to the automatic approver only when the entry also puts that tool under require_approval.never, and the tool is eligible: the server itself declares it destructiveHint: false and openWorldHint: false, or your policy names it with an exact rule such as "tools": { "mcp__erp__book_entry": "auto" }. Otherwise the run pauses for a person. A connector tool is named by its model-facing name mcp__<server_label>__<tool>, so renaming the entry's server_label silently drops that opt-in.
A run with connectors carries a few extra items. The first turn of a conversation chain gets one mcp_list_tools item per connector — a catalogue, repeated on a later turn only if that server's tools have changed in between. Every call the model makes appears as an mcp_call with its output or its error. A call that needs approval appears as an mcp_approval_request, and you answer it with an mcp_approval_response (backbone:approval_response is accepted too) on the continuation.
Approving re-lists the server first, so the call runs against the tools it offers now. If the server cannot be reached at that moment the answer is 503, nothing runs, and the approval stays pending — send the same decision again once it is back. If the server answers and no longer offers the tool, the call fails as withdrawn.
Approved hosts
Before any connector can be reached, an organization admin approves its host. Approving a host approves every path and every account on it, so it is a trust decision rather than a routing detail:
2kw connectors public-hosts add mcp.example.com # --port for anything but 443
2kw connectors public-hosts list
2kw connectors public-hosts remove {id}
Because it is a trust decision, it takes a person: add and remove need a signed-in organization admin — run 2kw auth login and sign in through the browser. A signed-in Member, Admin or Owner may list the hosts, which is what the console's agent editor offers an author to pick from. An API key is refused on all three whatever role it carries, so that a key leaked out of an automation cannot approve its own destination. Admins can also approve and remove hosts in the console under Agents → Connectors.
A connector whose host is not on that list is refused before any request leaves 2kw.ai, and a withdrawal takes effect on the very next call — including calls in a run that is already under way.
A server that is unreachable, or too slow to finish listing its tools, offers no tools for that turn: the run goes on with everything else the agent has rather than failing.
Servers that need a sign-in
Some MCP servers act as a person: they refuse an anonymous request and ask for an OAuth sign-in. 2kw.ai never shares one sign-in across the organization for such a server. Each member connects their own account, and each agent that should act as them needs their permission.
- Connecting. A member connects once, on the Connectors page of the chat app (
https://chat.2kw.ai/connectors), or from the card a paused chat shows. The server's sign-in page opens in a new tab and returns tohttps://chat.2kw.ai/connectors/callback, the one callback address 2kw.ai uses. After that, 2kw.ai renews the access in the background; when it cannot, the connection needs a reconnect. - Allowing an agent. A connection alone lets no agent use it. The member allows each agent separately, and is asked again when a new version of the agent reaches destinations it did not reach when they allowed it. Connecting from a paused chat's card also allows the agent that asked; connecting from the Connectors page stores the connection only.
- Tightening. Per allowed agent, the member can make any tool stricter than the author set it — Needs approval or Blocked — and never looser. A change applies from the next response on.
- Who the run acts as. A request with a member's session runs as that member, and a request with an API key as the key's owner. For n8n or the CLI, the key's owner therefore connects in the chat app; see Connecting as yourself for what an API client receives when nobody has. A user of an embedded panel connects from the panel, under their own account on the server, without being a member of the organization; their connection is theirs alone and usable by the installation's agent only.
2kw.ai signs in to the server's authorization server as its own OAuth client. Where that server supports it, the registration is automatic. Where it does not, Connect fails with a message to ask your administrator, and an organization admin registers 2kw.ai as an OAuth client there and enters the client ID, secret and authentication method in the console under Agents → Connectors. Changing that registration later asks everyone connected through it to sign in again the next time their access is renewed. The authorization server has to be on an approved host, like the MCP server itself.
The same page lists each server with the number of members connected to it and a Disconnect everyone action: it deletes every member's connection to that server and every permission they gave, and revokes the tokens at the server where it can. An admin can remove connections but never sees or uses anyone's token.
In the console's agent editor, the Connectors section adds a server from an approved host and a path. Test connection lists every tool the server offers, including those the entry keeps from the model; a server that needs a sign-in lists nothing until you connect as yourself from the same section. A tool overview then shows each tool's own level as three icons: Always allow, Needs approval or Blocked for a read-only tool, Auto, Needs approval or Blocked for a write; destructive tools are fixed at Blocked. The Read-only and Write group controls set every tool in the group at once, and read Custom while its tools differ. The overview writes the entry's allowed_tools and require_approval and the hitl_policy.tools rules for mcp__<server_label>__…. An entry or rules it cannot express are shown as Custom (JSON), with a Reset to simple action.
Write Auto is require_approval: "never" on the entry, and on its own it changes nothing: as described above, a connector write reaches the automatic approver only when hitl_policy.classes.write is auto as well. That key applies to every write tool of the agent, so the editor never sets it silently. While it is off, the section says Auto has no effect yet and offers the switch; once on, a Turn off link removes it again.
Connectors on a request without an agent
A POST /v1/responses request that names a model and no agent can carry mcp entries of its own. 2kw.ai lists the server's tools, offers them to the model and runs the calls it picks, the same way it does for an agent's connector, and gates them the same way: a read runs, a write asks, a destructive tool never runs.
{
"model": "openai/gpt-4o",
"input": "How many invoices are overdue?",
"tools": [
{
"type": "mcp",
"server_label": "acme",
"server_url": "https://mcp.example.com/mcp",
"authorization": "<bearer token for that server>"
},
{ "type": "function", "name": "lookup_customer", "parameters": { "type": "object" } }
]
}
- Who may. A signed-in member, or an API key, which acts as its owner. A surface session is refused with
404before it gets this far, and any caller 2kw.ai cannot name as a member is refused with400andmcp tools in a request need a member or an API key. - What is checked. The entry is held to the same rules as one saved on an agent: the host has to be approved, an entry may not name a single tool (
name), and a function tool may not be calledmcp__…. A refusal is a400and nothing is dialled. - What runs here. Only the connector's own calls. Your function tools in the same request still come back to you as
function_callitems, as they do without connectors, and you answer them on the continuation. Nothing else the model might ask for runs. authorization. An optional bearer for that server, used for this request only. 2kw.ai sends it to the server asAuthorization: Bearer …when it lists the tools and when it calls one, and never stores it, echoes it in the response'stoolsor writes it to a log. A server that needs a person's sign-in is reached through the member's connection instead: connect on the Connectors page, which stores the connection and allows no agent, and leaveauthorizationout.- Approvals. A write pauses the response with an
mcp_approval_request. Only the member who made the request can decide it, because the call runs with the bearer of the request that decides it; anyone else gets400. The continuation has to carry the samemcpentry again,authorizationincluded, since 2kw.ai kept none. Without it the answer is400and the approval stays pending, so you can send the decision again. - Waiting for a sign-in. If the member has not connected yet, the response pauses with a
backbone:connector_auth_request, and continuing it, with themcpentry again, picks up the connection once it exists. - Synchronous only. These requests run to their end or pause in the request itself; they are not a background run.
Not in this release
- Servers on a private network, reached through a relay, cannot be added yet.
- An unattended run is not held until someone connects, and nobody is notified by email: an API-key run ends with the pause described under Connecting as yourself, and continuing it is up to the caller. Connect on the Connectors page before an unattended run needs the server.
- A request without an agent cannot run its connectors in the background, and an approval it raises does not appear in an agent's approvals list.
- The CLI and the MCP server report a sign-in pause but cannot manage connections, permissions or OAuth clients; that happens in the chat app and the console.
File Search and Citations
With file_search configured, the model can search the agent's knowledge bases. Each result carries a handle such as h_0, and the model is instructed to cite the handles it used inline as [h_0].
The tool result the model reads:
{
"results": [
{
"id": "h_0",
"text": "Mounting. Tighten the four M8 bolts to 25 Nm in a cross pattern …",
"document": "service-manual-2026.pdf",
"pages": [14, 14],
"headings": ["Installation", "Mounting"]
}
],
"truncated": false
}
text is the passage with its surrounding context. truncated: true tells the model that lower-ranked hits were dropped to stay within the result budget, so it does not treat a partial list as the whole corpus. When retrieval had to fall back to keyword search, a degraded object says so.
Citations
For every handle the answer actually cites, the response carries a backbone:citation output item next to the message, with the knowledge base, document, revision, chunk, document name and page range. The [h_0] marker stays in the text so a client that only renders text still shows where a claim came from; a client that understands the item renders a footnote.
Resolve a citation to its passage at any time with GET /v1/knowledge-bases/{knowledgeBaseId}/citations/{chunkId}. The passage stays resolvable after the document is re-ingested, and after it is deleted the citation still resolves with its text withheld. Details are on the Knowledge page.
Handles are numbered per run, so two searches in one turn never reuse a handle, and a handle the model invents resolves to nothing.
Files in a Turn
Upload a file once with POST /v1/files, then attach it to a turn with an input_file part that names its id:
{ "type": "input_file", "file_id": "file_607fe7c92e2b4d599d8f7b1a789dddd8" }
The model sees one line per attachment with filename, type, size and id; images additionally reach it as pictures. Upload, retention, limits, how a file reaches a tool and the errors are on the Files page.
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": "auto", "destructive": "block" },
"tools": { "erp_orders_*": "write", "erp_orders_delete": "destructive", "lookup_order": "read", "erp_orders_post": "approve" }
}
| Key | Meaning |
|---|---|
classes | The action per class: allow, auto, approve, block or deny. Defaults: read: allow, write: approve, destructive: block |
tools | Rules by tool name or glob (*), assigning a class (read, write, destructive) or an action (approve, auto, deny; never allow). Every matching rule applies; a rule can only tighten, never loosen |
default | An explicit action for unclassified tools: allow, approve, block or deny. Can only tighten the deployment setting. auto is refused here: an unclassified tool is exactly the one the automatic approver has no metadata for |
conversation_modes | Which conversation modes the embedded chat offers and which one it starts in: {"offered": ["plan", "ask"], "default": "ask"}. Not enforced by the server |
| Action | Effect |
|---|---|
allow | The call runs, or is handed to your client, without a decision |
auto | An automatic approver decides between approving the call and escalating it to a person (see Automatic approval below) |
approve | The turn pauses until a person decides. One exception: the built-in Bash under approval: sandbox runs instead (see Offline auto-run) |
block, deny | The call is refused without execution or release |
Actions are ordered allow < auto < 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 the deployment's backbone.agent.policy.unclassified setting (AGENT_POLICY_UNCLASSIFIED, normally execute). The stricter action wins: deny and block refuse, approve pauses, and allow permits execution or relay. An agent's allow cannot override a deployment's deny. Omit default to keep the deployment's existing 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 deployment's deny. auto does nothing here, because the approver has no metadata for the tool, and the deployment setting decides as if no rule matched. deny blocks it.
The policy is validated when you save it. write: allow, destructive: allow, approve or auto, default: auto, an auto rule matching a tool whose annotations declare it destructive, 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 today
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; auto withholds it until the automatic approver has decided or a person has; block and deny refuse it without execution or release. The one exception to the first two 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
auto delegates the approve decision for a call to a separate model, the automatic approver. It reads the agent's instructions, the tool's description and annotations, the call's arguments and the last few transcript items, 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 approve, with the approver's reason on the pending item. The approver never rejects: a rejection is a person's to make.
Which writes the approver sees. For writes, auto is an allowlist. A write whose composed action is auto reaches the approver only when one of two things holds:
- Declared harmless. The tool declares
destructiveHint: falseandopenWorldHint: false, both as JSON booleans. A function tool declares them in itsannotations, an installation-catalog tool in its module's manifest, a connector tool through the MCP server's own annotations. - Named. An exact
autorule intoolsnames the tool, for example"tools": { "issue_refund": "auto" }. A glob such as"issue_*": "auto"does not name anything: it is class-wideautounder another name.
Every other write under auto pauses for a person, exactly as under approve, and the effective policy (GET /v1/agents/{agentId}/versions/{versionId}/policy) names the reason auto_eligibility among its matched rules. The effective policy does not answer for connector tools; for those, the platform's per-turn policy-gate log line names every write the gate paused. 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 are never eligible by their annotations, only by name. Reads are not affected: auto on a read-class call works as before.
Naming a tool needs its class to come from somewhere else — its annotations, the built-in catalog or a glob class rule — because tools is an object and one key cannot say both write and auto.
What naming tells the approver. The approver's input says whether you named the tool. For a tool you did not name, it escalates any call that moves or commits money, deletes or overwrites data, or changes who can access something — even when the user asked for exactly that call, because the person in the conversation is not the operator and may be an outside party. For a tool you named, you have decided that a clear request from the user is enough: the approver approves such a call when the user clearly asked for it and the arguments and the tool's description match that request, and escalates otherwise.
Adopt it downward, not upward. Because the most restrictive action wins, a per-tool auto can never loosen a class-wide approve; {"classes": {"write": "approve"}, "tools": {"create_note": "auto"}} still pauses for a person on every write. Turn the class on instead, name the tools you want judged on the user's word, and pull back the calls that must stay human:
{
"version": 1,
"classes": { "read": "allow", "write": "auto", "destructive": "block" },
"tools": { "create_note": "auto", "post_invoice": "approve", "send_email": "approve" }
}
post_invoice and send_email always wait for a person. create_note is judged per call on the user's request. Every other write is judged only if its tool declares itself harmless; the rest wait for a person.
Changed in this release
Eligibility applies to every pinned version at run time, with no flag and no grandfathering: a write under auto whose tool declares no openWorldHint and is not named now pauses for a person where the approver used to decide it. Today that includes most installation-catalog and connector tools. There are two ways back, per tool: declare destructiveHint: false and openWorldHint: false where that is true, or name the tool with an exact auto rule. Installation modules have to emit openWorldHint in their tool manifests before their catalog writes can reach the approver by declaration.
Fail-closed. Every way the approver can fail escalates: model not configured, model unavailable, timeout, provider error, budget exhausted, an answer that does not parse, or more auto candidates in one turn than the deployment's per-turn cap. 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 escalates too, decided before any model call (see Values the user did not write below). 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 the provider's error text.
What does not belong in auto. The destructive class cannot be delegated: destructive: auto is refused when you save the policy, an auto rule matching a tool annotated destructiveHint: true is refused with it, and a destructive candidate always wins the composition at run time. Unclassified tools are never judged either: the approver decides from a tool's description and annotations, and a tool without them gives it nothing to decide on. An auto rule that matches an unclassified tool therefore pauses the call for a person, exactly as approve would; annotate the tool to let the approver decide it. Beyond those two guarantees, keep under approve every call whose harm a person cannot undo afterwards: irreversible sends, payments and anything that leaves your systems.
Telling the approver about your environment. The approver reads tool metadata, the call and the conversation — it 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": "auto", "destructive": "block" },
"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 even when the approver would otherwise hesitate, and soft_deny sends a call to a person even when the user asked for it. Where they conflict, soft_deny wins over allow, and both win over anything the conversation says. This is the only part of the approver's input it treats as instruction; the conversation and every tool result stay data. 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 — the approver still has only two answers.
There is deliberately no hard_deny, and writing one is refused. A hard deny that a model has to interpret is weaker than one that is enforced without a model, and this policy already has two of those: the destructive class, and a tools rule such as {"purge_archive": "deny"}. Use those.
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 — nothing is silently shortened, so a rule you saved is a rule the approver read. The block travels with the version like the rest of the policy, and it is accepted on a policy with no auto in it (so you can write the context before turning auto on), where it is simply never read.
Values the user did not write. Before the approver's model sees a call, the platform checks where the call's addresses come from. Every e-mail address, http or https link, IBAN and base64-encoded text in the arguments must also appear in one of three sources: the user's own messages in the conversation, the agent's instructions, or the environment and allow entries of the judge block. Tool results never count, and neither do the assistant's own text or soft_deny. A call that carries a value from nowhere else — a reply-to address read from an e-mail, a payment link from a web page, an account number from a ticket — pauses for a person with a reason naming the kind of value, never the value itself. Matching is exact: billing@acme.example does not cover billing@acme.example.co or other+billing@acme.example, and a link covers only itself, not a longer path or a query. For destinations that are routine for an agent, such as the IT support mailbox or the finance address, name them in the agent's instructions or in judge.environment / allow, and those calls reach the approver as before. A reply to a sender whose address only the read e-mail contains stays a click; no rule over literal values can tell it apart from a diverted reply.
The check trusts the user's messages as the user's. If your integration forwards outside content — an e-mail body, a workflow's input, a delegating agent's request — put it in a tool result or a file, not in the user message; content in the user message counts as something the user wrote. The approver's model still weighs everything the check does not recognize, such as a destination spread over several fields or plain text copied from a tool result.
Deployment configuration. The approver is configured once per deployment, not per agent or organization. AGENT_AUTO_APPROVAL_ENABLED (default true) is the kill switch: false makes every auto behave as approve without touching any policy. AGENT_AUTO_APPROVAL_MODEL (default gpt-4.1-mini) names the judge model. A bare name is the platform's built-in deployment, on the same data path and in the same region as the built-in models an agent already uses. A provider/model name uses the organization's own configured provider instead, and the approver's input, the agent's instructions, the call's arguments and the transcript tail, then leaves for that provider. That is a data-flow change, not a quality setting; make it deliberately. Blank means not configured, and every candidate escalates. AGENT_AUTO_APPROVAL_TIMEOUT (default 10s) and AGENT_AUTO_APPROVAL_MAX_PER_TURN (default 4) bound each judgement and each turn. AGENT_AUTO_APPROVAL_MIN_APPROVE_PROBABILITY (default 0.99) is a confidence threshold: the approver approves only when the model's own probability for approving reaches it, and escalates to a person otherwise. It needs an Azure OpenAI judge model — the built-in one or your own Azure OpenAI provider — because only those report the probability today; with any other judge model every approval escalates, so set it to 0, which switches the threshold off.
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 namedBashis never auto-run; - the deployment has
Bashenabled; - the policy and the conversation mode would pause the call or hand it to the approver. A
denyrule,classes.write: blockand plan mode still block it;askdoes not stop it, because no approver is involved; - the kill switch
AGENT_AUTO_APPROVAL_ENABLEDis on. With it off, 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 (AGENT_BASH_MAX_AUTO_RUN_CALLS_PER_TURN, AGENT_BASH_MAX_AUTO_RUN_SECONDS_PER_TURN). 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. The platform marks it in its own call record and on the call's trace span, backbone.sandbox.approval_route: "sandbox_offline", beside approved for a released call and direct for every other one. 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.
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:gpt-4.1-mini",
"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…" }
]
}
decision | Effect |
|---|---|
approve | The call executes in the continuation and its result goes back to the model |
reject | The model is told the call was rejected, with your optional reason, and continues |
Decisions are recorded once. Sending the same decision again returns the recorded outcome and executes nothing. Every pending approval, and every pending client tool call, must be answered on the same continuation.
| Error | When |
|---|---|
400 unknown_approval_id | The id is not pending on this run's chain, or the item was sent on a first turn |
400 approval_hmac_mismatch | The echoed hmac does not match the stored call |
400 invalid_approval_decision | decision 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_outputs | An approval or a client tool call was left unanswered |
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"
}
reason | What the user does |
|---|---|
connect | Connects their account |
allow | Is 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 |
reconnect | Signs 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_outputyou send for it is dropped and audited. Do not send thebackbone:connector_auth_requestitem back either: it is output only, and a request that carries it is refused with400. - Continue with
previous_response_id. A request that continues a connect pause byconversationalone is refused with400. - 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) and arguments. 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_by | Decider |
|---|---|
auto:<model> | The automatic approver, running the named model |
grant:<approval id> | A remembered approval: the earlier human decision named by the id stands in for this one |
| any value without a colon | A person with an organization account, by user id |
surface | A person using an embedded surface, identified to 2kw.ai only by their session |
null | Nobody 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, or decide the calls that need approval themselves. The mode belongs to the conversation, not to the agent.
| Mode | What happens |
|---|---|
| Plan | Read only. Anything that could change something is refused. |
| Ask | No automatic approver. Calls that need approval wait for you. |
| Auto | The operator's policy as written, automatic approver included. |
| No mode set | The same as Auto. |
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. No mode runs a write unasked that the policy would not; offline auto-run is the operator's setting and applies in Auto and Ask alike. The mode is one more source in the "most restrictive wins" rule of the policy under Approvals: Plan adds block to every call that is not purely read-class, unclassified ones included, and Ask turns an auto into approve. A destructive, block or deny call stays refused in every mode. With automatic approval switched off for the deployment (AGENT_AUTO_APPROVAL_ENABLED=false), Auto pauses like Ask.
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, and an item withoutmoderesets the conversation to no mode set. - It is stored on the conversation and applies to later requests that carry no item.
- 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, 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_modeis the mode this request ran under, ornullwhen none is set. - The conversation's
backbone.mode(GET /v1/conversations/{id}) is the mode later requests will use. - A
backbone:approval_requestitem'smodeand an approval record'sconversationModeare 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
approveis 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
approvereturns400 conversation_in_plan_mode, and the approval stays pending. - A
rejectalways works. - A
backbone:modeitem withaskon 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. A mode that changed a tool's answer is listed among its matched rules as conversation_mode.<mode>. Without mode the answer is the policy with no mode set. 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 embedded chat offers and which one it starts in:
{ "conversation_modes": { "offered": ["plan", "ask"], "default": "ask" } }
Without it, Plan and Ask are offered and Ask is the default. The server never enforces it: an API client may send any mode, because no mode loosens the policy.
Embedding an Agent
An agent can be placed inside your own web application as a chat panel: your users talk to it under their own login, and tools can run inside your application under the user's own permissions. See Embeddable Surface.
Errors
| Status | When |
|---|---|
400 | A malformed model reference, a #{model} override the version does not list, an invalid stored hitlPolicy, a bad continuation (see Approvals), an invalid backbone:mode item (invalid_conversation_mode), an approve refused under Plan (conversation_in_plan_mode, see Conversation Mode), a bad file reference (see Files), or a malformed Backbone-Turn-Id header (see Stopping a turn) |
404 Agent not found | Unknown agent, or an agent the caller may not address |
410 file_expired | An attached file's lifetime ran out |
429 | The turn rate for an embedded installation and end user was exceeded |
Errors on this endpoint use the OpenAI error envelope: { "error": { "type": …, "code": …, "message": … } }.