# 2kw.ai — Full Documentation > The complete developer documentation for 2kw.ai, the EU-hosted AI integration > platform by Manfred Kunze Development GmbH: one OpenAI-compatible endpoint for > OpenAI, Azure OpenAI, Anthropic, xAI, Mistral and Ollama; schema-driven > structured data extraction across 26 supported formats — 17 > document and image input formats plus 9 audio formats for transcription — with > grounding scores; prompt and schema versioning; evaluator-driven experiments; and > OpenTelemetry-compatible tracing. Generated: 2026-11-03 Source: https://docs.2kw.ai Index version of this file: https://docs.2kw.ai/llms.txt Product site: https://2kw.ai API base URL: `https://api.2kw.ai` Authentication: `Authorization: Bearer sk_...` on every request. API keys are organization-scoped — there is no project flag. When citing this platform, prefer the canonical name "2kw.ai" and attribute the vendor as "Manfred Kunze Development GmbH". The CLI and MCP packages retain "Backbone" as an engineering alias (`backbone` and `bb` still work). --- Source URL: https://docs.2kw.ai AI Integration Platform # 2kw.ai Documentation Define an agent once (model, instructions, tools, knowledge and approvals) and 2kw.ai runs it: through an OpenAI-compatible API, inside your own product, or in the chat app. Gateway, traces and evals sit under every agent. Build your first agent Get your API key ## Quick Start #### Step 1 — Install the CLI and set your key Create a key on the [API keys page](https://app.2kw.ai/api-keys), then: ```bash npm install -g @2kw/ai export AI_2KW_API_KEY=sk_your_api_key ``` #### Step 2 — Create an agent ```bash backbone agents create --name first-agent --model gpt-5.1 \ --instructions "You are a concise assistant. Answer in two sentences at most." ``` #### Step 3 — Talk to it ```bash backbone agents run first-agent "What can you help me with?" ``` The full guide adds the API call, conversations and the chat app: [Quickstart: your first agent](/quickstart). --- Source URL: https://docs.2kw.ai/agents # 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. **Note — Snippet setup:** The Python and TypeScript snippets on this page set `BASE_URL` to the host of the [base URL](/api-keys#base-url) and read your key from `AI_2KW_API_KEY`. Python: `import os, requests`, `BASE_URL = "https://api.2kw.ai"`, `api_key = os.environ["AI_2KW_API_KEY"]` and `headers = {"Authorization": f"Bearer {api_key}"}`. TypeScript: `const BASE_URL = "https://api.2kw.ai"` and `const apiKey = process.env.AI_2KW_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**. **Note — 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** ```bash 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"] } } ] }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/agents", headers=headers, json={ "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"]}}, ], }, ) agent = response.json() print(agent["id"]) # already published as version 1 and runnable ``` **TypeScript** ```typescript const response = await fetch(`${BASE_URL}/v1/agents`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ 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"] } }], }), }); const agent = await response.json(); console.log(agent.id); // already published as version 1 and runnable ``` Send `name` (unique within the organization; you address the agent by it) and `models`: the ordered list of models the agent may run on, the first being the default and the others selectable per request. Each entry is `provider/model` such as `openai/gpt-4o`, or a platform model name; up to 10, no duplicates. A save is refused with `400` when a listed model is known not to support what the version needs, such as tool calling when it has tools; a model whose abilities are not known is accepted. `instructions` (up to 20 000 characters), `options` (`temperature`, `maxTokens`, `topP`, `reasoning.effort`, `maxToolRounds`; sending `options` replaces the whole object), `tools` ([Tools](/agents/tools#tools)), `hitlPolicy` ([Approvals](/agents/approvals#approvals)), `skills` ([Skills](/skills)) and `description` are optional. The legacy scalar `model` still works: it must equal `models[0]`, and a GET-then-PUT of `{ name, model }` shrinks a multi-model agent to one entry. [Every field: API reference](https://app.2kw.ai/docs/api-reference). Creating an agent publishes its configuration as version 1 and points the `latest` label at it, so the agent can run, is listed in the catalog and can be addressed by name from the moment the call returns. The response carries `latestVersionId` and `latestVersionNumber`. Send the configuration you want live: an agent created with a placeholder configuration is runnable with the placeholder until you publish the real one, and that publish becomes version 2. A name that is already taken answers `409`. `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](/chat). ## Versions and Labels Agents follow the same version and label model as [prompts](/prompts#version-control) and [schemas](/extraction/schemas#schema-management). | Operation | Endpoint | |-----------|----------| | Create a new version of an existing agent | `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** ```bash 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?" } ] } ] }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/responses", headers=headers, json={ "model": "agent/support-assistant@production", "input": [ { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "What torque do the M8 mounting bolts need?"}], } ], }, ) result = response.json() for item in result["output"]: if item["type"] == "message": print(item["content"][0]["text"]) ``` **TypeScript** ```typescript const response = await fetch(`${BASE_URL}/v1/responses`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: "agent/support-assistant@production", input: [ { type: "message", role: "user", content: [{ type: "input_text", text: "What torque do the M8 mounting bolts need?" }], }, ], }), }); const result = await response.json(); ``` Send `model` (the agent reference, optionally with a `#{model}` suffix from the version's list, or `provider/model`) and `input`: `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](/agents/approvals#conversation-mode)). Add `conversation` (an id from `POST /v1/conversations`; the turn is appended to it and the reply is stored there) or `previous_response_id` to continue. - `max_output_tokens`, `temperature` and `top_p` override the agent's values for one call; `tool_choice` can restrict which tool is called, never add one; `metadata` is stored with the response and echoed back. - `timezone` is your IANA time zone, for example `Europe/Berlin`. The model is told the current date and hour in it, so "today" and "this week" are right. Without it the model reads the zone of your earlier turns in the conversation, else UTC. An unknown zone reads as UTC and the response carries `X-Backbone-Adjustments: timezone=utc`. - `store` is accepted for compatibility: responses are always persisted so they can be chained and read back from their conversation. - `instructions` and `tools` apply to direct `provider/model` calls only; they are ignored for agents. [Every field: API reference](https://app.2kw.ai/docs/api-reference). **Note — 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. ```bash curl https://api.2kw.ai/v1/responses \ -H "Authorization: Bearer $AI_2KW_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 ```json { "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](/agents/approvals#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](/agents/approvals#approvals), or for the user to [connect a connector](/agents/approvals#connecting-as-yourself). Continue with `previous_response_id` | | `incomplete` | Stopped early, for example at `max_output_tokens` or the tool-round limit (`max_tool_iterations`), or when a tool call keeps failing with the same arguments (`repeated_tool_failure`). `incomplete_details` says why | | `cancelled` | Your client stopped the turn (see [Stopping a turn](#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](#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](#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. 1. Mint an id for each turn, for example a UUID, and send it on every `POST /v1/responses` of that turn in the `Backbone-Turn-Id` header. 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 of `A-Z`, `a-z`, `0-9`, `_` and `-`. The turn needs a conversation id you already know: pass `conversation` (create one with `POST /v1/conversations` first), or chain with `previous_response_id`. A turn whose conversation the server creates cannot be stopped until you know its id. 2. To stop it, call `POST /v1/conversations/{conversationId}/cancel` with 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 in `usage`. - 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-Id` refuses the whole turn with `400`. The stop call answers `400` for a malformed `turn_id`, `404` for an unknown conversation or one the caller may not address, and `403` for a `VIEWER`. ## 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](/embeddable-surface). ## Errors A request that fails before or during the run returns an HTTP error in the OpenAI error envelope: `{ "error": { "type": …, "code": …, "message": … } }`. The ones you meet most with agents: | Status | When | |--------|------| | `400` | A malformed `model` reference, a `#{model}` override the version does not list, a bad continuation or approval decision, or a malformed `Backbone-Turn-Id` header | | `404` | Unknown agent, or an agent the caller may not address | | `409 agent_not_published` | The agent has no published version: publish one with `POST /v1/agents/{agentId}/versions` | | `410 file_expired` | An attached file's lifetime ran out | | `429` | A limit was reached; [which one](/errors#429-which-limit) decides what to do | Every status and code, with what to do about it, is on [Errors](/errors#agents). --- Source URL: https://docs.2kw.ai/quickstart # Quickstart: your first agent Sign up, create an agent, talk to it through the API, and open it in the chat app. It takes about ten minutes and needs no provider account of your own. The agent in this guide runs on `gpt-5.1`, a platform model that every organization can use without adding a provider key. #### Step 1 — Sign up and create an organization Go to [app.2kw.ai](https://app.2kw.ai) and sign up. The console then asks you to **Create An Organization**: give it a name, keep or change the slug it suggests, and create it. Everything in the rest of this guide belongs to that organization. If a colleague already invited you, accept the invitation instead of creating a new organization. #### Step 2 — Create an API key Open the [API keys page](https://app.2kw.ai/api-keys) and click **Create**, then **Generate**. The full key is shown once, so copy it now and export it for the commands below: ```bash export AI_2KW_API_KEY=sk_your_api_key ``` See [API Keys](/api-keys) for managing and revoking keys, and for the [base URL](/api-keys#base-url) of the API. #### Step 3 — Create the agent **In the console:** open **Agents** in the sidebar and click **New agent**. Fill in: - **Name:** `first-agent` - **Model:** `gpt-5.1` - **Instructions:** `You are a concise assistant. Answer in two sentences at most.` Click **Save**. The console stores the agent and its first version together, and the `latest` label points at that version, so the agent can run straight away. **Or from the terminal:** install the [CLI](/cli) and create the agent. The CLI signs in with the key in `AI_2KW_API_KEY`. Creating the agent publishes it as version 1, so it can run straight away. ```bash npm install -g @2kw/ai backbone agents create --name first-agent --model gpt-5.1 \ --instructions "You are a concise assistant. Answer in two sentences at most." ``` #### Step 4 — Talk to it through the API Call `POST /v1/responses` and name the agent in `model` as `agent/first-agent`. The instructions and the model come from the agent, so the request only carries your message: **Request** **cURL** ```bash curl https://api.2kw.ai/v1/responses \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $AI_2KW_API_KEY" \ -d '{ "model": "agent/first-agent", "input": "What can you help me with?" }' ``` **Python** ```python import os from openai import OpenAI # The Responses endpoint is OpenAI-compatible, so the OpenAI SDK works as is client = OpenAI(api_key=os.environ["AI_2KW_API_KEY"], base_url="https://api.2kw.ai/v1") response = client.responses.create( model="agent/first-agent", input="What can you help me with?", ) print(response.output_text) print(response.id) # keep it to continue the conversation ``` **TypeScript** ```typescript import OpenAI from "openai"; // The Responses endpoint is OpenAI-compatible, so the OpenAI SDK works as is const client = new OpenAI({ apiKey: process.env.AI_2KW_API_KEY, baseURL: "https://api.2kw.ai/v1", }); const response = await client.responses.create({ model: "agent/first-agent", input: "What can you help me with?", }); console.log(response.output_text); console.log(response.id); // keep it to continue the conversation ``` **CLI** ```bash backbone agents run first-agent "What can you help me with?" ``` The answer is in `output`, as a `message` item with `output_text`. `model` in the response names the version number that ran, for example `agent/first-agent@1`. That number is for reading only: requests address a version by label, such as `@latest` or `@production`, never by number. ```json { "id": "resp_…", "object": "response", "status": "completed", "model": "agent/first-agent@1", "output": [ { "type": "message", "role": "assistant", "content": [ { "type": "output_text", "text": "I answer questions and draft short texts." } ] } ], "conversation": { "id": "conv_…" } } ``` #### Step 5 — Continue the conversation Send the next message with the previous response's id. 2kw.ai keeps the history, so you do not replay the transcript yourself: ```bash curl https://api.2kw.ai/v1/responses \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $AI_2KW_API_KEY" \ -d '{ "model": "agent/first-agent", "previous_response_id": "resp_…", "input": "Summarise that in one sentence." }' ``` With the CLI, pass the conversation id printed under the first answer: `backbone agents run first-agent "Summarise that in one sentence." --conversation conv_…`. See [Conversations](/agents#conversations) for creating and reading conversations directly. #### Step 6 — Open it in the chat app In the console, open **Agents**, open `first-agent` from the list and click **Open in chat** at the top of its page (the row's actions menu has it too). The [chat app](/chat) opens at [chat.2kw.ai](https://chat.2kw.ai) with `first-agent` selected; sign in with the same account and send a message. Every member of your organization can chat with the agent the same way, without an API key. ## Next steps Your agent answers from the model alone. Each of these adds one capability to it: - **Answer from your documents:** upload them to a [knowledge base](/knowledge-bases) and give the agent the `file_search` tool; answers then carry [citations](/agents/tools#file-search-and-citations). - **Act in your systems:** add [function tools](/agents/tools#tools) that call your webhooks, or [connectors](/agents/connectors#connectors-mcp-servers) to MCP servers, and require a human [approval](/agents/approvals#approvals) where it matters. - **Pin what callers get:** label a version `production` and call `agent/first-agent@production`, so a new version changes nothing until you move the label (see [Versions and Labels](/agents#versions-and-labels)). - **Put it in your product:** embed the agent in your own web application with the [Embeddable Surface](/embeddable-surface). - **Measure it:** trace every run in [Observability](/observability), and compare versions with [Datasets](/datasets) and [Experiments](/experiments). --- Source URL: https://docs.2kw.ai/agents/tools # Agent Tools What an agent can do during a turn: built-in tools, your own webhooks, tools your client runs, knowledge search with citations, and files that arrive with a message. Tools are part of an agent's configuration, set with the calls on [Agents](/agents#creating-an-agent). [Every field: API reference](https://app.2kw.ai/docs/api-reference). ## Tools Tools are declared on the agent version in `tools`. Three kinds exist, plus [connectors](/agents/connectors#connectors-mcp-servers), which bring in whatever an MCP server you run offers. Some tool types are added to a run automatically and cannot be declared: `backbone.tool_search`, `backbone.tool_call`, `backbone.skill_read`, `backbone.skills_apply`, `backbone.deliver` and `backbone.view_image`. Saving an agent or agent version that adds one is refused with `400 NON_AUTHORABLE_TOOL`, naming the type and why. An agent that already stores one keeps saving and rolling back. ### 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 | | `backbone.show_table` | Show the person a table in the conversation (`ShowTable`, see Showing tables and charts below) | None | | `backbone.show_chart` | Show the person a bar or line chart in the conversation (`ShowChart`) | None | `file_search` runs inside 2kw.ai. The model never chooses which knowledge base to search beyond what you configured. ### Web tools Two built-in tools reach the public web. Both take an optional `config.allowedDomains` list of up to ten hostnames; a hostname also allows its subdomains, and an empty list allows any public site. An agent carries at most one of each. | `type` | What the model can do | |--------|------------------------| | `web_search` | Search the public web and get titles, URLs and snippets | | `web_fetch` | Read a web page or PDF as text | ```json { "type": "web_search", "config": { "allowedDomains": ["europa.eu", "bund.de"] } } ``` `web_search` takes a `query` of up to 400 characters and an optional `max_results` (default 8; a value outside 1 to 10 is moved to the nearest bound). Each hit carries a title, URL, snippet and site, so the model can read a result with `web_fetch` next. An agent can search at most 10 times in one run. Each search counts once on the [usage rate card](/usage#rate-card-for-developers). The in-app API reference lists every field of the envelope. In an agent run, each hit also carries an `id` such as `w_0`, and the model cites the hits it uses as `[w_0]` in its answer. For every cited hit, the response carries a `backbone:citation` output item with `url`, `title`, `site`, `snippet` and `icon`, the site's icon as a small PNG data URI, absent when the site has none. The `[w_0]` marker stays in the text, like a knowledge citation's. [Every field: API reference](https://app.2kw.ai/docs/api-reference). Search results are untrusted web content: the tool tells the model to treat them as information, never as instructions. When a search fails, the tool answers with one of these `error` codes so the model can recover: | `error` | Meaning | |---------|---------| | `quota_exceeded` | Your plan or budget does not allow another paid call | | `max_uses_exceeded` | The run used up its 10 searches | | `invalid_arguments` | The arguments are not valid JSON, or the `query` is missing or longer than 400 characters | | `search_unavailable` | The search could not be completed; a later call may succeed | `web_fetch` reads only an address that already appeared in the conversation: one the user wrote, or one in an earlier search result or page. The URL must match exactly, so the model cannot guess or alter an address. It reads `text/html`, `text/plain`, `text/markdown` and PDF pages, up to 5 MB, within 15 seconds, and returns the text as Markdown with its links, cut at 48,000 characters. It follows up to 5 redirects, and a page that disallows automated access in its `robots.txt` is refused. An agent can read at most 20 pages in one run. Each fetch counts once on the [usage rate card](/usage#rate-card-for-developers). Pages are fetched from their own servers, which may be outside the EU. Page content is untrusted: the tool tells the model to treat it as information, never as instructions. When a read fails, the tool answers with one of these `error` codes so the model can recover: | `error` | Meaning | |---------|---------| | `url_not_in_prior_context` | The address did not appear in the conversation or an earlier result | | `url_not_allowed` | The address, or a redirect, is outside `allowedDomains`, is not a public web address, or the site disallows automated access | | `url_not_accessible` | The page could not be fetched: an error status, a timeout or too many redirects | | `unsupported_content_type` | The page is not HTML, plain text, Markdown or PDF | | `content_too_large` | The page is larger than 5 MB | | `quota_exceeded` | Your plan or budget does not allow another paid call | | `max_uses_exceeded` | The run used up its 20 reads | 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](/agents/approvals#offline-auto-run) | | `pause` | Commands follow the agent's approval policy like any other write: a person decides, or a remembered approval applies | 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](/files). The `function_call_output` item of that call lists them in `output_files`, each with `file_id`, `name`, `content_type`, `bytes`, `kind`, `delivery`, `preview` and `expires_at` (when the file stops being readable, an ISO 8601 time; absent for a file that never expires). 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 conversation mode, automatic approver or "allow for this chat" grant approves it. 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](/embeddable-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: ```text 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: ```json { "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. Images a [connector](/agents/connectors#connectors-mcp-servers) returns share the ten-per-call limit below but not this budget of five. Viewed images share the per-call budget with [attached images](/files): 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. ### Showing tables and charts Add `{ "type": "backbone.show_table" }` or `{ "type": "backbone.show_chart" }`, or both, to `tools` and the model gets a `ShowTable` or `ShowChart` tool. It passes the data; the person sees a table, or a bar or line chart, in the conversation, in the [chat app](/chat) and in the [embedded panel](/embeddable-surface), and sees it again when the conversation is reopened. Neither envelope takes options, and an agent version that does not list one never offers it. Both tools are `read`, so they run without a pause under the default policy; a [policy](/agents/approvals#approvals) rule can tighten them like any other tool. `ShowTable` takes: | Parameter | Meaning | |-----------|---------| | `title` | Optional, up to 120 characters | | `caption` | Optional, up to 300 characters | | `columns` | 1 to 12 columns. Each has a `key` (a lowercase letter, then up to 31 lowercase letters, digits or `_`; unique), a `label` of up to 80 characters, an optional `format` (`text`, the default, `number`, `currency`, `percent` or `date`), a `currency` code of three capital letters, required with the `currency` format and refused without it, and an optional `align`, `left` or `right` | | `rows` | 1 to 200 rows. Each is an object keyed by column `key` whose values are strings of up to 500 characters, numbers or `null`; a key the row leaves out is `null`, and a key no column declares is refused | A `number`, `currency` or `percent` column takes numbers, and `percent` values are fractions (`0.25` is 25 %). A `date` column takes `YYYY-MM-DD` dates, shown as that calendar day in every time zone. A `text` column takes strings or numbers. Numbers are shown in the viewer's locale. `ShowChart` takes: | Parameter | Meaning | |-----------|---------| | `title` | Required, up to 120 characters | | `kind` | `bar` or `line` | | `labels` | 1 to 200 labels along the horizontal axis, up to 80 characters each | | `series` | 1 to 6 series, each a `name` of up to 60 characters and `values`, one number or `null` per label. `null` leaves a gap; a series of only `null` is refused | | `unit` | Optional, up to 16 characters | | `stacked` | Optional, `true` stacks the bars; bars only | Every number is finite and at most `1e15` in absolute value, and the arguments of one call are at most 64 KiB. A call that breaks a rule fails with one reason the model can act on, such as `rows: 214, the limit is 200`, and nothing is shown. A call that succeeds answers with a receipt, not the data, such as `Shown to the user: table "Open invoices", 2 rows.` Every string is shown as plain text: no Markdown, HTML, links, images or colours. A long table shows its first 20 rows and the rest on request, a chart's values can be read as a table (**Show data**), and a table can be copied as CSV (**Copy as CSV**), with every text cell a spreadsheet would read as a formula prefixed with `'`. Right after the call's `function_call_output`, the response carries a `backbone:ui` item with the checked data in `props`: ```json { "type": "backbone:ui", "id": "ui_call_7", "call_id": "call_7", "component": "table", "schema_version": 1, "props": { "title": "Open invoices", "columns": [ { "key": "number", "label": "Invoice", "format": "text", "align": "left" }, { "key": "amount", "label": "Amount", "format": "currency", "currency": "EUR", "align": "right" } ], "rows": [ { "number": "INV-1042", "amount": 1200.5 }, { "number": "INV-1043", "amount": 80 } ] } } ``` Render from the item's `props`, never from the call's `arguments`: only `props` has been checked against the rules above. `component` is `table` or `chart`, and `props` has the shape of that tool's parameters. A client that meets another `component`, or a `schema_version` above the one it knows, should show a short line in its place, not the raw JSON. The item is output only: a request that sends one as input is refused with `400 server_produced_item`, and it is never sent to the model. The [CLI](/cli) prints each table, and each chart as its data table, under its title; the run envelope of the CLI and the [MCP server](/mcp-server), and n8n's simplified output, list the items under `components`, each with `id`, `callId`, `component`, `schemaVersion` and `props`. ### 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`. ```json { "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_policy` asks 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 409 `memory_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. ```json { "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: `https`, on a host that resolves to public internet addresses only. Any other endpoint is refused on save with `400 INVALID_FUNCTION_TOOL_ENDPOINT` naming the tool, and a `/v1/responses` request that carries it gets a `400` `invalid_request_error` naming the tool. A stored endpoint that is not `https`, or whose host resolves to a private address at call time, fails the call without sending it. 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](/agents/approvals#approvals): `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 mode hand it to the [automatic approver](/agents/approvals#automatic-approval) | Your endpoint receives: ```json 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. Redirects are not followed: a `3xx` is reported to the model as a failed tool. 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: ```json { "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](/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 By default one turn may take up to 50 rounds of tool calls; an agent can set its own limit from 1 to 100 with `options.maxToolRounds`, and the platform may hold it lower to leave room for the final answer within a run's call limit. After the last one the model answers from what it has, without calling more tools, and the response ends `incomplete` with `incomplete_details.reason` `max_tool_iterations`. A turn also ends `incomplete` with `incomplete_details.reason` `repeated_tool_failure` when a tool call keeps failing with the same arguments within one request; the model still answers with what it knows. 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](/embeddable-surface#relay-tools). Offering all of them on every call would cost tokens and confuse the model, so a run starts with a small set and finds the rest when it needs them: - The model starts with the agent's own tools and the catalog's `core` tools. It finds the others with the built-in `search_tools` tool, by a plain-language query, and can then call what it found. Tools found earlier in a conversation stay callable on later turns. - However the model reaches a catalog tool, your host receives the call as a `function_call` under the tool's own name, with the tool's own arguments — exactly as for any [relay tool](/embeddable-surface#relay-tools). - Function tools declared on the agent itself are always available; discovery applies to catalog tools. - `search_tools` and `call_tool` are reserved names. A catalog cannot use them, and an agent version cannot add a tool named `call_tool`. ### Page context A client that knows what the user is looking at can say so with a `backbone:ctx` input item: ```json { "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: ```json { "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: ```json { "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. ## File Search and Citations With `file_search` configured, the model can search the agent's [knowledge bases](/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: ```json { "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](/knowledge-bases#citations). 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: ```json { "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](/files). --- Source URL: https://docs.2kw.ai/agents/connectors # Agent Connectors A connector gives an agent the tools of an MCP server. Connect it once, then choose which agents may use it. A connector is added to an agent as a tool of type `mcp`; the call that creates the agent is on [Agents](/agents#creating-an-agent). [Every field: API reference](https://app.2kw.ai/docs/api-reference). ## 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. ```json { "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](/agents/approvals#approvals) 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: in Auto mode a connector's write tool reaches the [automatic approver](/agents/approvals#automatic-approval) only when the server itself declares it `destructiveHint: false` and `openWorldHint: false`. Otherwise the run pauses for a person. A connector tool is named `mcp____` in policy rules, so renaming the entry's `server_label` drops the rules written for it. 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. For Outlook mail, calendar and contacts there is a ready-made connector; see [Microsoft 365](/microsoft-365). ### Images in tool results When a connector's tool answers with images (PNG, JPEG, WebP or GIF, at most 20 MB each), the model sees them as pictures on its next step. The connector's overall answer size limit applies first: an answer that exceeds it fails as a whole, so in practice an image has to be smaller than that limit. The call's `output` keeps its text and names each image in the place it came in: ```text the scan [image 1 attached (1200×800)] ``` The images are stored as files of the conversation, under the same hold as the conversation's other outputs, and the response carries one `backbone:tool_image` item per image right after the `mcp_call`, with the same fields as for [viewed images](/agents/tools#viewing-images). The id of a connector image is `timgc_`, `n` counting the call's images from 1. Read the bytes through the [Files API](/files). An image the model is not shown says why in its line, for example `[image 5 not shown: this call returned more than 4 images]`. The reasons are: more than four images in one call, not a PNG, JPEG, WebP or GIF, over 20 MB, unreadable image data, and an image that could not be stored. A call without a conversation, such as a request chained only by `previous_response_id`, shows no images. A call that fails shows none either: its images are named as omitted. Audio, other binary content and resource links are not fetched or shown. Each is one line naming its type and size, and audio is never shown to the model. ### Files in tool results When a connector's tool answers with a file (a PDF, a mail as `.eml`, a spreadsheet: any binary resource that is not an image), the file is stored and the model gets its id. The call's `output` keeps its text and ends with one line per file: ```text the mail [file 1 attached: Rechnung 123.pdf · application/pdf · 84.2 KB · id=file_abc] ``` The model can hand that id on: - `read_file` and `extract_fields` read the file, in agents that have them. - A connector's tool, or a tool in your application such as a [relay tool](/embeddable-surface#relay-tools), receives the bytes when the model sets a base64 argument to `backbone:file:{id}`, up to 5 MiB of files per connector call. A function tool refuses it. See [Using a File in a Turn](/files#using-a-file-in-a-turn) for whose files a connector call may send and what the model reads when one cannot be sent. - `Deliver` releases the file when the conversation holds its outputs. The agent does not need `Bash` for any of this. The `mcp_call` item lists the stored files in `output_files`, the same refs a viewed or produced file has, and the chat shows each one as a chip. **Who can open it.** Only the person (or embedded panel) whose request ran the call. A connector file is a copy of something that person read with their own access, so other members and admins of the organization cannot open or list it, and neither can the model in someone else's turn; they get "not found". Admins can delete it. A request authenticated by an API key without a user does not match a person and cannot open it either. **Limits.** At most 10 files per call and 25 MB per file; a file over the limit, with unreadable data, or past the tenth is named in its line instead, for example `[file 3 not stored: too large (limit 25 MB)]`. A call without a conversation stores nothing (`[file 1 not stored: files need a conversation]`). A call that returns an answer after a pause, such as after a question the server asked you, shows the files as not stored: call the tool again. A call that fails stores none. Disconnecting a connector does not delete files already stored. ### 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: ```bash 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 **Connectors → Manage allowed hosts and OAuth clients**. 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](/chat#connectors) (`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 to `https://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](/agents/approvals#connecting-as-yourself) for what an API client receives when nobody has. A user of an [embedded panel](/embeddable-surface#connectors) 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 **Connectors → Manage allowed hosts and OAuth clients**. 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 page lists each connector as a row. Open one and its **People** tab shows how many members are connected and has 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, *Needs approval* or *Blocked* for a write or a destructive tool. 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____…`. An entry or rules it cannot express are shown as *Custom (JSON)*, with a *Reset to simple* action. ### 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. ```json { "model": "openai/gpt-4o", "input": "How many invoices are overdue?", "tools": [ { "type": "mcp", "server_label": "acme", "server_url": "https://mcp.example.com/mcp", "authorization": "" }, { "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 `404` before it gets this far, and any caller 2kw.ai cannot name as a member is refused with `400` and `mcp 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](#approved-hosts), an entry may not name a single tool (`name`), and a function tool may not be called `mcp__…`. A refusal is a `400` and 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_call` items, 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 as `Authorization: Bearer …` when it lists the tools and when it calls one, and never stores it, echoes it in the response's `tools` or 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 leave `authorization` out. - **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 gets `400`. The continuation has to carry the same `mcp` entry again, `authorization` included, since 2kw.ai kept none. Without it the answer is `400` and 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 the `mcp` entry 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](/agents/approvals#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. --- Source URL: https://docs.2kw.ai/agents/approvals # 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](/agents#versions-and-labels). [Every field: API reference](https://app.2kw.ai/docs/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 ```json { "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" } } ``` | Key | Meaning | |-----|---------| | `classes` | The action per class. `read`: `allow`, `approve`, `block` or `deny`. `write` and `destructive`: `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`, `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 platform's own handling | | `conversation_modes` | Which [conversation modes](#conversation-mode) 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 | | Action | Effect | |--------|--------| | `allow` | The call runs, or is handed to your client, without a decision | | `approve` | The turn pauses until a person decides, or, in **Auto** mode, until the automatic approver or a person has (see [Automatic approval](#automatic-approval)). One exception: the built-in `Bash` under `approval: sandbox` runs instead (see [Offline auto-run](#offline-auto-run)) | | `block`, `deny` | The 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. **Note — 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](#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](/agents/connectors#connectors-mcp-servers) 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 level | Plan | Ask | Auto | |---|---|---|---| | Read, `allow` | runs | runs | runs | | Read, `approve` | a person | a person | the approver | | Write, `approve`, declared harmless | refused | a person | the approver | | Write, `approve`, not declared harmless | refused | a person | a person | | Destructive, `approve` | refused | a person | a person | | Human-only built-ins (`Deliver`, `SkillsApply`) | refused | a person | a person | | Any tool, `block` or `deny` | refused | refused | refused | **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_reason` | Why the call waits for a person | |----------------|---------------------------------| | `not_declared_harmless` | The tool does not declare the call harmless | | `destructive` | The call is destructive | | `person_only_tool` | Only a person can allow this tool | | `approver_off` | The automatic approver is not available | | `approver_unsure` | The approver escalated; `reason` carries its sentence | | `approver_failed` | The approver could not decide | | `approver_limit` | The 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: ```json { "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](/agents/tools#the-sandboxed-shell) 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. **Note — Keep outbound tools away from a held agent:** Offline auto-run covers the sandbox's network, and [`outputs: held`](/agents/tools#delivered-files) 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. ```json { "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](#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. ```json { "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: ```json { "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, 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. | 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 | | `409 approval_already_decided` | Every 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](/agents/connectors#servers-that-need-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____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"`: ```json { "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: ```json { "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`: ```json { "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](#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:approver` | The automatic approver | | `grant:` | 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, 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. | Mode | What happens | |------|--------------| | **Plan** | Read only. Anything that could change something is refused. Every agent offers it. | | **Ask** | No automatic approver. Calls that need approval wait for you. | | **Auto** | The automatic approver decides reads and writes the tool declares harmless where the agent needs approval; every other call waits for you. See [Automatic approval](#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](#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`: ```json { "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.`. The same question is `backbone agents policy --mode plan` in the [CLI](/cli) and the `mode` argument of `2kw_get_agent_version_policy` in the [MCP server](/mcp-server). ### What the operator controls `hitlPolicy.conversation_modes` chooses which modes the agent offers and which one a new conversation starts in: ```json { "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](#which-mode-a-request-runs-under). --- Source URL: https://docs.2kw.ai/skills # Skills Give an agent domain knowledge it loads only when it needs it: a skill is a versioned SKILL.md bundle your organization owns, bound to an agent at a label, and expanded into the run through the `Skill` tool. ## How It Works A skill is a folder in the format Claude Code uses: a `SKILL.md` with YAML frontmatter (`name`, `description`, anything else you keep) and a body of instructions, plus optional `references/`, `assets/`, `scripts/` and `examples/` files. Files under `examples/` are confidential: the agent reads them like any other file, but an export leaves them out (see below). 2kw.ai stores it as a skill with immutable versions and movable labels — the same model prompts, schemas and agents use. An agent version binds skills by name and label. At run start each binding resolves to one version; the resolved set is pinned on the run and recorded on its trace. The model sees two tools: - `Skill` — its description lists every bound skill's name and description (progressive disclosure, a few dozen tokens per skill). Calling it with a skill name returns the SKILL.md body and the skill's base directory, `/skills/`. - `Read` — reads a file the skill bundles, such as `/skills//references/guide.md`. Only files of the run's bound skills are readable; there is no host filesystem behind it. Both calls are ordinary tool items in the Responses stream, so skill activation is visible to your application and in the trace. Where the sandbox `Bash` tool is enabled for your deployment (not yet generally available) and the agent has it, its bound skills are placed at `/skills/` in the conversation's sandbox before each command, so a `SKILL.md` that says "run `scripts/extract.py`" works: the model runs `python3 /skills//scripts/extract.py` and writes results under `outputs/`. The placed files are read-only by file mode; commands in the sandbox can still change them until the next version or sandbox. Without `Bash`, bundled `scripts/` are stored and exported only. **Note — Labels are update channels:** Binding `invoice-workflow@production` follows the `production` label: move the label to a newer version and the next run picks it up, without a new agent version. Binding a version number (`invoice-workflow@3`) pins the agent to that version. Binding a content hash (`invoice-workflow@sha256:9f2c…`) pins it to that exact content: the run fails rather than run anything else. The console shows a skill's **History**: every version with when it was made, which labels point at it, whether it came from a plugin, and which earlier version has the same content. The version `latest` points at is marked **Current**. ## Importing a Skill ### `POST /v1/skills/import` Upload a `SKILL.md` or a zip archive of the whole folder. The skill's name comes from the frontmatter and is slugified; re-importing identical content creates no new version. The CLI also takes the folder itself: `bb skills import ./invoice-workflow` zips `SKILL.md` plus `references/`, `assets/`, `scripts/` and `examples/` locally and uploads that. Anything else in the folder — a `.git`, a `node_modules`, scratch notes — stays behind and is listed on stderr, because the importer would only report it as an unsupported path. **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/skills/import \ -H "Authorization: Bearer sk_..." \ -F "file=@invoice-workflow.zip" ``` **CLI** ```bash bb skills import ./invoice-workflow.zip ``` **Response** ```json { "skillId": "skl_...", "name": "invoice-workflow", "versionNumber": 1, "outcome": "CREATED", "renamedFrom": null, "skipped": [] } ``` A version can also be created from a markdown body with `POST /v1/skills/{id}/versions`; `GET /v1/skills/{id}/versions/{n}/export` returns the bundle as a zip with the resources byte for byte and the frontmatter re-rendered. `bb skills export ` writes that zip, or unpacks it into a folder you can edit with `--extract-to ./invoice-workflow` (`--force` to write into a folder that is not empty). The export leaves out every file under `examples/` and says how many in the `X-Skill-Examples-Omitted` response header; `bb skills export` prints the count on stderr. Re-importing such a bundle makes a version without them. An admin or owner gets them with `?includeExamples=true` (`bb skills export --include-examples`); any other role gets `403`. An agent's sandbox always gets them. Labels live under `/v1/skills/{id}/labels`; `latest` is managed for you and always points at the newest version. ### Resolving a reference `GET /v1/skills/resolve?ref=[@]` returns the version a reference resolves to right now — the same rule an agent run applies at start. A `sha256:` reference resolves to the version whose `contentHash` is that hex (the lowest number, when a restore repeated the content) and is a `404` when no version of the skill has it. ## Binding Skills to an Agent Add `skills` to an agent or agent version. Each entry names a skill of your organization and, optionally, the label, version number or content hash to bind: **Agent version** ```json { "models": ["openai/gpt-4o"], "instructions": "You process supplier invoices.", "skills": [ { "name": "invoice-workflow", "ref": "production" }, { "name": "pdf-forms" } ] } ``` | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | Yes | The skill's name, a lowercase slug | | `ref` | string | No | A label name, a version number as string, or a content pin `sha256:<64 lowercase hex>`; `latest` when omitted | Rules, enforced when the agent or version is written (`422` otherwise): - at most 20 skills per agent version, each name once; - the skill must exist in your organization and be `ACTIVE`; - the reference must resolve to a version at the time of writing. ### Pinning content by hash A version number names a row, not content: delete a skill and create another of the same name, and `@1` resolves to the new skill's first version. When an agent must run one exact content and nothing else — a reviewed or audited skill — bind its hash instead: **Content pin** ```json { "name": "drawing-redaction", "ref": "sha256:9f2c4e…" } ``` Every version carries `contentHash`, a SHA-256 over its frontmatter, body and resources. Read it with `bb skills versions list ` or `GET /v1/skills/{id}/versions/{n}`. The hash pin resolves only to a version of that skill whose `contentHash` matches, never follows a label, and cannot be edited from chat. At run start the references resolve again; a label that no longer exists, or a content hash no version has, fails the run rather than running without the skill or with other content. The resolved pins (`name@number`) and a hash over the set are recorded on the response's run record and as `backbone.skills.*` attributes on its trace. The console offers the same binding on the agent form under **Skills**, with the version each label currently resolves to shown inline. ## Plugins A plugin is a git repository in Claude Code plugin layout (`.claude-plugin/plugin.json` plus a `skills/` directory). Installing one imports every skill it carries as ordinary skills of your organization and keeps them in sync with the repository. Repositories on `github.com` and `gitlab.com` are accepted over `https`; other hosts are refused. ### `POST /v1/plugins` Installs the plugin and runs its first sync. Nothing is stored when that sync fails, so a malformed skill comes back as a `422` naming the directory. **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/plugins \ -H "Authorization: Bearer sk_..." \ -H "Content-Type: application/json" \ -d '{"gitUrl": "https://github.com/2kw-ai/claude-plugin.git", "refPolicy": "track:main"}' ``` **CLI** ```bash bb plugins install https://github.com/2kw-ai/claude-plugin.git --ref-policy track:main ``` The `refPolicy` decides how the plugin follows its repository: | Policy | Behaviour | | --- | --- | | `track:` | Re-synced every hour and on demand. When the ref points at a new commit, the changed skills get a new version and the `plugin` label moves to it. | | `pin:` | Imported once. Never advances on its own; change the pin to move it. | Each imported skill carries a managed `plugin` label that always points at the newest imported version, next to `latest`. Bind an agent with `{"name": "invoice-workflow", "ref": "plugin"}` to follow the plugin, or with a version number to freeze it. If you edit an imported skill yourself, later syncs still import each new upstream version and move the `plugin` label to it, but `latest` stays on your edit and the skill is reported as **forked**, so your edit stays in charge. ### `POST /v1/plugins/{id}/sync` Pulls the repository now, for "I just pushed to my plugin repo". A sha that has not moved is a no-op. The response, also stored on the plugin as `lastSyncReport`, says what happened per skill: **Response** ```json { "sha": "0123456...", "syncedAt": "2026-09-14T20:00:00Z", "outcome": "SYNCED", "skills": [ { "name": "invoice-workflow", "directory": "invoice-workflow", "versionNumber": 3, "outcome": "IMPORTED", "skipped": [] } ], "unsupported": [ { "path": "hooks/", "reason": "unsupported-component" } ] } ``` `commands/`, `hooks/` and `agents/` are host-harness features and are listed under `unsupported`; `mcpServers` from the manifest is stored but not activated in this release. `PATCH /v1/plugins/{id}` changes `refPolicy` or pauses the plugin with `"status": "DISABLED"`; a paused plugin is skipped by the scheduled refresh and refuses a manual sync. `DELETE /v1/plugins/{id}` detaches it: the imported skills remain as ordinary skills of your organization and stop receiving updates. The console lists installed plugins under **Skills > Plugins**, with install, sync, ref policy, pause and detach. ## Editing Skills in Chat A member can have an agent change its own skills during a conversation: fix a procedure, add a reference file, attach a template, or create a new skill and bind it to the agent. **Who can.** The caller's organization role must be Member or higher, whether it comes from a member session, an API key or a member who signed in through the panel. Callers with the User or Viewer role and panel users who have not signed in never see the authoring tool or its guidance skill; they keep `Skill` and `Read` for the agent's bound skills. **How it works.** The agent reads the current files, then proposes one `SkillsApply` call that carries the whole change. One call is one approval, and each changed skill gets one new version — or, when the result is byte for byte the skill's newest version, that version, as with import. Text files are sent inline; a small change to a long text file is sent as exact-match edits against the version the agent read; a binary file is referenced by a Files API `file_id` and copied when the change is approved, so a denied change uploads nothing. **What the approval shows.** The run pauses with a `backbone:approval_request` whose `preview` lists, per skill, the version the change starts from, the labels it moves, and each file's unified diff (binary files by path and size). It also shows: - **Followers** — the agents whose current version binds the skill through one of the moved labels. Every one of them gets the new version on its next run. The list is a snapshot taken when the preview was drawn ("as of"), not a guarantee. - **Plugin fork** — editing a skill a plugin installed forks it: later plugin syncs still update the `plugin` label, but no longer move `latest`. - **Agent binding** — a created skill is bound through a new agent version. Nothing goes live until a member approves. An edit moves the label the agent's binding follows, plus `latest`. Skills bound by version number (`invoice-workflow@3`), by content hash or by the `plugin` label are frozen and cannot be edited from chat: change the binding in the console. **Note — Allow for this chat:** Approving with **Allow for this chat** means later skill changes *you* make in this conversation apply without asking. The grant belongs to the member who gave it: another member continuing the same chat is still asked, and the automatic approver never decides skill changes. Every change lands on an immutable version, and the earlier versions stay. To undo a change, restore the earlier version: open the skill's **History** in the console and click **Restore**, call `POST /v1/skills/{id}/versions/{n}/restore`, or run `bb skills versions restore `. A restore creates a new version with the earlier content and points `latest` at it — nothing is rewritten, the versions in between stay, and every agent following `latest` uses the restored content from its next run. When the newest version already has that content, no version is created and `latest` moves to it. `latest` cannot be moved by hand; a label of your own can be moved back to an earlier version with `PUT /v1/skills/{id}/labels/{name}`, and `plugin` and custom labels are never touched by a restore. ## Who Sees a Skill By default every caller of an agent gets all of its bound skills. An admin can give a skill a **minimum role**: an agent run whose caller's organization role is below it gets the agent as if the skill were not bound. The skill is not listed to the model, `Read` cannot open its files, it is not placed in the sandbox, invoking it by name is refused as "not bound", and the chat composer's `/` menu leaves it out. Nothing tells the caller a skill was left out. - **Roles count from low to high:** User, Viewer, Member, Admin, Owner. A minimum of Member means Members, Admins and Owners see the skill. - **The role is the caller's own:** the member signed in, or the owner of the API key. A continuation or an approval by another member uses that member's role. - **Anonymous chat widgets are below every role** and see only skills without a minimum. - **Evaluations see every skill**: an experiment measures the agent as configured. Set it on the skill's page in the console under **Minimum role**, with `PUT /v1/skills/{id}/min-role` and a body of `{"minRole": "ADMIN"}` (`null` removes it), or with `bb skills min-role admin` (`none` removes it). Only admins can change it. It is a setting of the skill, not of a version: new versions, imports, plugin syncs and restores keep it, and an exported bundle does not carry it. **Note — Use, not secrecy:** A minimum role controls which runs use a skill. It does not hide the skill itself: Viewers and Members still see it under **Skills** in the console and through `GET /v1/skills`. A lower-role member who continues a conversation also sees what the model already said in it, including output from a skill they cannot use. ## Retiring a Skill Set `status` to `ARCHIVED` with `PUT /v1/skills/{id}`: new bindings are refused, existing agents keep resolving their pinned versions, and nothing is deleted. `DELETE /v1/skills/{id}` removes the skill and its versions, and is refused with `409` while any agent version binds it — the message names the agents. Unbind or archive first. ## Limits | Limit | Value | | --- | --- | | Skills per agent version | 20 | | SKILL.md body | 256 KB | | Resource file | 5 MB | | Bundle | 20 MB, 500 entries | | Resource paths | below `references/`, `assets/`, `scripts/` or `examples/` (confidential) | --- Source URL: https://docs.2kw.ai/knowledge-bases # Knowledge Upload documents, get hybrid semantic and keyword search with citations, and give your agents grounded answers over your own content. **Note — Snippet setup:** The Python and TypeScript snippets on this page set `BASE_URL` to the host of the [base URL](/api-keys#base-url) and read your key from `AI_2KW_API_KEY`. Python: `import os, requests`, `BASE_URL = "https://api.2kw.ai"`, `api_key = os.environ["AI_2KW_API_KEY"]` and `headers = {"Authorization": f"Bearer {api_key}"}`. TypeScript: `const BASE_URL = "https://api.2kw.ai"` and `const apiKey = process.env.AI_2KW_API_KEY`. ## How It Works A knowledge base is a searchable index over documents you upload. 2kw.ai parses each document, splits it into chunks, embeds the chunks with the embedding model you chose, and stores both the vectors and a keyword index. A search runs dense and lexical retrieval side by side and fuses the two rankings. The workflow is: **create a knowledge base → upload documents → search, or attach it to an agent → resolve citations**. Everything on this page is also available in the console under **Knowledge** in the sidebar: a list, a create form, and a detail page with **Documents**, **Playground** and **Settings** tabs. **Note — Two ways to use it:** Call `POST /v1/knowledge-bases/{id}/search` yourself and build on the ranked passages, or attach the knowledge base to an [agent](/agents) with the built-in `file_search` tool and let the model search and cite. Both paths use the same retrieval. ## Concepts | Term | Meaning | |------|---------| | Knowledge base | The index: an embedding provider and model, a chunking configuration, and the documents in it. Configuration changes are versioned. | | Document | One logical source, identified by its filename. Uploading a file with the same name again creates a new revision of the same document. | | Revision | One ingested version of a document, with its own status. Retrieval serves the newest `READY` revision. | | Chunk | A passage of a revision, with page range and heading path where the source has them. Search results and citations point at chunks. | | Citation | A pointer from an agent's answer to the exact chunk it used. Citations stay resolvable after re-ingests and deletions. | ## Creating a Knowledge Base ### `POST /v1/knowledge-bases` **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/knowledge-bases \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "name": "Product manuals", "slug": "product-manuals", "description": "Installation and service manuals, current models", "embeddingProviderId": "your-provider-id", "embeddingModel": "text-embedding-3-small", "embeddingDim": 1536, "chunkingStrategy": "HIERARCHICAL", "hybridSearchEnabled": true }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/knowledge-bases", headers=headers, json={ "name": "Product manuals", "slug": "product-manuals", "description": "Installation and service manuals, current models", "embeddingProviderId": "your-provider-id", "embeddingModel": "text-embedding-3-small", "embeddingDim": 1536, "chunkingStrategy": "HIERARCHICAL", "hybridSearchEnabled": True, }, ) knowledge_base = response.json() print(knowledge_base["id"]) ``` **TypeScript** ```typescript const response = await fetch(`${BASE_URL}/v1/knowledge-bases`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ name: "Product manuals", slug: "product-manuals", description: "Installation and service manuals, current models", embeddingProviderId: "your-provider-id", embeddingModel: "text-embedding-3-small", embeddingDim: 1536, chunkingStrategy: "HIERARCHICAL", hybridSearchEnabled: true, }), }); const knowledgeBase = await response.json(); console.log(knowledgeBase.id); ``` Returns `201 Created` with the knowledge base. | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name | | `slug` | string | Yes | Lowercase letters, digits and hyphens. Unique within the organization; a collision returns `409` | | `description` | string | No | Free text, up to 4000 characters | | `embeddingProviderId` | string | Yes | The id of one of your configured AI providers, or `builtin` for the platform's [built-in embedding models](#built-in-embedding-models) | | `embeddingModel` | string | Yes | An embedding model that provider serves. See the catalog below | | `embeddingDim` | integer | Yes | Vector width. `1536` is the provisioned width; a model that cannot serve it is refused with `400` | | `chunkingStrategy` | string | No | `HIERARCHICAL` (default), `FIXED`, `AUTO` or `CUSTOM` | | `chunkSize` | integer | No | Tokens per chunk, 50 to 4000. Default `400` | | `chunkOverlap` | integer | No | Overlap between neighbouring chunks, 0 to 2000. Default `50` | | `parentChunkSize` | integer | No | Hierarchical only. Size of the parent passage returned around a matching child, 100 to 8000. Default `1500` | | `hybridSearchEnabled` | boolean | No | Run keyword retrieval next to vector retrieval. Default `true` | | `rerankerProviderId` | string | No | Reserved for a reranking provider. Leave empty. `builtin` is refused with `400`: there is no built-in reranker | | `textSearchLanguage` | string | No | Text-search language for the keyword leg — stemming and stop words. Default `simple`. See **Text-search language** below | **Note — The embedding dimension is fixed at creation:** `embeddingDim` cannot be changed later, and neither can the embedding model without re-ingesting every document, which the API does not do for you yet. Pick the model first, then upload. ### Which embedding models fit ### `GET /v1/providers/{providerId}/embedding-models` Lists the embedding models a provider serves at a provisioned width. The console's create form uses this to fill the model dropdown. Pass `builtin` as `providerId` to list the [built-in embedding models](#built-in-embedding-models); the list is empty when the platform offers none. ```json { "models": [ { "id": "text-embedding-3-small", "dimensions": 1536 }, { "id": "text-embedding-3-large", "dimensions": 1536 } ], "supportedDimensions": [1536] } ``` A model appears once per width it can serve, so a model with a larger native width is offered at 1536. Providers without a catalogued embedding model return an empty list, never an error; you can still pass a model id by hand. Creating the knowledge base tries that model on the provider first and answers `400` when the provider does not serve it at the requested width, so nothing is saved that cannot embed. ### Chunking strategies | Strategy | What it does | Use it when | |----------|--------------|-------------| | `HIERARCHICAL` | Indexes small child chunks for precise matching and returns the surrounding parent passage (`parentChunkSize`) as the result. | Default. Manuals, contracts, reports: anything where a match needs its context. | | `FIXED` | Fixed-window chunks of `chunkSize` tokens with `chunkOverlap`. The chunk itself is the result. | Short, uniform records: tickets, FAQ entries, product rows. | | `AUTO` | Today identical to `HIERARCHICAL`. Reserved for a per-document choice later. | Only if you want the platform's future default without changing configuration. | | `CUSTOM` | Not supported yet. A knowledge base with this strategy fails ingestion. | Do not use. | Chunking settings apply to documents ingested after the change. Existing revisions keep the chunks they were indexed with. ### Text-search language The keyword half of hybrid search matches in a language of your choice — with stemming and a stop-word list, so a search for "invoices" also matches "invoice", and common words like "the" or "die" do not drown out the terms that actually distinguish a passage. `textSearchLanguage` picks that language; the default, `simple`, applies neither: no stemming, no stop words, which is the safe choice for a mixed-language corpus but the weakest match for a single-language one. Stemming joins inflected forms of the same word ("invoices" → "invoice", plural and case endings collapsing onto a shared root). It does **not** join a word with an unrelated derived form — a German knowledge base set to `german` still treats "klassifiziert" and "Klassifizierung" as different words, since they are not the same inflection of one root. Matching across word families like that is the job of the vector half of hybrid search, not the keyword one. Use the English name of the language: `english`, `german`, `french`, `spanish` and the other major languages. Pick the language most of the knowledge base's content is written in. Changing `textSearchLanguage` does not rewrite anything inline. A background process rebuilds the knowledge base's keyword index in the new language, and search keeps answering from the current language and its existing rows until that finishes — never a mix of both. In the console, the knowledge base's **Settings** tab shows "Reindexing to ``…" under the field while this is in progress, and choosing the current language back cancels it. From the API, a `PATCH` that omits `textSearchLanguage`, or sends back the active or the pending language — the value `GET` just returned included — leaves an in-progress reindex untouched. To abandon it and keep the active language, add `"cancelTextSearchReindex": true` to the update body; the rows the reindex already built are discarded. ## Built-in embedding models A knowledge base does not need a provider of your own. Set `embeddingProviderId` to `builtin` and it embeds through the platform's own models: ```json { "name": "Product manuals", "slug": "product-manuals", "embeddingProviderId": "builtin", "embeddingModel": "text-embedding-3-large", "embeddingDim": 1536 } ``` | Model | Stored width | Price per million input tokens | |-------|--------------|--------------------------------| | `text-embedding-3-small` | 1536 | €0.0825 | | `text-embedding-3-large` | 1536 | €0.53625 | Both models store 1536-wide vectors: `text-embedding-3-small` emits that width natively, and `text-embedding-3-large` is asked for 1536 dimensions on every call. `GET /v1/providers/builtin/embedding-models` lists the models the platform offers right now. Prices are net of VAT; see the [rate card](/usage#rate-card-for-developers). Documents and search queries are embedded on the platform's Azure OpenAI deployments in the EU Data Zone, the same processor and region as the built-in chat models. Creating the knowledge base checks the model against the platform's catalogue without calling it, so the check costs nothing. The same models are callable on their own through the gateway's OpenAI-compatible [`POST /v1/embeddings`](/ai-gateway#embeddings), charged at the same rate. Every embedding call, at ingestion and for each search query, counts toward your plan's usage and is charged for the tokens it used. When a call is refused, for example because your plan's usage is used up and no extra usage is available: - **Ingestion**: the revision ends in `ERROR` with the refusal in its `error`, and the revision retrieval already serves stays as it was. Nothing resumes by itself; once usage is available again, retry the document with `POST /v1/knowledge-bases/{knowledgeBaseId}/documents/{documentId}/retry`, which embeds it again from the start. - **Search**: the request answers with the refusal's status, `402` when usage is used up, instead of falling back to keyword results with `lexical_only`. **Note — Switching an existing knowledge base:** A `PATCH` can move `embeddingProviderId` from one of your providers to `builtin` or back. The model check runs on the new provider, but nothing is embedded again: the stored vectors stay comparable only if the new provider serves the same model at the same width, and a model name does not prove that (on Azure OpenAI it is a deployment name you chose). The safe way to move is a new knowledge base on the new provider, with the documents uploaded again. The console offers no switch. ## Uploading Documents ### `POST /v1/knowledge-bases/{knowledgeBaseId}/documents` Multipart upload. The form field is `files` and takes one or more files. **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/knowledge-bases/kb_123/documents \ -H "Authorization: Bearer sk_your_api_key" \ -F "files=@service-manual-2026.pdf" \ -F "files=@installation-guide.docx" ``` **Python** ```python with open("service-manual-2026.pdf", "rb") as manual, open("installation-guide.docx", "rb") as guide: response = requests.post( f"{BASE_URL}/v1/knowledge-bases/{kb_id}/documents", headers={"Authorization": f"Bearer {api_key}"}, files=[ ("files", ("service-manual-2026.pdf", manual, "application/pdf")), ("files", ("installation-guide.docx", guide, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")), ], ) for result in response.json(): if result.get("error"): print(result["filename"], "rejected:", result["error"]) else: version = result["upload"]["acceptedVersion"] print(result["filename"], "accepted as revision", version["id"]) ``` **TypeScript** ```typescript const form = new FormData(); form.append("files", manualBlob, "service-manual-2026.pdf"); form.append("files", guideBlob, "installation-guide.docx"); const response = await fetch(`${BASE_URL}/v1/knowledge-bases/${kbId}/documents`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, body: form, }); for (const result of await response.json()) { if (result.error) console.log(result.filename, "rejected:", result.error); else console.log(result.filename, "accepted as revision", result.upload.acceptedVersion.id); } ``` The response is always `202 Accepted`, with one entry per file: ```json [ { "filename": "service-manual-2026.pdf", "upload": { "document": { "id": "doc_…", "name": "service-manual-2026.pdf", "knowledgeBaseId": "kb_123" }, "acceptedVersion": { "id": "ver_…", "versionNo": 1, "status": "PENDING", "mime": "application/pdf", "byteSize": 1834022 } } }, { "filename": "notes.xyz", "error": "Unsupported file type" } ] ``` Files are accepted independently. One rejected file does not discard the others. | Rule | Value | |------|-------| | Formats | The same 17 input formats as [Document Conversion](/document-conversion): PDF, DOCX, XLSX, PPTX, CSV, MSG, EML, HTML, Markdown, TXT, AsciiDoc, ZIP, images, DXF, DWG, STEP, GEO | | Size | 25 MB per file, 104 MB per request. A file over the per-file cap is rejected with `413 file_too_large`; split larger batches into several requests | | Same filename | Creates a new revision of the existing document. The previous revision keeps serving retrieval until the new one is `READY`, then the switch is atomic | | Storage | Document bytes are kept for as long as the document exists, so a revision can be re-served and its citations resolved | ### Ingestion status Ingestion is asynchronous. Poll the accepted revision until it reaches a terminal state: ### `GET /v1/knowledge-bases/{knowledgeBaseId}/documents/{documentId}/versions/{versionId}` | Status | Meaning | |--------|---------| | `PENDING` | Accepted, waiting for a worker | | `PARSING` | Converting the file to text and structure | | `CHUNKING` | Splitting into chunks | | `EMBEDDING` | Calling the embedding model | | `READY` | Searchable. Terminal | | `ERROR` | Failed. Terminal; `error` on the revision says why | `GET /v1/knowledge-bases/{knowledgeBaseId}/documents` lists documents with the revision retrieval is serving, newest first. Filter with `?status=READY` (or any other status); the filter applies to the current revision. **Note — Attaching a file you already uploaded:** A file uploaded through [`POST /v1/files`](/files#knowledge-documents-from-a-file) with `purpose=knowledge` can be ingested without a second upload: `POST /v1/knowledge-bases/{knowledgeBaseId}/documents/attach` with `{"fileId": "file_…"}`. It answers `202` with the accepted revision, `400` if the file's purpose is not `knowledge`, `404` for an unknown file and `410` for a deleted one. ## Searching ### `POST /v1/knowledge-bases/{knowledgeBaseId}/search` **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/knowledge-bases/kb_123/search \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "query": "torque for the M8 mounting bolts", "topK": 5 }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/knowledge-bases/{kb_id}/search", headers=headers, json={"query": "torque for the M8 mounting bolts", "topK": 5}, ) for hit in response.json()["hits"]: print(hit["documentName"], hit.get("pageStart"), hit["content"][:120]) ``` **TypeScript** ```typescript const response = await fetch(`${BASE_URL}/v1/knowledge-bases/${kbId}/search`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ query: "torque for the M8 mounting bolts", topK: 5 }), }); const { hits, degraded } = await response.json(); ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `query` | string | Yes | Natural-language question or keywords | | `topK` | integer | No | Number of hits. Default `8`, clamped to 1 to 100 | | `metadataFilter` | object | No | Key/value filter on document metadata | ### Response ```json { "query": "torque for the M8 mounting bolts", "hits": [ { "hitId": "h_0", "knowledgeBaseId": "kb_123", "documentId": "doc_…", "documentVersionId": "ver_…", "documentName": "service-manual-2026.pdf", "chunkId": "chk_…", "content": "Mounting. Tighten the four M8 bolts to 25 Nm in a cross pattern …", "matchedSpan": "M8 bolts to 25 Nm", "pageStart": 14, "pageEnd": 14, "headingPath": ["Installation", "Mounting"], "score": 0.0325, "matchedBy": ["vector", "keyword"] } ] } ``` | Field | Description | |-------|-------------| | `content` | The passage. Under hierarchical chunking this is the parent passage around the matching child | | `matchedSpan` | The part of `content` that matched most closely, when available | | `pageStart`, `pageEnd` | Page range in the source, absent for sources without pages | | `headingPath` | The heading hierarchy above the passage | | `score` | Relevance score. Useful for ordering, not comparable across queries | | `matchedBy` | Which searches found the passage: `vector` (by meaning), `keyword` (by its words), or both. A passage both find ranks higher | | `chunkId`, `documentVersionId` | Stable ids you can store and later [resolve as a citation](#citations) | Search is not paginated: every request ranks the whole knowledge base afresh, so there is no stable ordering to page through. Raise `topK` instead. An empty `hits` array is a normal result, not an error. ### Degraded results When the embedding provider does not answer, the search falls back to keyword retrieval and says so: | `degraded` | Meaning | |------------|---------| | absent | Both retrievers ran | | `lexical_only` | Keyword retrieval only. The embedding call for the query failed | | `partial_lexical_only` | Some of the searched knowledge bases fell back to keyword retrieval | Results are still returned with HTTP `200`. Check the field if recall matters to you. A refused call to a [built-in embedding model](#built-in-embedding-models) is not a degraded result: the search answers with the refusal's status (`402` when usage is used up) instead of `lexical_only`. ## Citations An agent that answers from a knowledge base cites the passages it used. Each citation carries the `chunkId` of the passage, and you can turn it back into text at any time: ### `GET /v1/knowledge-bases/{knowledgeBaseId}/citations/{chunkId}` ```json { "chunkId": "chk_…", "documentVersionId": "ver_…", "text": "Mounting. Tighten the four M8 bolts to 25 Nm in a cross pattern …", "textStatus": "AVAILABLE", "pageStart": 14, "pageEnd": 14, "headings": ["Installation", "Mounting"], "isCurrentVersion": true } ``` Citations are durable on purpose: - **After a re-ingest**, a citation issued against the old revision still returns the full text the model saw, with `isCurrentVersion: false`. - **After the document is deleted**, the citation still resolves with its ids, page range and headings, but the passage is withheld: `text` is `null` and `textStatus` is `WITHDRAWN`. - A chunk id that does not exist, or belongs to another knowledge base or organization, is a `404`. How citations are produced, and the `backbone:citation` items an agent emits, are described on the [Agents page](/agents/tools#file-search-and-citations). ## Using a Knowledge Base with an Agent Attach one or more knowledge bases to an agent by adding the built-in `file_search` tool to the agent's `tools`: ```json { "type": "file_search", "config": { "knowledgeBaseIds": ["kb_123", "kb_456"] } } ``` The model can then search those knowledge bases during a turn. It cannot name a knowledge base itself: which ones it may search is your configuration on the agent version, not a request parameter. Searching across several knowledge bases fuses their rankings into one list. In the console, the agent form's **Tools** section offers the same setting with a knowledge base picker. Details of the tool, its result shape and citation handles are on the [Agents page](/agents/tools#file-search-and-citations). ## Managing Knowledge Bases | Operation | Endpoint | Notes | |-----------|----------|-------| | List | `GET /v1/knowledge-bases` | Paginated, newest first | | Get | `GET /v1/knowledge-bases/{id}` | | | Update | `PATCH /v1/knowledge-bases/{id}` | Takes the same body as create, plus `cancelTextSearchReindex` (see [Text-search language](#creating-a-knowledge-base)). Mutable fields are replaced and the result is snapshotted as the next configuration version. `embeddingDim` cannot change | | Delete | `DELETE /v1/knowledge-bases/{id}` | Soft delete; releases the slug, keeps version history. Admin only | | Get document | `GET /v1/knowledge-bases/{id}/documents/{documentId}` | The document with the revision retrieval serves | | Delete document | `DELETE /v1/knowledge-bases/{id}/documents/{documentId}` | Takes every revision out of retrieval and withdraws the passage text from previously issued citations. Admin only | ### The console Open **Knowledge** in the sidebar. - **List** shows name, status, embedding model, chunking strategy and creation date. - **Create** derives the slug from the name, offers **Built-in** as the first embedding provider whenever the platform offers [built-in embedding models](#built-in-embedding-models) (preselected when your organization has no embedding-capable provider of its own), offers the embedding model catalog for the chosen provider, and hides chunking under an advanced section. - **Documents** tab: a dropzone that enforces the size limits and splits large batches, a table with status badges, and polling while any revision is still ingesting. A revision that has not moved for fifteen minutes is marked as possibly stalled. - **Playground** tab: run a search against this knowledge base and see ranked passages with page, headings and the matched span. Use it to tell "the agent is misconfigured" apart from "the knowledge base does not contain the answer". - **Settings** tab: edit name, slug, description, hybrid search, chunking parameters and the [text-search language](#creating-a-knowledge-base); provider (**Built-in** for a knowledge base on the built-in models), model and dimension are read-only. ## Permissions | Role | Can | |------|-----| | Viewer | List, read, search, resolve citations | | Member | Create and update knowledge bases, upload documents | | Admin | Delete knowledge bases and documents | Search results are restricted to passages the calling principal may see. ## Errors | Status | When | |--------|------| | `400` | Embedding dimension does not match the model, a model the provider does not serve (the built-in provider included), `builtin` as `rerankerProviderId`, invalid chunking parameters, attach with a file whose purpose is not `knowledge` | | `402` | A search on a built-in knowledge base whose embedding call was refused because usage is used up | | `404` | Unknown knowledge base, document, revision, chunk or file | | `409` | Slug already in use | | `410` | Attach with a file that has been deleted | | `413` | A file or request over the size limit | Uploads answer `202` even when every file was rejected; read each entry's `error`. --- Source URL: https://docs.2kw.ai/microsoft-365 # Microsoft 365 Let your agents read and write Outlook mail and calendar and read contacts, each member signed in with their own Microsoft work or school account. ## Overview Microsoft 365 is a ready-made [connector](/agents/connectors#connectors-mcp-servers). An organization admin registers an app in Microsoft Entra once and enters it in the 2kw.ai console; agent authors then add one connector URL to their agents, and each member connects their own account in the [chat app](/chat#connectors). | Area | What an agent can do | | --- | --- | | Profile | Read the signed-in member's profile | | Mail | List folders, list and read messages, read a message as an `.eml` file, list a message's attachments and read each as a file, create drafts, send, reply, reply all, forward, move a message to another folder, mark it read or unread, flag it, set its categories | | Shared mailboxes | List a shared mailbox's folders and subfolders, list and read its messages, list a folder's messages, read a message as an `.eml` file, list its attachments and read each as a file, create drafts, send, reply, reply all, forward, move a message to another folder, mark it read or unread, flag it, set its categories | | Calendar | List calendars and events, find meeting times, create and update events, accept, decline or tentatively accept invitations | | Contacts | List and read contacts | You choose which of these areas agents get, and whether mail and calendar are read-only; an agent sees only the tools your choice covers. Everything is on by default. See [Choose the permissions](#choose-the-permissions). An agent cannot delete anything, or reach OneDrive, SharePoint, Teams or To Do. It can move a message to another folder, Deleted Items included, but never into the Recoverable Items folders: a move goes to a well-known folder such as `inbox` or `archive`, or to a folder id that 2kw.ai finds in the mailbox's folders, and any other destination is refused. In Auto, the automatic approver decides moving a message, marking it read or unread, flagging it and setting its categories. Every other tool that writes, such as sending, replying, forwarding, creating drafts and changing events, waits for a person's approval in Auto too; see [Approvals](/agents/approvals#approvals). ### Mail and attachments as files An agent reads a message as an `.eml` file and each attachment as a file, and the files are stored like any [file a connector's tool returns](/agents/connectors#connectors-mcp-servers): the agent hands them on by id. An attachment list shows each attachment's name, type and size, not its content. A file can be up to 25 MB; a mail larger than that is read attachment by attachment. ## Before you start - A Microsoft Entra role that may register applications in your tenant, such as **Application Developer** or **Application Administrator**. - The **Admin** or **Owner** role in your 2kw.ai organization. - Members sign in with work or school accounts of that tenant. Personal Microsoft accounts are not supported. ## Choose the permissions The **Add Microsoft 365** dialog (see [Enable it in the console](#enable-it-in-the-console)) has five settings. Each starts at its recommended value, everything on. Turn off what your organization does not want agents to do; the dialog lists the Microsoft Graph permissions your settings need, and those are the ones to add in Microsoft Entra. | Setting | Options | Permissions it needs | | --- | --- | --- | | Mail | **Read and draft**, Read only, Off | `Mail.ReadWrite`, or `Mail.Read` for read only | | Send mail | **On**, Off | `Mail.Send` | | Shared mailboxes | **On**, Off | The shared-mailbox version of each mail permission above: `Mail.ReadWrite.Shared` or `Mail.Read.Shared`, and `Mail.Send.Shared` | | Calendar | **Read and change**, Read only, Off | `Calendars.ReadWrite` and `Calendars.Read.Shared`, or `Calendars.Read` for read only | | Contacts | **On**, Off | `Contacts.Read` | `User.Read` is always needed: it signs the member in. `Calendars.Read.Shared` lets an agent find meeting times: it reads the free/busy times of the people invited, and only what the member can already see in Outlook. Finding meeting times needs **Read and change**. Shared mailboxes follows your mail and send settings, so turning off **Send mail** also stops sending from shared mailboxes, and with **Mail** and **Send mail** both off the setting does nothing. Agents get only the tools your settings cover. ## Register the app in Microsoft Entra #### Step 1 — Create the app registration In the [Microsoft Entra admin center](https://entra.microsoft.com), open **Identity → Applications → App registrations** and choose **New registration**. - **Name**: anything your members recognize, for example `2kw.ai agents`. They see it when they sign in. - **Supported account types**: *Accounts in this organizational directory only*. - **Redirect URI**: platform **Web**, value `https://chat.2kw.ai/connectors/callback`. Choose **Register**. #### Step 2 — Add the Microsoft Graph permissions Open **API permissions → Add a permission → Microsoft Graph → Delegated permissions** and add exactly the permissions the dialog lists for your settings, no more: a permission your settings do not ask for makes sign-in fail. With everything on, these are the eight: | Permission | Used for | | --- | --- | | `User.Read` | Who the member is | | `Mail.ReadWrite` | Reading mail, creating drafts, moving, marking and flagging messages | | `Mail.Send` | Sending, replying and forwarding | | `Calendars.ReadWrite` | Reading and changing events | | `Calendars.Read.Shared` | Finding meeting times from the attendees' free/busy | | `Contacts.Read` | Reading contacts | | `Mail.ReadWrite.Shared` | Reading mail, creating drafts, moving, marking and flagging messages in shared mailboxes | | `Mail.Send.Shared` | Sending, replying and forwarding from shared mailboxes | Then choose **Grant admin consent** for the tenant. Many tenants do not let members agree to these permissions themselves; without admin consent they see *Need admin approval* when they connect. With it, members are not asked to agree to the permissions. At sign-in, members also see *Maintain access to data you have given it access to*: it lets 2kw.ai renew their sign-in without asking again. #### Step 3 — Create a client secret Open **Certificates & secrets → Client secrets → New client secret**, pick an expiry and choose **Add**. Copy the secret's **Value** right away; Entra shows it only once. The **Secret ID** is not needed. Put a reminder in your calendar a few weeks before the expiry. See [When the secret expires](#when-the-secret-expires). #### Step 4 — Copy the two IDs On the app's **Overview**, copy the **Application (client) ID** and the **Directory (tenant) ID**. ## Enable it in the console Open **Connectors** in the console's sidebar and choose **Add connector → Microsoft 365**. Set the five [permission settings](#choose-the-permissions), leaving them on unless you want less. Enter the directory (tenant) ID, the application (client) ID and the client secret value, and keep the token endpoint auth method at `client_secret_post` unless your app requires `client_secret_basic`. Then choose **Enable**. The dialog approves the hosts `m365.mcp.2kw.ai` and `login.microsoftonline.com` for your organization and registers your app for your tenant, showing each step as it goes. Approving those hosts lets agents reach any connector on the first and the sign-in pages of any Microsoft Entra tenant on the second; only your tenant gets a registration. - **Running it again** picks up where it stopped: a step that is already done is skipped. - **A registration for your tenant already exists**, for example from an earlier run: the dialog fills in its permission settings, shows its client ID, auth method and permissions next to the ones you entered, and you choose **Use existing registration** or **Replace credentials**. **Use existing registration** is offered only while your settings match the registration's permissions; **Replace credentials** needs the client secret. Replacing asks every member connected through the old one to sign in again. - **Your organization has reached its limit of approved hosts**: the dialog stops and says so. Remove a host you no longer need under **Connectors → Manage allowed hosts and OAuth clients** and run it again. Once the steps are done, the dialog shows your connector URL and tests it: ```text https://m365.mcp.2kw.ai/t/{tenant-id}/mcp ``` The tenant ID in the URL is lowercase. Choose **Connect** in the test result to sign in as yourself and see the tools. ## Add it to an agent Give the connector URL to your agent authors. In the console's agent editor they add a connector on the approved host `m365.mcp.2kw.ai` with the path `/t/{tenant-id}/mcp`, or declare it on the agent version: ```json { "type": "mcp", "server_label": "m365", "server_url": "https://m365.mcp.2kw.ai/t/{tenant-id}/mcp", "server_description": "The member's Outlook mail, shared mailboxes, calendar and contacts" } ``` Once Microsoft 365 is enabled, the agent editor's connector form also offers a **Microsoft 365** button for each tenant that fills in the host and path. Levels per tool, approvals and the rest work as for any connector; see [Connectors](/agents/connectors#connectors-mcp-servers). ## Changing the permissions Change the settings by running **Add Microsoft 365** again and choosing **Replace credentials**: a change needs a client secret. Every member connected through the registration signs in again, and until they do they have no Microsoft 365 connection. A connection made under the old permissions stops working at its next use. - **Giving agents more.** Add the new permissions to the app in Microsoft Entra (**API permissions**, then **Grant admin consent**), create a new client secret, then run the dialog with the new settings and **Replace credentials**. - **Giving agents less.** Microsoft keeps every permission an app was granted, so narrowing the settings alone does not remove one from the app. The dialog lists what the old app would still hold. The cleanest way is a new app registration with exactly the permissions your new settings list (steps 1 to 4 above): enter its IDs and secret with **Replace credentials**. Or keep the app, remove the permissions in **API permissions**, and revoke the consent members and the tenant already gave: under **Enterprise applications → your app → Permissions**, and for each member's own consent. While the app is still granted a permission your settings do not list, members cannot connect. An edit under **Connectors → OAuth clients** (for example to rotate the secret) keeps the permissions as they are. **Set up before finding meeting times was added?** Your registration keeps working with its seven permissions, but agents do not find meeting times. To add it, follow **Giving agents more** with `Calendars.Read.Shared`. ## Shared mailboxes An agent can work in a shared mailbox the member is allowed to open. It names the mailbox by its sign-in address (UPN), for example `support@contoso.com`. A mailbox whose address contains `#` is not supported. Microsoft cannot list the mailboxes a member may open, so the member or the agent's instructions give the address. What the member can do depends on rights an Exchange admin gives them; 2kw.ai cannot grant them. | A member wants to | Exchange permission | | --- | --- | | List and read messages, create drafts in a mailbox | **Full Access** (*Read and manage*) on it | | Read a folder a colleague shared with them | That folder's permission, without Full Access | | Send, reply, reply all, forward | **Send As** (the mail shows the mailbox as sender) or **Send on Behalf** (it shows *member* on behalf of *mailbox*) on it | | Send a draft created in the mailbox | **Full Access**, and either of the sending rights above | Without the right, Microsoft answers with an access error and the agent sees it as the tool's error. Where Microsoft files the sent copy, the mailbox's *Sent Items* or the member's own, depends on the mailbox's Exchange settings. - **Folders.** An agent lists a shared mailbox's folders and their subfolders to find a folder by name, such as a custom archive folder, and moves a message into it by the folder's id. Listing the folders, and moving by a folder's id, needs **Full Access**. Without it, an agent reaches folders by the well-known names `inbox`, `drafts`, `sentitems`, `deleteditems`, `junkemail` and `archive`, or by the folder id a listed message carries, and moves by well-known name. - **Delegated folders and mailboxes.** Agents also reach folders and mailboxes a colleague delegated to the member. - **Members connected earlier** sign in once more, so they agree to the two added permissions. ## What members see - **Connecting.** A member connects on the chat app's [Connectors page](/chat#connectors) or from the card a paused chat shows. Microsoft's sign-in page opens in a new tab, lists the permissions your admin chose unless an admin already granted them, and returns to the chat. - **Acting.** An agent reads as the member. Before it sends, creates or changes anything, the member approves the call. In Auto the automatic approver decides moving, marking, flagging and categorizing messages; everything else still waits for the member. - **Disconnecting.** Disconnecting deletes the member's sign-in in 2kw.ai. Microsoft offers no way to revoke it from outside, so at Microsoft it stays valid until it expires, or until the member removes the app under [My Apps](https://myapps.microsoft.com) or an Entra admin revokes their sessions. ## When the secret expires Once the client secret has expired, every member's connection needs a reconnect, and reconnecting fails until an admin enters a new secret. Create a new secret in Entra (step 3 above), then either run **Add connector → Microsoft 365** again and choose **Replace credentials**, or edit the registration under **Connectors → Manage allowed hosts and OAuth clients**. Every member connected through it signs in again. --- Source URL: https://docs.2kw.ai/chat # Chat App The [chat app](https://chat.2kw.ai) lets organization members talk to their agents without the console. Use the same 2kw account to ask questions, attach files, and review tool approvals in a dedicated chat interface. ## Sign in #### Step 1 — Open the chat app Go to [chat.2kw.ai](https://chat.2kw.ai) and sign in with the same 2kw account you use for the [console](https://app.2kw.ai). #### Step 2 — Choose an organization Select the organization whose agents you want to use. The organization picker keeps your chats in the right workspace when you belong to more than one organization. The app supports light and dark themes and a mobile layout, so you can use it from a desktop browser or your phone. ## Start a chat Pick an agent and send your first message. Only agents with a published version appear in the picker. An agent that has been created but has no published version is not ready to chat with yet. If you manage agents in the console, use **Open in chat** at the top of the agent's page (on every tab) or in the agents list's actions menu. It opens a new browser tab with that agent already selected. For an unpublished agent, publish a version first. The link has this form, with your organization slug and agent ID: ```text https://chat.2kw.ai/{org}/new?agent= ``` See [Agents](/agents) for creating an agent and publishing its instructions, model, and tools as a version. ## Conversations Your sidebar is a personal list of conversations you started in the selected organization. Select a conversation to resume it and continue from its history. You can also delete conversations you no longer need. Answers support Markdown formatting and citations. Follow a citation when you want to inspect the source behind an answer. These conversations are the same `/v1/conversations` objects exposed by the API. The chat app provides an interface to that history; it does not keep a separate copy of the conversation outside the platform. A conversation started through the API without a title takes one from its first message. Set `metadata.title` when you create it, or rename it afterwards, to choose your own. **Note — A personal sidebar:** The sidebar shows your own conversations. Organization roles still determine access through other platform interfaces; the User role is restricted to its own conversations. ## Attachments Add files using the attachment control in the message composer. The attached files appear alongside your message, giving the agent material to work with as part of the conversation. For example, attach a document and ask the agent to summarize it or answer questions about its contents. Review the selected files before sending your message so the agent receives the context you intended. ## Approvals When a tool call needs a human decision, the chat displays an approval card. Review the requested action before choosing a decision: - **Allow once** permits this tool call. - **Allow for this chat** remembers the decision for this conversation. - **Deny** rejects the tool call. You can include an optional note with your decision to give the agent context. For example, explain why you denied an action or what should change before the agent tries again. **Note — Destructive tools need a fresh decision:** **Allow for this chat** is never offered for destructive tools. Remembered decisions apply to the current conversation, not to every chat with the agent. Decided requests stay visible in the conversation so you can review what was requested and how you responded. A request for a tool of a [connector](#connectors) names the server it belongs to, for example "on erp". It is decided the same way, and an approved call runs with the connection of the person whose message led to it. ## Conversation modes The mode chip in the composer, next to the attachment control, sets how much the agent may do in this conversation before it needs you. Open it to pick a mode: | Mode | What the agent may do | |------|-----------------------| | **Plan** | Read only. Anything that could change something is refused. | | **Ask** | No automatic approver. Calls that need approval wait for you. | | **Auto** | An automatic approver decides where this agent allows it; the rest wait for you. | A mode only narrows what the agent's author allowed. No mode lets the agent do more: a tool the author blocked stays blocked in every mode. The mode belongs to the conversation. It is stored with the chat, so resuming a conversation brings its mode back. A new pick applies from your next message or approval; a call that is already running keeps the mode it started with. Which modes the chip offers, and which one a new chat starts in, is set by the agent's author under **Conversation modes** in the agent's settings in the [console](https://app.2kw.ai). Without that setting the chip offers Plan and Ask, and a new chat starts in Ask. When the agent offers a single mode and the chat is already in it, the chip is hidden. ### Plan When the agent tries a call that Plan does not allow, the call is refused and the agent is told so. Below the answer, each refused call is marked *Blocked in plan mode* with a **Switch to Ask** button, or **Switch to Auto** when the agent does not offer Ask. Switching sends nothing by itself: send your message again to let the agent retry. ### Modes and approvals - **Ask** shows an [approval card](#approvals) for every call that needs approval, including calls an automatic approver would decide in Auto. A call you allowed with **Allow for this chat** keeps running without asking again. - **Auto** lets the agent's automatic approver decide the calls its author lets it decide. Every other call that needs approval shows a card, as in Ask. - **Plan** lets you allow only a read-only call. For any other call, and for every [connector](#connectors) call, both Allow buttons are disabled and the card says why. The card's **Switch to Ask** (or **Switch to Auto**) button leaves plan mode so you can allow the call. **Deny** works in every mode. For how modes work in the API, the CLI and the embedded chat panel, see [Conversation Mode](/agents/approvals#conversation-mode) in the agent documentation. ## Connectors Some agents use a server that acts as you, such as your company's ERP, and need your own sign-in there. You connect such a server once, then decide which agents may use the connection and what they may do with it. Open **Connectors** from the user menu, or go to `https://chat.2kw.ai/connectors`, which opens the page of your active organization. **Your connections** lists the servers you are connected to: the host, how it is reached (`direct`), *Active* or *Needs reconnect*, and since when you are connected. **Reconnect** signs you in again when the connection needs it. **Disconnect** asks for a confirmation, then removes the connection and every agent's access to it; you sign in again to use it. For [Microsoft 365](/microsoft-365#what-members-see), your sign-in stays valid at Microsoft until it expires or you remove the app under My Apps. Under each connection are the agents that use the server: - **Allow** lets that agent act as you on the server. Connecting alone does not. - **Allow again** appears when a new version of the agent also reaches other destinations. The row names them, and the agent cannot use the connection until you allow it again. - **Permissions** lists the server's tools for that agent. You can set each one to *Needs approval* or *Blocked* to make it stricter than the agent's author set it, never looser; a level the author ruled out cannot be chosen. The change applies from the agent's next answer. - **Revoke** takes the agent's access away. Your connection stays, and **Allow** gives the access back. **Available** lists the servers your organization's agents use that you have not connected. **Connect** opens the server's sign-in page in a new tab, and the page updates when you are done. Connecting here stores the connection only: allow each agent afterwards. A server that needs no sign-in says so; agents use it without one. ### Connecting from a chat When an agent needs a server you have not connected, have not allowed it to use, or whose connection has expired, the chat pauses and shows a card: "*Agent* wants to act as you in **host**". - **Connect** or **Reconnect** opens the server's sign-in page in a new tab. If the browser blocks the tab, the card shows a link instead. Connecting from the card also allows this agent. - **Allow** appears when you are connected already. If the agent now also reaches other destinations, the card lists them. - **Continue** appears while a sign-in is under way. Use it if the chat did not notice that you finished. - **Not now** goes on without the server. The agent is told you did not connect; after two refusals in one conversation it stops asking there. The chat continues once every server on the card is answered. You can also type a message instead: the agent then goes on without the connection. While a chat waits, the sidebar marks it *Needs you: connect …*. Every sign-in, including one started from the console's agent editor, returns to `https://chat.2kw.ai/connectors/callback`. That page completes the connection, tells the chat or the Connectors page, and closes itself, or says "Connected. Return to the app." when the browser does not let it close. A sign-in started from the console sends you back to the console. The page needs you signed in to the organization you started from. ## Roles Organization **Owner**, **Admin**, and **Member** roles can use the chat app as well as their existing console access. The **User** role is for chat-only members. Users can talk to published agents and see only their own conversations; they do not get the console's agent management interface. To invite a chat-only member, open your organization in the [console](https://app.2kw.ai), go to the **Members** page, and invite them with the **User** role. They sign in to the chat app with their own 2kw account. ## Limits - Answers do not stream yet. The response appears after the agent finishes producing it. - Each conversation uses one agent. To talk to a different agent, start a new chat and pick that agent. --- Source URL: https://docs.2kw.ai/chat/scheduled-tasks # Scheduled tasks A scheduled task runs one of your agents for you at set times, such as every weekday at 07:00. Each run lands in a new chat in your sidebar, so the result is waiting when you open the [chat app](https://chat.2kw.ai). ## Create a task You can create a task in two places. Both open the same form. - Open **Scheduled** in the chat sidebar and choose **New task**. - In a chat, open the **+** menu in the message box and choose **Schedule…**. The form starts with that chat's agent and the text you typed as the instructions. Fill in the form and choose **Schedule**: - **Name**: shown in the list and in the title of each run. - **Agent**: the agent that runs. You cannot change it afterwards. - **Instructions**: the message the agent receives at every run. - **Repeat**, **time** and **time zone**: when the task runs. The time zone starts as your browser's. Nothing is scheduled until you choose **Schedule**. An agent never creates a task on its own. ## Repeat options | Option | Runs | |--------|------| | **Once** | On one date, at one time. | | **Every hour** | Every hour, at the minute you choose. | | **Every day** | Every day at the time you choose. | | **Weekdays** | Monday to Friday at the time you choose. | | **Every week** | On the day of the week and at the time you choose. | | **Every month** | On the day of the month and at the time you choose. A month without that day runs on its last day. | | **Only when I run it** | Never by itself. You start it with **Run now**. | A task that runs once is done after its run. Edit its date to schedule it again. ## What a run does A run acts as you. It uses your role and your own [connector](/chat#connectors) sign-ins, the same as the agent does when you chat with it yourself. If your role is reduced or you leave the organization, your tasks stop. Each run starts a new chat at the top of your sidebar, titled with the task name and the date, for example "Overdue invoices · Tue 28 Oct". The first message is your instructions, with a small **Scheduled** label that opens the task. The sidebar shows the state of the run: - **Working** while the agent runs. - **Needs you** when the run waits for you. - **Done** when the run has finished and you have not opened it yet. - **Failed** when the run did not finish. Open the chat to read the result. When the run has ended you can keep chatting in it like in any other conversation. ## When a run needs you A run follows the approval mode that the agent's author set as its default, the same rules as in your own chats (see [Conversation modes](/chat#conversation-modes)). Your scheduled instructions never count as approval in advance. A call that needs approval waits for you, and it does not run on its own. When that happens, or when a connector needs you to sign in again, the chat shows **Needs you**. Open it and answer the approval card or reconnect, as you would in a live chat. The run then continues and the chat shows **Working**. While a run waits, you cannot send a new message in that chat. You can stop the run instead. If nobody answers, the run eventually ends as failed. To avoid a pile-up, a task does not start a new run while its previous run is still open. That time is skipped. A task whose runs stop finishing pauses itself, and its row on the Scheduled page reads "Paused: the last runs did not finish". Choose **Resume** after you have fixed the cause. A connector's question to you during a scheduled run is not supported. The agent reports that it could not ask. **Note — Connect before the first run:** If the agent uses a connector you have not signed in to, the form says so, for example "Connect Dolibarr before the first run, or it will wait for you". Connect it first so the first run can finish by itself. ## Manage your tasks The **Scheduled** page lists your tasks with their repeat setting, the next run, and the result of the last run. The menu on each row has: - **Run now**: start one run immediately. The task's schedule stays as it is. You cannot use it while the last run is still open. - **Pause** and **Resume**: stop and restart the schedule. - **Edit**: change the name, instructions, repeat, time or time zone. - **Delete**: remove the task. The chats of its past runs stay in your sidebar. Tasks are private. Other members, including administrators, cannot see or change yours. ## Limits - Up to 10 active tasks per member. Paused, stopped, finished and **Only when I run it** tasks do not count. Pause or delete one to make room. - Up to 50 tasks per member in total, in any state. - A task runs at most once per hour. - A task has one open run at a time. - A run may start some minutes after the set time. - A run that could not start for a long time after its set time is skipped, not run late. - Runs use your organization's usage and credits, the same as the same message in a chat. ## For organization admins Admins can turn scheduled tasks off for the whole organization: open **Settings** in the [console](https://app.2kw.ai) and switch off **Allow scheduled tasks**. While it is off, no task starts and members cannot create or resume one. Tasks are kept and run again when you switch it back on. ## Example: a daily overdue-invoice digest Anna works with MK's Dolibarr agent and has connected her Dolibarr account. 1. She types "List the overdue Dolibarr invoices by customer, with the amount and days overdue" in a chat with that agent, opens **+** and chooses **Schedule…**. 2. She names the task "Overdue invoices", picks **Weekdays** at 07:00, and chooses **Schedule**. The row reads "Weekdays at 07:00 · Next: tomorrow 07:00". 3. Next morning the agent runs. Listing invoices is a read, so it needs no approval. The chat "Overdue invoices · Tue 28 Oct" is at the top of her sidebar with **Done**. 4. She reads the digest and asks "Draft a reminder to Müller GmbH" in the same chat. 5. One day her Dolibarr sign-in has expired. The run shows **Needs you** instead. She reconnects in the chat and the run finishes. --- Source URL: https://docs.2kw.ai/embeddable-surface # Embeddable Surface Put an agent inside your own web application as a chat panel: one script tag, your users under their own login, tools that run inside your application under their own permissions. **Note — Beta:** The embed protocol is frozen at version 1: attributes are only ever added, never removed or renamed. The panel, the console pages and this guide are in beta and change with each release. ## How It Works Three pieces on three origins: ``` your page (https://erp.example.com) ├── ``` Where the snippet goes is your business: a template, a footer hook, a CMS "extra scripts" field. ### Origins and keys Enrolment registers one origin and one key. Manage both later with the installation endpoints. **Origins** decide which pages may frame the panel. `PUT /v1/installations/{id}/origins` replaces the whole list; to add one, send the complete list. Entries are bare origins, `scheme://host[:port]`, no path, no wildcard; `https` only, or `chrome-extension://{id}` for a browser extension. Up to 20. Comparison is by origin, not spelling: casing, a default port and an internationalized host name all match their canonical form. An unregistered origin is not told it is unregistered: the browser blocks the frame and, after about 10 seconds, the loader writes a note into your page. **Keys**: at most **two** active keys per installation, so a rotation can overlap. `POST /v1/installations/{id}/keys` registers a public key under a `kid`; `GET …/keys` lists them, revoked ones included; `DELETE …/keys/{kid}` revokes. Rotate in this order: register the successor, switch signing, confirm it verifies, then revoke the predecessor. Between "your application signs with a new key" and "that key is registered", every assertion is refused and nothing looks wrong on your side. There is no delete for installations. Set `status` to `DISABLED` with `PUT /v1/installations/{id}`: a disabled installation fails every assertion and is refused a frame. ## The Snippet The surface origin is taken from the script's own `src`. Attributes are additive: unknown ones are ignored, a missing optional one takes its default. | Attribute | Required | Default | Meaning | |-----------|----------|---------|---------| | `data-installation` | yes | | Installation id | | `data-agent` | yes | | Agent id. The panel sends `model: "agent/{id}"` | | `data-mint-url` | yes | | Same-origin path of your mint endpoint. The loader refuses to boot without it; the console's placeholder is not a working value | | `data-mint-csrf` | no | | CSRF token sent with mint and tool-proxy requests. When your page carries its token in an `input[name="token"]`, the loader reads that one itself | | `data-execute-url` | no | `execute.php` beside the mint URL | Same-origin tool proxy for relay tools. The default is the file name of the reference PHP host, kept so its module keeps working. Any other stack sets this attribute to its own proxy path | | `data-manifest-hash` | no | | Hash of the tool catalog your application currently serves. Lets the panel warn admins when 2kw.ai holds an older catalog | | `data-mode` | no | `inline` | `inline` fills the box the script sits in; `drawer` renders a fixed right-hand drawer with a launcher button | | `data-drawer-width` | no | `640` | Drawer width in px | | `data-launcher` | no | `default` | `none` suppresses the built-in launcher | | `data-launcher-selector` | no | | With `data-launcher="none"`: a CSS selector for your own button, looked up once at boot | | `data-color-scheme` | no | `auto` | `light`, `dark` or `auto` | | `data-accent` | no | | Brand colour. Contrast-checked; an unreadable accent is used as a surface colour only, or dropped | | `data-radius` | no | | CSS length for corner radius | | `data-font-family` | no | | Font stack | `data-manifest-url`, `data-min-height` and `data-max-height` are accepted and ignored: older snippets that still carry them keep working unchanged. `data-mint-url` and `data-execute-url` must be same-origin with the page: the loader sends your session cookie to them, and a cross-origin target is refused at boot. **Sizing the inline frame.** The iframe is `width: 100%; height: 100%` and fills the box it is in; your CSS owns the height. Give the snippet's parent a height, or the frame is 0 pixels tall and invisible with no error anywhere: ```html
``` Drawer mode needs none of this. **JavaScript API.** The loader installs `window.BackboneSurface` with `open()`, `close()`, `toggle()` and `isOpen()`. Bind your own launcher to `toggle()` and mirror `aria-expanded` from `isOpen()`. **What the user gets.** A chat panel with the agent's name in the header; answers rendered as Markdown; a light/dark toggle; a microphone button for [dictation](#dictation); a conversation that survives navigation within your application; an open drawer is a dialog with focus management and closes on Escape. ## The Mint Endpoint The loader calls your mint endpoint about once a minute per tab, with your session cookie, and asks for an assertion for the logged-in user. **Request** ``` POST ?token= Content-Type: application/json Accept: application/json X-Requested-With: XMLHttpRequest X-Embed-Protocol: 1 X-CSRF-Token: {"strict": false} ``` The CSRF token is sent both as a query parameter and as a header so either kind of framework can read it. `strict` is a legacy flag you may ignore. **Success**: `200` with JSON. ```json { "assertion": "", "expiresIn": 60 } ``` `expiresIn` is seconds. The loader caches the assertion until shortly before it expires and asks again on the next request that finds none. **Failure**: always JSON, never a redirect, never a login page with status `200`. | Status | Body | The panel shows | |--------|------|-----------------| | `401` | `{"error":"session_expired"}` | "log in again" | | `403` | any | The user may not use the assistant | | `429` | any | "refusing sign-in requests for now" | | `5xx` | any | "the sign-in endpoint failed" | A `2xx` that redirects or carries an HTML body is read as `session_expired`. ### The assertion A compact JWS, `alg: EdDSA`, header `typ: "surface+jwt"`, header `kid` naming a registered key. All claims are required: | Claim | Type | Value | |-------|------|-------| | `iss` | string | Installation id | | `sub` | string | Your user's id. Identifiers only, never names or e-mail addresses | | `ent` | string | Your tenant or entity id | | `aud` | string | The `backboneOrigin` from enrolment, byte for byte | | `adm` | boolean | `true` for your admins. Unlocks catalog sync and the drift check | | `iat`, `nbf`, `exp` | number | Lifetime at most 5 minutes | | `jti` | string | 32 hex characters, unique per mint | Keep `sub` stable: the same person always gets the same value, and a value is never reused for another person. The user's conversations, [connectors](#connectors) and, with [member sign-in](#member-sign-in), their sign-in all hang on it. 2kw.ai refuses a bad assertion with `401 invalid_assertion` (signature, `aud`, unknown `kid`, malformed) or `401 assertion_expired`. A verified end user may call only `/v1/responses`, `/v1/conversations`, `/v1/dictation` and `/v1/surface/**`, and only the agent the installation is bound to; anything else is `403`. ## Relay Tools Optional. Relay tools are tools your application executes on the model's behalf, under the end user's own session and permissions: the ERP's REST API, for instance. Your application publishes the tools it offers, and the loader runs the calls through your tool proxy. ### Publish the tool catalog ``` POST https://api.2kw.ai/v1/surface/agents/{agentId}/tool-catalog Authorization: Bearer Content-Type: application/json { "manifest": "", "signature": "", "kid": "3f9c1a7e0b2d4c68" } ``` - Authenticated with an assertion, not an API key: the installation is taken from the assertion. - `kid` names the installation key the signature is checked against. - A byte-identical manifest is accepted idempotently. Answers `{ "content_hash", "synced_at" }`; `403` for a bad signature, `422` for a malformed manifest, `409` for a manifest older than the one on record. - `GET` on the same path returns the active catalog's `content_hash` and timestamps, or nulls when nothing was synced. The org-side history is `GET /v1/agents/{agentId}/tool-catalogs`. The manifest carries `schema_version`, `installation_id`, `tools[]` and optionally `generated_at` (ISO-8601 UTC; when present, an older manifest is refused). Each tool has `type: "relay"`, a namespaced `name`, JSON-Schema `parameters`, and both `annotations.readOnlyHint` and `annotations.destructiveHint` as booleans. The annotations are required and stored, and the agent's [approval policy](/agents/approvals#approvals) reads them to class the tool: `destructiveHint: true` is `destructive`, otherwise `readOnlyHint: true` is `read`, and anything else is `write`. Catalogs with hundreds of tools are normal. The model does not see them all at once; it starts with the agent's own and the catalog's `core` tools and finds the rest with [tool discovery](/agents/tools#tools). ### The execute endpoint When the model calls a relay tool, the turn pauses with `status: "requires_action"`. The panel hands each call to the loader, which posts it to your tool proxy under the user's session: ``` POST ?token= { "callId": "call_…", "name": "erp_orders_get", "arguments": "{\"id\":17}" } ``` Answer with JSON carrying `output` (a string) and `failed` (a boolean). `output` is exactly what the model reads. A non-`2xx` answer with `{ "error", "code" }` is handed to the model as a failed tool. `arguments` is the model's own JSON string, forwarded verbatim; `callId` is your idempotency key. Your application resolves the name against its own catalog and refuses unknown names. One rewrite happens before that forward. When the user attached files and the model hands one to a tool, it sets the argument to `backbone:file:{id}`; the panel replaces that value with the file's bytes, base64, so your tool receives the document itself. A file the panel cannot fetch is answered as a failed call inside the panel and never reaches your application. Attachments, retention and quotas are on the [Files page](/files#files-from-the-embedded-panel). By default up to 50 rounds of tool calls run per turn. The agent's [approval policy](/agents/approvals#approvals) gates relay tools like any other tool: a class or tool rule that says `approve` pauses the call for a person, `deny` and `block` refuse it, and only an allowed call reaches your proxy. The [conversation mode](/agents/approvals#conversation-mode) applies too, so Plan mode refuses a relay tool that is not `read`. Your proxy is still where write permissions are finally enforced, under the user's own session. ## Connectors Optional, and nothing to set up on your side. When the agent declares a [connector](/agents/connectors#connectors-mcp-servers) whose MCP server needs a sign-in, your users connect it from the panel, each under their own account on that server — without being members of your 2kw.ai organization. **What the person sees.** The first time the agent needs the server, the turn pauses and a prompt takes the composer's place: "*Agent* wants to act as you in *host*", with **Connect** and **Not now** (Enter and Esc, as on an approval). Connect opens the server's own sign-in page in a new tab; once the person signed in, the tab says to return to the panel and closes, the prompt settles and the agent goes on with the server's tools in the same answer. Later turns need no prompt. The person is asked again only to **Allow** the agent after a new version of it reaches destinations it did not reach before, or to **Reconnect** when 2kw.ai could no longer renew the access in the background. Not now continues without the server, and the agent is told it is not connected. **Where the sign-in lands.** The sign-in tab returns to `https://chat.2kw.ai/connectors/callback`, the one callback address 2kw.ai registers with every server. That page does not complete a panel user's sign-in itself; it hands the server's answer back to the panel window that opened the tab, and the panel completes it with the user's own assertion. Your page, your CSP and your mint endpoint are not involved: the loader's frame allows popups for this, and the tab is an ordinary top-level window. **Who holds the connection.** A connection, and the agent's permission to use it, belong to the end user your assertion names (`sub`) within your installation; two users of the same installation never share one, and a panel user never sees a member's connections or vice versa. Your application signs the assertion, so it is your application that says who the user is: a host that mints an assertion for a user can use that user's connections — for this installation's agent only. The access token is held by 2kw.ai, encrypted, is never shown to the model or to your page, and is bound to the server the agent declares. Disabling the installation stops its assertions from verifying and, with them, every use of its users' connections; an organization admin's **Disconnect all** on a server removes them. A panel has no connections page in this release; a user revokes the access in the server's own account settings. **When it does not work.** - *The prompt says the sign-in page could not be opened and shows a link.* The browser's popup blocker refused the tab. The link opens the same sign-in page; afterwards the person presses **Continue** in the panel. - *The sign-in tab says the sign-in could not be handed back to the panel.* The server's sign-in page sends `Cross-Origin-Opener-Policy: same-origin`, which severs the link to the window that opened it. Such a server cannot be connected from a panel yet; members can still connect it in the chat app. - *The prompt stays after signing in.* The person closed the sign-in tab before it returned, or the hand-back was lost. **Continue** asks 2kw.ai how the sign-in stands and goes on if it completed; **Connect again** starts over. ## Member Sign-In Optional, off by default, and nothing to build on your side: the snippet and the mint endpoint stay as they are. Without it the agent knows a panel user only as the `sub` your assertion names. With it, a person who is also a member of your 2kw.ai organization can sign in once from the panel, and the agent then treats them as that member for 90 days, including their agent memory. **Turn it on.** An organization admin switches **Allow members to sign in** on for the installation, on its card in the console, with `PUT /v1/installations/{id}/member-login` or with `2kw installations member-login`. The panel's header shows **Sign in** only while the switch is on. **What the person sees.** **Sign in** opens a tab on `chat.2kw.ai` with a card that names the installation and every origin registered for it, and the account and organization the person is signed in with. **Sign in** on the card approves; **Cancel** closes the tab without signing in; the request lapses by itself after a few minutes. Back in the panel the header shows a person icon with a menu, "Signed in · until *date*" and **Sign out**. A person who is not a member of the organization is told to ask an admin to invite them; one whose app is already signed in is told to sign out in the app first. **Ending a sign-in.** A sign-in lasts 90 days and is never extended. The person can sign out in the panel's menu, or on `chat.2kw.ai` under **Where you're signed in** in the user menu, which lists every app they are signed in to. An admin turning the switch off signs everyone out of that installation; so does the person leaving the organization. When a sign-in ends on its own, the panel says "You're signed out. Memory is off." and goes back to **Sign in**. **Who can act as a member.** Whoever controls the installation's signing key can act as its signed-in members, the same way they can already act as any `sub` they assert. Keep the key where only your server can read it. A platform-side key rotation can end every sign-in early; members then sign in again. **When it does not work.** - *The panel says "Couldn't open sign-in. Allow pop-ups for this site and try again."* The browser blocked the tab. The person allows pop-ups for your page, then presses **Sign in** again. - *The card says the request isn't available.* It expired, or the person is signed in to `chat.2kw.ai` with a different organization than the app belongs to. The card offers a switch of organization; otherwise press **Sign in** in the panel again. - *The tab stays open after signing in.* Some browsers will not let a script close a tab. The card says to return to the app; the panel notices the sign-in on its own. ## Conversation Mode The panel shows a mode chip next to the composer. Your users switch between Plan (read only), Ask (calls that need approval wait for them) and, if you offer it, Auto; the choice is stored on the conversation and sent with every turn and decision they start. Which modes the chip offers and which one it starts in is `conversation_modes` in the agent's approval policy. Plan applies to your relay tools too: a relay tool runs under Plan only when its manifest marks it read-only (`readOnlyHint: true`, `destructiveHint: false`) and no rule of the approval policy classifies it otherwise; every other relay call is refused and never reaches your proxy. See [Conversation Mode](/agents/approvals#conversation-mode). ## Installation Endpoints | Method and path | Purpose | |-----------------|---------| | `GET /v1/installations` | List, with `search`, `status` and `agentId` filters | | `POST /v1/installations` | Create, bound to `agentId`; returns the first pairing string | | `GET /v1/installations/{id}` | Read, including `origins` and `agentId` | | `PUT /v1/installations/{id}` | Rename, describe, set `status`; binds `agentId` once if still unbound | | `POST /v1/installations/{id}/pairing-code` | A fresh pairing string | | `PUT /v1/installations/{id}/origins` | Replace the allowed origins | | `PUT /v1/installations/{id}/member-login` | Allow or stop [member sign-in](#member-sign-in); stopping signs everyone out of the app. Needs a signed-in `ADMIN` or `OWNER`; an API key is refused. Every field is in the in-app API reference | | `GET /v1/installations/{id}/keys` | List verification keys, revoked ones included | | `POST /v1/installations/{id}/keys` | Register a public key by hand (`kid`, `publicKeyBase64`) | | `DELETE /v1/installations/{id}/keys/{kid}` | Revoke a key | | `POST /v1/surface/enrol` | Unauthenticated: redeem a pairing string | Writes need an `ADMIN` or `OWNER` credential; reads need `VIEWER` or above. The console's **Embed** page on each agent covers create, pairing, the snippet, keys and revocation. From a terminal, `2kw installations` creates and updates installations, issues pairing codes and manages origins and keys; see [Installations](/cli#installations) on the CLI page. ## Dictation The composer has a microphone button beside Send. The user taps it and speaks. While dictation runs, the row under the message box shows a level trail that moves with the voice, a cancel button and a finish button, and an empty message box reads "Starting…", "Listening…" or "Finishing…". The words appear in the message box after each short pause; the box is read-only until dictation ends. Finish (or Enter) keeps the words once the last ones are transcribed, and the user can edit them before sending. Cancel (or Escape) discards the dictation and puts the message back as it was before the tap. The agent only ever receives the text; no audio reaches it. The button shows whenever the browser can capture audio (`getUserMedia` and AudioWorklet). Browsers that let the panel read your page's permissions policy (Chromium-based ones) hide it when that policy refuses the microphone; Firefox and Safari still show it. Nothing needs to be enabled on 2kw.ai. - **Permission.** The loader's frame carries `allow="microphone"`, so nothing on your page needs to change. The browser asks the user before the first dictation; Safari, and Firefox when the user did not choose to remember the decision, ask again after a reload. In Chrome and Firefox that prompt, and the grant the browser stores, belong to your page's origin, not to the surface origin: a user who allows the microphone for the assistant has granted the microphone to your application's origin itself, including any third-party script your page loads (in Firefox for as long as the grant lasts). - **Turning it off.** Send `Permissions-Policy: microphone=()` with your page. Chromium-based browsers honour it for the panel's frame and hide the button. Firefox and Safari may not apply that header to the frame and give the panel no way to read it: there the button still shows, and a click may still prompt for the microphone. The header is therefore not a reliable off switch in every browser. - **HTTPS.** Browsers only grant the microphone to secure pages. The loader already requires HTTPS, so no new requirement applies. - **Where the audio goes.** Short audio slices are sent to 2kw.ai and transcribed there, never through a provider your organization connected. Audio is not stored and not logged, and the transcribed text is not logged either. Once sent, the text is a message like any typed one. - **Limits.** 30 slices per minute per end user and 300 per minute per installation. At the limit the panel says "Dictation limit reached, try again in N s." - **When it stops.** When the user presses finish or Enter, after 60 seconds without speech, and after 5 minutes at the latest; each keeps the words. Cancel or Escape discards them. The panel's own close button stops dictation and keeps the words so far; **New conversation** discards a dictation in progress. **What the panel may say.** Words already in the message box are always kept. | Message | Cause | |---------|-------| | "Microphone blocked. Allow it in the browser's site settings." | The user refused the prompt, the browser's site settings for your origin block the microphone, or the browser applied your page's `Permissions-Policy` without being able to hide the button. A `SecurityError` from `getUserMedia`, such as a page that is not a secure context, gets the same message | | "No microphone found." | The device has no usable audio input | | "The microphone could not be started." | The microphone could not be opened, runs below 16 kHz (for example a Bluetooth headset in call mode; switching the headset to its media profile fixes it), or the audio pipeline did not start | | "The microphone stopped." | The microphone went away during dictation, for example it was unplugged or the permission was revoked. Dictation stops and still transcribes what was already heard | | "Dictation limit reached, try again in N s." | One of the limits above | | "Dictation is unavailable right now." | The transcription service is not available or did not answer in time | | "Part of the dictation could not be transcribed." | One slice failed twice; dictation goes on with the next | | "Dictation failed." | The service refused the panel's request, for example with `403` from `POST /v1/dictation`, sign-in still failed after a retry with a fresh assertion, or the panel hit an internal error. Not a user error: the browser console carries the details | ## Troubleshooting | Symptom | Cause | Check | |---------|-------|-------| | Note in your page after about 10 s: the panel could not be loaded | Origin not registered, installation disabled, or unknown id. One indistinguishable answer by design | `GET /v1/installations/{id}`: `status` and `origins`, compared with the browser's `location.origin` | | Same note, origin registered and active | Page is not a secure context (`http://`) | `isSecureContext` in the browser console | | Note at boot: `data-mint-url must be same-origin` | Absolute or protocol-relative mint URL pointing elsewhere | Use a path | | Panel loads, every turn fails, mint requests `404` | The placeholder mint URL was left in, or your application lives under a subdirectory | Open the mint URL from the snippet in the browser with your session | | Panel says "log in again" though the user is logged in | Mint answered a redirect, an HTML body or `401` | `curl -i` the mint URL with the session cookie; check the CSRF token reaches it | | `401 invalid_assertion` on every call | `aud` differs from `backboneOrigin`; `kid` not registered; signing with a new key before registering it; `sub` or `ent` sent as numbers | Decode the JWS payload; compare `kid` with `GET …/keys` | | `403` from 2kw.ai | The end user hit a path outside `/v1/responses`, `/v1/conversations`, `/v1/dictation`, `/v1/surface` | | | Relay tool calls never run; the model apologises | No catalog synced for this agent and installation | `GET /v1/surface/agents/{agentId}/tool-catalog` with an admin assertion | | Catalog sync `403` | Signature computed over re-encoded bytes, or with a revoked or unregistered key | Sign the exact string you send in `manifest` | | Catalog sync `409` | `generated_at` older than the record | Regenerate, or stop sending `generated_at` | | The panel is invisible in inline mode, no error | The snippet's parent has no height | Give the wrapper a height | | No microphone button | Your page sends `Permissions-Policy: microphone=()` (Chromium-based browsers hide the button), the browser lacks `getUserMedia` or AudioWorklet, or the browser still runs a cached loader whose frame does not delegate the microphone (Chromium hides the button then too, for up to about 5 minutes after a panel release) | The `Permissions-Policy` response header of your page; reload the page after a few minutes | | The microphone button answers "Microphone blocked" | The microphone is blocked in the site settings for your page's origin, where the grant lives, or the browser applied your page's `Permissions-Policy` without being able to hide the button. A `SecurityError` from `getUserMedia` gets the same message | The site settings for your origin | | The microphone button answers "Dictation is unavailable right now" | The transcription service is unavailable or timed out | Try again later | | A connector's sign-in tab says the sign-in could not be handed back to the panel | The server's sign-in page sends `Cross-Origin-Opener-Policy: same-origin`; see [Connectors](#connectors) | The response headers of the server's sign-in page | ## Known Gaps - The panel does not stream; each answer arrives whole. - Closing the drawer from your page (Escape, your own launcher, `BackboneSurface.close()`) does not stop a dictation in progress yet; it ends after 60 seconds without speech or at 5 minutes. The panel's own close button does stop it. - Firefox and Safari show the microphone button even when your page sends `Permissions-Policy: microphone=()`, and a click there may still prompt for the microphone. - Typing on the keyboard while dictating can be picked up as speech and add stray words. - Where a browser refuses storage to a third-party frame, a navigation in your application starts a new conversation. --- Source URL: https://docs.2kw.ai/ai-gateway # AI Gateway One API, any provider. Use the OpenAI SDK you already know — 2kw.ai routes to the right backend. ## Overview The AI Gateway exposes OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/audio/transcriptions`, `/v1/models`) and routes requests to whatever provider you've configured. Swap between OpenAI, Azure OpenAI, Anthropic, xAI, Mistral, or a self-hosted Ollama server without touching your code. ### Why use it? - **No vendor lock-in** — switch providers by changing the model string, not your codebase - **Standard API** — any OpenAI-compatible SDK, tool, or framework just works - **Streaming** — full SSE support for real-time responses - **Centralized credentials** — manage API keys and routing per-organization ## Models ### Platform Models 2kw.ai comes with pre-configured models available on every tier. Use them by name — no provider prefix required. **Note — Checking available models:** Use the `GET /v1/models` endpoint to see all models currently available to your organization, including both platform and BYOK models. ### Bring Your Own Key (BYOK) On the **Team**, **Business** and **Enterprise** plans, you can connect your own provider accounts and use the `provider/model` format: ``` openai/gpt-5.1 azure-openai/gpt-4 anthropic/claude-sonnet-4-5-20250929 xai/grok-4 mistral/mistral-large-latest ollama/llama3 ``` The prefix tells the gateway where to route. The model name after the slash is passed directly to the provider. A BYOK request is billed through your own provider account, and its model charge does not count against your plan's [usage](/usage). **Note — BYOK availability:** Bring Your Own Key requires the **Team**, **Business** or **Enterprise** plan. Configure your providers under **Providers** in the sidebar (section **Build**); see [Providers & BYOK](/providers) for provider types, test connection and the `/v1/providers` API. ## Chat Completions ### `POST /v1/chat/completions` The main endpoint. Drop-in replacement for `https://api.openai.com/v1/chat/completions`. **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "model": "gpt-5.1", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is Spring Boot?"} ] }' ``` **Python** ```python from openai import OpenAI client = OpenAI( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1" ) response = client.chat.completions.create( model="gpt-5.1", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is Spring Boot?"} ] ) print(response.choices[0].message.content) ``` **TypeScript** ```typescript import OpenAI from "openai"; const client = new OpenAI({ apiKey: "sk_your_api_key", baseURL: "https://api.2kw.ai/v1", }); const response = await client.chat.completions.create({ model: "gpt-5.1", messages: [ { role: "system", content: "You are a helpful assistant." }, { role: "user", content: "What is Spring Boot?" }, ], }); console.log(response.choices[0].message.content); ``` ### Request Parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `model` | string | Yes | Platform model name (e.g., `gpt-5.1`) or `provider/model` for BYOK | | `messages` | array | Yes | Chat messages (`role` + `content`). `content` is a string, or an array of `text` and `image_url` parts — see [Images in messages](#images-in-messages) | | `stream` | boolean | No | Enable SSE streaming (default: `false`) | | `temperature` | number | No | Sampling temperature (0-2) | | `max_tokens` | number | No | Max tokens in response | | `top_p` | number | No | Nucleus sampling (0-1) | | `frequency_penalty` | number | No | Frequency penalty (-2 to 2) | | `presence_penalty` | number | No | Presence penalty (-2 to 2) | | `stop` | array | No | Stop sequences | | `response_format` | object | No | Output format — e.g. `{"type": "json_object"}` for JSON mode | | `tools` | array | No | Tool definitions for function calling | | `tool_choice` | string/object | No | Control tool selection: `"auto"`, `"none"`, or a specific tool | | `max_completion_tokens` | number | No | Max completion tokens (newer alternative to `max_tokens`) | | `parallel_tool_calls` | boolean | No | Accepted for OpenAI compatibility but not forwarded to the provider on this endpoint; the provider's own default applies | | `prompt_cache_key` | string | No | Up to 256 characters. Groups requests that share a long prompt prefix so the provider's prompt cache can serve them together. On the built-in models the provider receives a hash scoped to your organization, never the value itself. Longer than 256 characters is a `400` | | `stream_options` | object | No | Streaming options. `{"include_usage": true}` appends a final chunk with the call's token `usage` — see [Streaming](#streaming) | ### Response ```json { "id": "chatcmpl-123", "object": "chat.completion", "model": "gpt-5.1", "choices": [{ "index": 0, "message": { "role": "assistant", "content": "Spring Boot is a Java-based framework..." }, "finish_reason": "stop" }], "usage": { "prompt_tokens": 25, "completion_tokens": 150, "total_tokens": 175, "prompt_tokens_details": { "cached_tokens": 0 }, "completion_tokens_details": { "reasoning_tokens": 0 } } } ``` `usage.completion_tokens_details.reasoning_tokens` is how many of the `completion_tokens` the model spent on reasoning. They are counted inside `completion_tokens` and billed as output. The object is left out when the provider does not report the count, so a missing `completion_tokens_details` does not mean zero. `/v1/responses` reports the same count as `usage.output_tokens_details.reasoning_tokens`, covering the whole turn; there an unknown count is written as `0`, because that field is required. `usage.prompt_tokens_details.cached_tokens` is how many of the `prompt_tokens` the provider served from its prompt cache. Cached input is billed at the cache-read price. The object is left out when the share is not known, so a missing `prompt_tokens_details` does not mean zero. On the built-in models the field is reported only while the organization-scoped `prompt_cache_key` is sent to the provider; calls on your own provider ([BYOK](#bring-your-own-key-byok)) always report it when the provider does. ## Images in Messages A message's `content` can be an array of parts instead of a string. Text parts and image parts mix freely, in the same shape the OpenAI API uses: ```json { "model": "gpt-5.1", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "What does this label say, and is the part within tolerance?" }, { "type": "image_url", "image_url": { "url": "https://example.com/label.jpg" } } ] } ] } ``` `image_url.url` takes an `https` URL or a `data:` URL with base64 content; the provider you route to decides which image types and sizes it accepts. Pick a model that supports vision, or the provider rejects the request. Calls that carry an image are recorded under the `VISION` surface in analytics, so image traffic is visible next to text traffic in the analytics dashboard. Responses are always plain text; the model does not return image parts. ## Streaming Set `"stream": true` and you'll get Server-Sent Events: **Request** **Python** ```python from openai import OpenAI client = OpenAI( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1" ) stream = client.chat.completions.create( model="gpt-5.1", messages=[{"role": "user", "content": "Write a haiku about code"}], stream=True ) for chunk in stream: if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="") ``` **TypeScript** ```typescript const stream = await client.chat.completions.create({ model: "gpt-5.1", messages: [{ role: "user", content: "Write a haiku about code" }], stream: true, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) process.stdout.write(content); } ``` **cURL** ```bash curl -X POST https://api.2kw.ai/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "model": "gpt-5.1", "messages": [{"role": "user", "content": "Count to 5"}], "stream": true }' ``` **Note — SSE format:** Streaming responses follow the standard OpenAI SSE format — `data: {...}` lines terminated with `data: [DONE]`. The `stream` field picks the event stream, not the `Accept` header. A request without `"stream": true` is answered with JSON, so if its `Accept` header rules out `application/json` it is refused with `406` (`invalid_request_error` / `not_acceptable`) before the model runs, and nothing is billed. A stream carries no token counts by default. Send `"stream_options": {"include_usage": true}` and the last chunk before `data: [DONE]` has an empty `choices` array and a `usage` object in the shape of the non-streaming response, `prompt_tokens_details.cached_tokens` included. Every other chunk omits `usage`. When the provider reports no token counts for the stream, that chunk is not sent, so do not wait for it. ## List Models ### `GET /v1/models` Lists all models available to your organization — both platform models and BYOK providers you've configured: ```bash curl https://api.2kw.ai/v1/models \ -H "Authorization: Bearer sk_your_api_key" ``` ## Responses API ### `POST /v1/responses` The gateway also serves the OpenAI Responses API. With `model: "provider/model"` it is a direct call with the same routing as chat completions; with `model: "agent/{name}"` it runs a stored [agent](/agents) with server-side tools, knowledge search, citations and approvals. The request and response shapes, conversations and the continuation protocol are documented on the [Agents page](/agents#running-an-agent). Set `"stream": true` for Server-Sent Events in the Open Responses format; the same page lists the events a stream carries. Send the optional `timezone` field (an IANA zone such as `Europe/Berlin`) and the model knows the current date and hour in that zone; the [Agents page](/agents#running-an-agent) has the details and the in-app API reference lists every field. ## Embeddings ### `POST /v1/embeddings` Drop-in replacement for `https://api.openai.com/v1/embeddings`. There is no CLI or MCP command for it; call it from an OpenAI SDK, LangChain or any OpenAI-compatible client. **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "model": "text-embedding-3-small", "input": ["First text", "Second text"] }' ``` **Python** ```python from openai import OpenAI client = OpenAI( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1" ) response = client.embeddings.create( model="text-embedding-3-small", input=["First text", "Second text"] ) print(len(response.data[0].embedding)) ``` The response is the OpenAI shape: `object: "list"`, one `data[]` entry per input in input order with its `index` and `embedding`, the `model` you sent, and a `usage` with `prompt_tokens` and `total_tokens`. | Field | Description | |-------|-------------| | `model` | Required. A bare name runs on the built-in models; `provider/model` runs on your own provider (see below). | | `input` | Required. A string, or an array of strings. | | `dimensions` | Optional. The width of each vector, for models that can be shortened. | | `encoding_format` | Optional. `float` (default), or `base64`: each vector as little-endian float32, base64-encoded. The OpenAI SDKs decode it for you. | | `user` | Optional. An identifier of your end user, recorded on the trace. | #### Two routes | `model` | Runs on | Cost | |---------|---------|------| | `text-embedding-3-small`, `text-embedding-3-large` (bare name) | The platform's [built-in embedding models](/knowledge-bases#built-in-embedding-models) | Charged against your plan's usage: €0.0825 per million input tokens on `text-embedding-3-small`, €0.53625 on `text-embedding-3-large` (see the [rate card](/usage#rate-card-for-developers)). Each call is charged for the tokens it used. | | `openai/text-embedding-3-small`, `azure-openai/` (`provider/model`) | Your own [provider](/providers) | Not charged by 2kw.ai; your provider bills you. Needs the Team, Business or Enterprise plan, like any other [BYOK call](#bring-your-own-key-byok). Counts toward your concurrent-call limit. | Only the `openai` and `azure-openai` providers serve embeddings; any other prefix answers `400` with code `model_not_supported`. The `model` in the response is the one you sent. Where the data goes follows the route: the built-in models run on the platform's Azure OpenAI deployments in the EU Data Zone, and `provider/model` sends your input to the provider you configured. #### Limits - At most 2,048 inputs per request. - Every input is a non-empty string. - At most 1 MiB of UTF-8 in total across the inputs. - OpenAI's own token limits per input are the provider's, and a request over them is answered with the provider's `400`. Arrays of token ids are not supported. LangChain's `OpenAIEmbeddings` sends token ids by default and gets a `400` until you set `check_embedding_ctx_length=False` (see [LangChain](#lang-chain)). #### Errors Errors use the gateway's OpenAI-style envelope `{"error": {"message", "type", "param", "code"}}`. The same statuses and codes, with what to do, are on [Errors](/errors). | Case | Status | `type` / `code` | |------|--------|-----------------| | `Accept` header that rules out JSON | 406 | `invalid_request_error` / `not_acceptable` | | A JSON request body over 64 MB (the same on chat completions, responses and conversations; file uploads have their own limits) | 413 | `invalid_request_error` / `request_too_large` | | Malformed body, `input` of another type, token-id array, unknown `encoding_format` | 400 | `invalid_request_error` | | More than 2,048 inputs, an empty input, more than 1 MiB | 400 | `invalid_request_error` / `too_many_inputs`, `empty_input`, `input_too_large` | | A bare name that is not a built-in embedding model, or a malformed `provider/model` | 400 | `invalid_request_error` | | A provider that serves no embeddings | 400 | `invalid_request_error` / `model_not_supported` | | No provider configured for the prefix | 404 | `invalid_request_error` | | Viewer role | 403 | `invalid_request_error` / `forbidden` | | Usage used up with no extra usage available, or a plan without BYOK | 402 | `billing_error` | | Too many concurrent calls | 429, with `Retry-After` | `rate_limit_error` | | The provider refused the request (over-long input, bad `dimensions`) | 400 | `upstream_error` | | The provider failed | 502 | `upstream_error` | When a chat or response call runs on your own provider and that provider account has no credits left, or has reached the spend limit set on it, the gateway answers `400` with `billing_error` / `provider_quota_exhausted`. The message says which of the two it is. Retrying does not help until you add credits or raise the limit with the provider. A stream that has already started ends with an error event carrying the same `type` and `code`, and a stored or background response is `failed` with the same code. Two answers come from the security layer in front of every `/v1` endpoint and are not in this envelope: a request with no credentials at all gets a bare `401`, and a rejected surface assertion gets a flat `{"error": "", "message": "..."}`. ## Framework Integrations The gateway works with anything that speaks OpenAI — just point `base_url` at your 2kw.ai instance. ### LangChain ```python from langchain_openai import ChatOpenAI llm = ChatOpenAI( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1", model_name="gpt-5.1" ) response = llm.invoke("What is the capital of France?") print(response.content) ``` **Note — Use provider/model, not agent/...:** Keep `ChatOpenAI` and other generic OpenAI clients on `provider/model` models. An `agent/{name}` turn returns the tool calls the agent already ran as `function_call` items, and a generic client reads them as calls for it to run; it also cannot see a run that waits for an approval. Call agents through the [Responses API](/agents#running-an-agent) from your own code, or through the [2kw n8n node](/integrations/n8n#agents). For embeddings, turn off LangChain's token-id chunking, which the gateway does not accept: ```python from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1", model="text-embedding-3-small", check_embedding_ctx_length=False ) vectors = embeddings.embed_documents(["First text", "Second text"]) ``` **Note — Works with any OpenAI-compatible tool:** LiteLLM, LlamaIndex, Haystack, Semantic Kernel — if it has a `base_url` setting, it works with 2kw.ai. ## Supported BYOK Providers Connect your own accounts from any of these providers (Team, Business and Enterprise plans): | Provider | Prefix | Example Models | |----------|--------|----------------| | OpenAI | `openai` | gpt-5.1, gpt-5-mini, gpt-4.1 | | Azure OpenAI | `azure-openai` | Your Azure deployment names | | Anthropic | `anthropic` | claude-sonnet-4-5, claude-opus-4-5, claude-haiku-4-5 | | xAI | `xai` | grok-4 | | Mistral | `mistral` | mistral-large-latest, mistral-small-latest | | Ollama | `ollama` | llama3, mistral, codellama (models pulled on your own server) | The name after the prefix is the provider's own model id, passed through unchanged. Platform model names are not provider ids: the platform's `gpt-5.1-mini`, for example, is OpenAI's `gpt-5-mini`, so a BYOK call names it `openai/gpt-5-mini`. A few request features depend on the provider: - **Mistral** takes `tool_choice` as `"auto"`, `"none"` or `"required"`. A specific named function is not passed on, so the model chooses among the tools itself. - **Images by URL** must use `https` for every provider; a plain `http` image URL is refused with `400`. OpenAI, Azure OpenAI, Anthropic, xAI and Mistral fetch an `https` image themselves. - **Ollama** accepts images only inline, as base64 `data:` URLs. An `https` image URL sent to an Ollama model is refused with `400`. Google Vertex AI is not supported. The `vertex-ai` prefix is recognized, and appears in the list of valid prefixes an unknown prefix returns, but no call routed to it can succeed. Configure providers under **Providers** in the sidebar (section **Build**) or through the API, see [Providers & BYOK](/providers). ## Provider Configuration ### `POST /v1/providers` Connects a provider account to your organization. The **Providers** page in the console makes the same call. [Providers & BYOK](/providers) documents every provider endpoint in full. ```bash curl -X POST https://api.2kw.ai/v1/providers \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "name": "Production Azure", "provider": "AZURE_OPENAI", "apiKey": "your-azure-openai-key", "config": { "endpoint": "https://my-resource.openai.azure.com" } }' ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name, up to 255 characters | | `provider` | string | Yes | `OPENAI`, `AZURE_OPENAI`, `ANTHROPIC`, `XAI`, `MISTRAL` or `OLLAMA` | | `apiKey` | string | Yes | The provider's API key, a top-level field and never part of `config`. Stored encrypted and never returned. Required for every provider except `OLLAMA`, which needs no key: leave it out | | `config` | object | Yes | Provider-specific settings from the table below. Send `{}` when none apply | The API also lists `VERTEX_AI` as a provider type, but a Vertex AI provider cannot be configured or serve requests. ### Config keys | Provider | Key | Required | Default | Notes | |----------|-----|----------|---------|-------| | OpenAI | `baseUrl` | No | `https://api.openai.com/v1` | For an OpenAI-compatible endpoint. Must be `https://` on a public host | | OpenAI | `organizationId` | No | | Your OpenAI organization ID | | OpenAI | `useResponsesApi` | No | `false` | `true` sends every request through OpenAI's Responses API. See below | | Azure OpenAI | `endpoint` | Yes | | Your resource URL, such as `https://my-resource.openai.azure.com`. Must start with `https://` | | Azure OpenAI | `apiVersion` | No | `2024-08-01-preview` | Used only to list your deployments | | Anthropic | `baseUrl` | No | `https://api.anthropic.com` | Must be `https://` on a public host | | Anthropic | `version` | No | `2023-06-01` | Sent as the `anthropic-version` header | | xAI | `baseUrl` | No | `https://api.x.ai/v1` | Must be `https://` on a public host | | Mistral | `baseUrl` | No | `https://api.mistral.ai` | Must be `https://` on a public host | | Ollama | `baseUrl` | Yes | | Your Ollama server, such as `https://ollama.example.com`. Must be `https://` on a public host | Azure OpenAI has no deployment setting: the deployment name is the model name, as in `azure-openai/my-gpt-4o-deployment`. **`useResponsesApi`.** You rarely need it. A request that carries function tools for a reasoning model is sent through the Responses API automatically, because chat completions refuses that combination, and so is a model that only exists on the Responses API. Setting the flag forces the Responses API for every request on this provider, tools or not. Streaming works on both. The flag takes a boolean or the string `"true"` or `"false"`; the console calls it **Use the Responses API**. ### Managing providers | Method and path | Purpose | |-----------------|---------| | `GET /v1/providers` | List your providers. `apiKey` is never included | | `GET /v1/providers/{id}` | Read one provider | | `PATCH /v1/providers/{id}` | Change `name`, `apiKey` or `config`. Every field is optional; the provider type cannot change | | `DELETE /v1/providers/{id}` | Remove a provider | | `POST /v1/providers/test` | Check `provider`, `apiKey` and `config` before saving. Answers `success`, a `message` and the models found | | `GET /v1/providers/models` | List the models available across all your providers | The CLI equivalents are under `backbone providers` on the [CLI page](/cli#providers). --- Source URL: https://docs.2kw.ai/providers # Providers & BYOK Connect your own provider account once, then call its models through the [AI Gateway](/ai-gateway) as `provider/model`. ## Overview A **provider** is one set of credentials for one AI vendor, stored for your organization. With Bring Your Own Key (BYOK), requests that name a provider's model go to your account at that vendor, and the vendor bills you for them. - **Console**: open **Providers** in the sidebar, under **Build**. - **API**: the `/v1/providers` endpoints below. - **CLI**: `backbone providers`, see [CLI](/cli#providers). API keys you store are encrypted at rest and never returned: no response on this page contains `apiKey`. ## Tier Availability BYOK is part of the **Team**, **Business** and **Enterprise** plans. On these plans the console shows **Providers** in the sidebar, and a request that routes to your own provider is billed by that provider, and its model charge does not count against your plan's [usage](/usage). On **Starter**, or without a plan, the sidebar item is hidden; use the [platform models](/ai-gateway#platform-models) instead. ### After a Downgrade A downgrade to a plan without BYOK keeps your providers and their keys, but they stop working: - Creating a provider answers `402` with `errorCode` `LIMIT_EXCEEDED_BYOK`. - Every call on a `provider/model` (chat completions, the Responses API, embeddings, extraction, transcription, agents) answers `402`; on the gateway the error has `type` `billing_error` and `code` `limit_exceeded`. Platform models keep working. - You can still list, update and delete your providers. An upgrade back to Team or higher makes the same providers work again at once; there is nothing to re-enter. ## Calling Your Models Name a BYOK model as `/`. The prefix picks the provider type; everything after the first `/` is passed to the provider unchanged. ``` openai/gpt-5.1 azure-openai/my-gpt-deployment anthropic/claude-sonnet-4-5-20250929 xai/grok-4 mistral/mistral-large-latest ollama/llama3 ``` A name without a `/` is a platform model and never touches your providers. The same naming works everywhere a model is accepted: [chat completions](/ai-gateway#chat-completions), the [Responses API](/ai-gateway#responses-api), [extraction](/extraction), [transcription](/transcription) and [agents](/agents). **Note — One provider per type:** The prefix names a provider **type**, not one of your providers. Configure at most one provider of each type per organization; to switch accounts, update the provider's API key instead of adding a second one. The API refuses a second provider of a type you already have: `POST /v1/providers` answers `409` and names the type. What goes wrong, and how the gateway answers: | Situation | Response | |-----------|----------| | Unknown prefix, e.g. `nope/gpt-5.1` | `400`, the message lists the valid prefixes | | Known prefix, but no provider of that type configured | `404` `Provider not configured. Please check your provider settings.` | | Platform model name that does not exist | `404`, the message lists the available platform models | ## Supported Provider Types | Provider | `provider` value | Prefix | `config` keys | |----------|------------------|--------|---------------| | OpenAI | `OPENAI` | `openai` | `baseUrl` (optional), `organizationId` (optional, your OpenAI organization), `useResponsesApi` (optional, `true` or `false`) | | Azure OpenAI | `AZURE_OPENAI` | `azure-openai` | `endpoint` (**required**, `https://`), `apiVersion` (optional), `extendedPromptCacheRetention` (optional, `true` or `false`) | | Anthropic | `ANTHROPIC` | `anthropic` | `baseUrl` (optional), `version` (optional, default `2023-06-01`) | | xAI | `XAI` | `xai` | `baseUrl` (optional) | | Mistral | `MISTRAL` | `mistral` | `baseUrl` (optional) | | Ollama | `OLLAMA` | `ollama` | `baseUrl` (**required**, your server, e.g. `https://ollama.example.com`) | - Every `baseUrl` and `endpoint` must be an `https://` URL on a public host; private, loopback and link-local addresses are refused with `400`. Leave `baseUrl` out to use the vendor's default endpoint; set it for a proxy. - For **Azure OpenAI**, the part after the prefix is your **deployment name**, not the model name. - For **Ollama**, the server must be reachable from 2kw.ai, and the models are the ones pulled on it. Ollama needs no key: leave `apiKey` out. - `useResponsesApi: true` makes 2kw.ai talk to OpenAI through the Responses API instead of chat completions. - `extendedPromptCacheRetention: true` asks Azure to keep this row's prompt cache for 24 hours instead of the few minutes it defaults to, for every deployment on this row named exactly `gpt-4.1`, `gpt-5.1` or `gpt-5.4` — other deployments on the same row are untouched. Azure stores key/value tensors of the prompt prefix on GPU-local storage on your own Azure account for up to 24 hours after last use — inside the data zone for Data Zone deployments, inside the region for Regional deployments; Microsoft makes no such statement for Global deployments. `gpt-5.5` deployments use extended retention by default whatever this setting says. The deployment name must match the model it serves, or Azure refuses the call. - Blank `config` values are dropped before the provider is saved. **Note — Google Vertex AI:** The API schema lists `VERTEX_AI`, but Vertex AI is not supported: creating or testing a provider of that type answers `400`. ## Test a Connection ### `POST /v1/providers/test` Checks credentials before you save them, or re-checks a saved provider. Nothing is stored. Whether the credentials work or not, the answer is `200`: read `success`. A `providerId` that does not exist answers `404`, and a provider type that is not supported answers `400`. ```bash curl -X POST https://api.2kw.ai/v1/providers/test \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "provider": "AZURE_OPENAI", "apiKey": "your-azure-key", "config": { "endpoint": "https://my-resource.openai.azure.com" } }' ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `provider` | string | Yes | Provider type, e.g. `OPENAI` | | `config` | object | Yes | The same `config` you would save | | `apiKey` | string | No | Key to test. Leave it out and send `providerId` to test a saved provider's stored key | | `providerId` | string | No | A saved provider whose stored key is used when `apiKey` is empty | **Success** ```json { "success": true, "models": ["my-gpt-deployment", "my-embedding-deployment"] } ``` **Failure** ```json { "success": false, "message": "Authentication failed: 401" } ``` On success, `models` lists what the credentials can reach; for Azure OpenAI these are your deployment names. On failure, `message` says why: a missing key, the HTTP status the vendor answered, or the connection error. ## Create a Provider ### `POST /v1/providers` ```bash curl -X POST https://api.2kw.ai/v1/providers \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "name": "Production OpenAI", "provider": "OPENAI", "apiKey": "sk-proj-...", "config": {} }' ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name, 1–255 characters, unique within the organization | | `provider` | string | Yes | Provider type from the [table above](#supported-provider-types) | | `apiKey` | string | Yes, except for `OLLAMA` | The vendor API key. Stored encrypted, never returned | | `config` | object | Yes | Type-specific settings; `{}` when you need none | Answers `201` with the provider: ```json { "id": "0b8e6f3c-...", "name": "Production OpenAI", "provider": "OPENAI", "config": {}, "organizationId": "org_...", "createdAt": "2026-09-27T10:15:00Z", "lastModifiedAt": "2026-09-27T10:15:00Z" } ``` The response also carries `version` and `aliases`; treat them as informational. A `config` that breaks the rules for its type (an Azure provider without `endpoint`, a `baseUrl` that is not `https://` on a public host, a `useResponsesApi` or `extendedPromptCacheRetention` that is not `true` or `false`) is refused with `400` and the message says what to fix. A `name` the organization already uses answers `409`, as does a second provider of the same type. A `4xx` will not succeed on retry: fix the request. ## Update a Provider ### `PATCH /v1/providers/{id}` Send only what changes: - `name` renames the provider; a name another provider of the organization already uses answers `409`. - `apiKey` replaces the stored key; leave it out to keep the current one. - `config` **replaces the whole** stored `config`, it is not merged. Send every key you want to keep. - `provider` cannot be changed. To move to another type, create a new provider and delete the old one. ```bash curl -X PATCH https://api.2kw.ai/v1/providers/0b8e6f3c-... \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "apiKey": "sk-proj-new-key" }' ``` ## Read Providers ### `GET /v1/providers` A page of providers. Query parameters: `search` (matches the name, case-insensitive), `page`, `size` and `sort`. ### `GET /v1/providers/all` Every provider of the organization as a plain array, without paging. ### `GET /v1/providers/{id}` One provider. An id that does not exist answers `404`. ## List Available Models ### `GET /v1/providers/models` The platform models first, then your providers' models under their `provider/model` names, fetched live from each vendor. ```json { "object": "list", "data": [ { "id": "gpt-5.1", "object": "model", "owned_by": "system" }, { "id": "openai/gpt-5.1", "object": "model", "owned_by": "openai" }, { "id": "anthropic/claude-sonnet-4-5-20250929", "object": "model", "owned_by": "anthropic" } ] } ``` `owned_by` is `system` for platform models and the prefix for yours. Use the `id` as the `model` of a request. The list is a convenience, not the full set of names you can call: - **Azure OpenAI** deployments are not listed. Call them by deployment name; `POST /v1/providers/test` shows the names. - **OpenAI** is filtered to chat models (`gpt-`, `o1-`, `o3-`, `o4-`, `chatgpt-`); fine-tunes and other models are left out. - A vendor that cannot be reached, or refuses the key, contributes no models instead of failing the call. A model missing from this list can still work. For embedding models of one provider, see [Which embedding models fit](/knowledge-bases#which-embedding-models-fit). A knowledge base no longer needs a provider of yours at all: it can embed through the platform's [built-in embedding models](/knowledge-bases#built-in-embedding-models). ## Delete a Provider ### `DELETE /v1/providers/{id}` Answers `204`. From then on, every request that names this provider's prefix answers `404` until you configure a provider of that type again, so move agents, prompts and schemas that use it to another model first. ## Permissions | Operation | Role needed | |-----------|-------------| | List, get, list models | Viewer, Member, Admin or Owner | | Create, update, test | Member, Admin or Owner | | Delete | Admin or Owner | An [API key](/api-keys) acts with the role it was created with. --- Source URL: https://docs.2kw.ai/prompts # Prompts Version-controlled prompt templates with variables, labels, and built-in testing —manage your AI prompts like code. **Note — Snippet setup:** The Python and TypeScript snippets on this page set `BASE_URL` to the host of the [base URL](/api-keys#base-url) and read your key from `AI_2KW_API_KEY`. Python: `import os, requests`, `BASE_URL = "https://api.2kw.ai"`, `api_key = os.environ["AI_2KW_API_KEY"]` and `headers = {"Authorization": f"Bearer {api_key}"}`. TypeScript: `const BASE_URL = "https://api.2kw.ai"` and `const apiKey = process.env.AI_2KW_API_KEY`. ## How It Works You create prompt templates with `{{variable}}` placeholders, commit immutable versions, and use labels like `production` or `staging` to control which version your application resolves at runtime. When you're ready, test prompts against any model directly from the API. The workflow is: **create prompt → commit versions → assign labels → resolve at runtime**. **Note — Two prompt types:** 2kw.ai supports **Text** prompts (a single template string) and **Chat** prompts (structured messages with roles). The type is set at creation and cannot be changed. ## Prompt Types ### Text Prompts A plain text template with `{{variable}}` placeholders: ``` You are an expert at {{domain}}. Analyze the following text and provide a summary focused on {{focus_area}}. Text: {{input_text}} ``` ### Chat Prompts A structured array of messages, each with a role and content. Variables work inside message content: ```json [ { "role": "system", "content": "You are a {{role}} assistant specialized in {{domain}}." }, { "role": "user", "content": "{{user_input}}" } ] ``` Chat prompts also support **placeholder** messages —named slots that can be filled programmatically: ```json [ { "role": "system", "content": "You are a helpful assistant." }, { "type": "placeholder", "name": "context" }, { "role": "user", "content": "{{question}}" } ] ``` ## Creating a Prompt ### `POST /v1/prompts` **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/prompts \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "name": "summarize", "description": "Summarize text with a focus area", "type": "TEXT" }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/prompts", headers=headers, json={ "name": "summarize", "description": "Summarize text with a focus area", "type": "TEXT" } ) prompt = response.json() print(prompt["id"]) # Use this to create versions ``` **TypeScript** ```typescript const response = await fetch( `${BASE_URL}/v1/prompts`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ name: "summarize", description: "Summarize text with a focus area", type: "TEXT", }), } ); const prompt = await response.json(); console.log(prompt.id); ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Unique within the organization | | `description` | string | No | What this prompt does | | `type` | string | Yes | `TEXT` or `CHAT` —immutable after creation | ## Version Control Every prompt change creates a new **immutable version**. Versions can't be edited after creation — this gives you a full audit trail of every change. ### Committing a Version ### `POST /v1/prompts/{promptId}/versions` ```json { "content": "You are an expert at {{domain}}. Summarize: {{input_text}}", "config": { "temperature": 0.7, "maxTokens": 500 }, "changeDescription": "Added domain variable for specialization" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `content` | string or array | Yes | Template string for TEXT, message array for CHAT | | `config` | object | No | Model config (temperature, maxTokens, etc.) | | `changeDescription` | string | No | What changed in this version | Each new version auto-increments the version number and updates the `latest` label. ### Version Operations | Method | Endpoint | What it does | | --- | --- | --- | | `GET` | `/v1/prompts/{promptId}/versions?page=0&size=20` | List the full history, paginated | | `GET` | `/v1/prompts/{promptId}/versions/latest` | The newest version; `404` while the prompt has none | | `GET` | `/v1/prompts/{promptId}/versions/{versionId}` | One version, read-only | | `PUT` | `/v1/prompts/{promptId}/versions/{versionId}/activate` | Re-activate a historical version: creates a new version as a copy, with `changeDescription` `Reactivated from version N`, and moves `latest` to it | | `DELETE` | `/v1/prompts/{promptId}/versions/{versionId}` | Deactivate a version you don't want used (soft delete, `isActive: false`); answers `204`. Needs the admin role | **Note — Re-activation creates a copy:** Re-activating version 3 doesn't revert — it creates version N+1 with the same content. The original stays untouched. ## Labels Labels are named pointers to specific versions. They decouple your application code from version numbers, enabling controlled rollouts. ### How Labels Work Every prompt gets a `latest` label automatically, updated on each new version. You create custom labels for deployment stages: | Label | Points to | Purpose | |-------|-----------|---------| | `latest` | v5 (auto) | Always the newest version | | `production` | v3 | What your users see | | `staging` | v5 | What you're testing | | `canary` | v4 | Gradual rollout | Your application resolves prompts by name + label —when you're ready to promote, just move the label pointer. ### Managing Labels ### `POST /v1/prompts/{promptId}/labels` **Create a label:** ```json { "name": "production", "promptVersionId": "version-uuid" } ``` **Update a label** to point to a different version: ### `PUT /v1/prompts/{promptId}/labels/{labelName}` ```json { "promptVersionId": "new-version-uuid" } ``` Label names must be lowercase alphanumeric with hyphens (e.g., `production`, `staging`, `v2-rollback`). The system `latest` label cannot be deleted. ## Variables Variables use the `{{variableName}}` syntax and are automatically detected from prompt content. ### How Variables Work When you compile or test a prompt, 2kw.ai scans the content for `{{...}}` patterns and returns the list of detected variables. You provide values as a key-value map: ```json { "variables": { "domain": "financial analysis", "input_text": "Q4 revenue grew 15% year-over-year...", "focus_area": "key metrics and trends" } } ``` Variables that aren't provided keep their `{{placeholder}}` syntax in the output —so you can do partial compilation. ### Prompt References Prompts can reference other prompts using the `@@@prompt:...@@@` syntax: ``` @@@prompt:name=system-context|label=production@@@ Now analyze: {{input_text}} ``` References are resolved recursively (up to 5 levels deep) with circular reference detection. ## Resolving Prompts ### `GET /v1/prompts/{promptId}/resolve` This is the main runtime endpoint. Resolve a prompt by ID and optional label to get the compiled content ready for use: **Request** **cURL** ```bash curl "https://api.2kw.ai/v1/prompts/{promptId}/resolve?label=production" \ -H "Authorization: Bearer sk_your_api_key" ``` **Python** ```python response = requests.get( f"{BASE_URL}/v1/prompts/{prompt_id}/resolve", headers=headers, params={ "label": "production" } ) resolution = response.json() print(resolution["content"]) # The prompt template print(resolution["variables"]) # ["domain", "input_text", "focus_area"] print(resolution["versionNumber"]) # 3 ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `label` | string | No | Label to resolve (defaults to `latest`) | **Response:** ```json { "promptId": "uuid", "promptName": "summarize", "type": "TEXT", "versionId": "uuid", "versionNumber": 3, "content": "You are an expert at {{domain}}. Summarize: {{input_text}}", "config": { "temperature": 0.7 }, "label": "production", "variables": ["domain", "input_text"] } ``` ## Compiling Prompts ### `POST /v1/prompts/{promptId}/compile` Compile substitutes variables and resolves prompt references without executing against a model: ```json { "label": "production", "variables": { "domain": "financial analysis", "input_text": "Q4 revenue grew 15%..." } } ``` **Response:** ```json { "compiledContent": "You are an expert at financial analysis. Summarize: Q4 revenue grew 15%...", "variables": ["domain", "input_text"], "versionNumber": 3, "versionId": "uuid" } ``` ## Testing Prompts ### `POST /v1/prompts/{promptId}/test` Run a compiled prompt against any AI model to validate output before deploying: **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/prompts/{promptId}/test \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "model": "gpt-5.1", "label": "staging", "variables": { "domain": "financial analysis", "input_text": "Q4 revenue grew 15% year-over-year" } }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/prompts/{prompt_id}/test", headers=headers, json={ "model": "gpt-5.1", "label": "staging", "variables": { "domain": "financial analysis", "input_text": "Q4 revenue grew 15% year-over-year" } } ) result = response.json() print(result["response"]) print(f"Tokens: {result['inputTokens']} in / {result['outputTokens']} out") print(f"Duration: {result['processingDurationMs']}ms") ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `model` | string | Yes | Model to test with (e.g., `gpt-5.1` or `openai/gpt-5.1`) | | `content` | string or array | No | Inline content to test instead of a saved version, in the same shape as a version's `content`. When set, `versionId` and `label` are not used | | `versionId` | string | No | Specific version to test | | `label` | string | No | Label to resolve (defaults to `latest`) | | `variables` | object | No | Key-value map of variable values | **Response:** ```json { "success": true, "response": "Based on the financial data, Q4 showed strong growth...", "inputTokens": 120, "outputTokens": 85, "processingDurationMs": 1250, "modelUsed": "gpt-5.1" } ``` The response includes token counts and processing time so you can estimate costs before deploying to production. ## Use Cases - **System prompts** —version-controlled system instructions for chatbots and agents - **Extraction templates** —reusable prompts for structured data extraction - **Multi-language** —maintain translated prompt variants with labels (`en`, `de`, `fr`) - **A/B testing** —use labels to route traffic between prompt versions - **Audit compliance** —immutable version history for regulated industries - **Prompt chaining** —compose complex workflows with prompt references --- Source URL: https://docs.2kw.ai/transcription # Audio Transcription Convert audio to text with OpenAI-compatible transcription endpoints, through the same gateway. ## Overview The transcription API follows the OpenAI `audio/transcriptions` format exactly. If you're already using the OpenAI SDK for transcription, just change the `base_url` and you're done. ### What you need - An audio file (supported: `flac`, `mp3`, `mp4`, `mpeg`, `mpga`, `m4a`, `ogg`, `wav`, `webm`) - A configured provider that supports transcription (OpenAI or Azure OpenAI) - Max file size: **25 MB** ## Transcribe Audio ### `POST /v1/audio/transcriptions` Send your audio file as `multipart/form-data`: **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/audio/transcriptions \ -H "Authorization: Bearer sk_your_api_key" \ -F "file=@meeting.mp3" \ -F "model=whisper-1" \ -F "language=en" ``` **Python** ```python from openai import OpenAI client = OpenAI( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1" ) audio_file = open("meeting.mp3", "rb") transcript = client.audio.transcriptions.create( model="whisper-1", file=audio_file ) print(transcript.text) ``` **TypeScript** ```typescript import OpenAI from "openai"; import fs from "fs"; const client = new OpenAI({ apiKey: "sk_your_api_key", baseURL: "https://api.2kw.ai/v1", }); const transcript = await client.audio.transcriptions.create({ model: "whisper-1", file: fs.createReadStream("meeting.mp3"), }); console.log(transcript.text); ``` ### Request Parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | file | Yes | Audio file to transcribe | | `model` | string | Yes | Platform model name (e.g., `whisper-1`) or `provider/model` for BYOK | | `language` | string | No | ISO-639-1 code (`en`, `de`, `fr`, etc.) | | `prompt` | string | No | Guide the transcription style | | `response_format` | string | No | `json`, `text`, `srt`, `verbose_json`, `vtt` (default: `json`) | | `temperature` | string | No | Sampling temperature from 0 to 1 as a decimal, e.g. `0.2`. A value that is not a number is ignored | | `timestamp_granularities[]` | array | No | `word` and/or `segment` | **Note — Improve accuracy with language:** Setting `language` explicitly improves accuracy and speed: the model doesn't have to auto-detect it. ## Response Formats Choose the output format that fits your use case: ### JSON (default) ```json { "text": "Welcome to today's meeting. We'll be discussing the Q4 roadmap..." } ``` ### Verbose JSON Get word-level timestamps for precise alignment: ```bash curl ... -F "response_format=verbose_json" \ -F "timestamp_granularities[]=word" \ -F "timestamp_granularities[]=segment" ``` ```json { "task": "transcribe", "language": "en", "duration": 45.2, "text": "Hello and welcome...", "words": [ {"word": "Hello", "start": 0.0, "end": 0.4}, {"word": "and", "start": 0.5, "end": 0.6}, {"word": "welcome", "start": 0.7, "end": 1.1} ] } ``` ### SRT / VTT Subtitles For video captioning, use `response_format=srt` for SRT or `response_format=vtt` for WebVTT. ### Generate Subtitle Files **Generate subtitles** **Python** ```python from openai import OpenAI client = OpenAI( api_key="sk_your_api_key", base_url="https://api.2kw.ai/v1" ) srt = client.audio.transcriptions.create( model="whisper-1", file=open("video.mp3", "rb"), response_format="srt" ) with open("subtitles.srt", "w") as f: f.write(srt) ``` **cURL** ```bash curl -X POST https://api.2kw.ai/v1/audio/transcriptions \ -H "Authorization: Bearer sk_your_api_key" \ -F "file=@video.mp3" \ -F "model=whisper-1" \ -F "response_format=srt" > subtitles.srt ``` ## Supported Models The platform provides `whisper-1` out of the box on all tiers. With BYOK (Team, Business and Enterprise plans), you can route through your own provider: | Provider | Model | Notes | |----------|-------|-------| | OpenAI | `openai/whisper-1` | Your own OpenAI Whisper API key | | Azure OpenAI | `azure-openai/{deployment}` | Your Azure Whisper deployment | ## Errors Errors use the OpenAI error format, with the type `invalid_request_error` (`rate_limit_error` for a `429`), so the OpenAI SDK raises them as usual: ```json { "error": { "message": "Unsupported audio format: pdf. Supported formats: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm", "type": "invalid_request_error" } } ``` | Status | `code` | Cause | |--------|--------|-------| | `400` | — | The file extension is not one of the supported formats, or `response_format` is not one of the supported values | | `400` | `audio_duration_unknown` | The built-in `whisper-1` could not read the audio's duration before transcribing | | `400` | `missing_required_part` | The request has no `file` or no `model` part | | `413` | — | The file is larger than 25 MB | | `429` | `rate_limit_exceeded` | The transcription provider is receiving too many requests; retry after the `Retry-After` header's seconds | | `502` | — | The transcription provider failed; retry later | ### Unsupported format The format is checked by file extension, so make sure the file name ends in one of `flac`, `mp3`, `mp4`, `mpeg`, `mpga`, `m4a`, `ogg`, `wav`, or `webm`. To convert other audio or video, use a tool such as ffmpeg: ```bash ffmpeg -i input.m4v output.mp3 ``` ### Built-in model: audio duration unknown The built-in `whisper-1` needs the audio's duration before it transcribes, and reads it from `mp3`, `mp4`, `m4a`, and `wav` files. Files in other formats, such as `ogg`, `webm`, or `flac`, can be refused with `400` and the code `audio_duration_unknown`. Convert them to one of those four formats first. A [BYOK](#supported-models) model accepts every supported format. ### File too large Files over 25 MB are refused with `413`. Make the file smaller, or split it and transcribe the parts one by one: ```bash # Lower the bitrate and mix down to mono ffmpeg -i input.wav -ac 1 -b:a 64k output.mp3 # Split into 10-minute parts ffmpeg -i input.mp3 -f segment -segment_time 600 output_%03d.mp3 ``` --- Source URL: https://docs.2kw.ai/extraction # Data Extraction Define a schema once, then extract structured data from any text —consistently, every time. **Note — Snippet setup:** The Python and TypeScript snippets on this page set `BASE_URL` to the host of the [base URL](/api-keys#base-url) and read your key from `AI_2KW_API_KEY`. Python: `import os, requests`, `BASE_URL = "https://api.2kw.ai"`, `api_key = os.environ["AI_2KW_API_KEY"]` and `headers = {"Authorization": f"Bearer {api_key}"}`. TypeScript: `const BASE_URL = "https://api.2kw.ai"` and `const apiKey = process.env.AI_2KW_API_KEY`. ## Quick Start #### Step 1 — Define a schema Create a schema — a standard JSON Schema describing the fields you want to extract: ```json { "type": "object", "properties": { "name": { "type": "string" }, "email": { "type": "string", "format": "email" }, "phone": { "type": "string" } }, "required": ["name"] } ``` #### Step 2 — Commit and label Hit **Commit** to lock in an immutable version. The `latest` label updates automatically — create custom labels like `production` to pin versions for consumers. #### Step 3 — Extract ```bash curl -X POST https://api.2kw.ai/v1/extractions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "schemaId": "your-schema-id", "inputText": "Contact: John Doe, john@example.com, 555-1234", "model": "gpt-5.1" }' ``` Response: ```json { "status": "COMPLETED", "result": { "structuredOutput": { "name": "John Doe", "email": "john@example.com", "phone": "555-1234" } } } ``` Schema in, structured data out. The rest of this page covers running extractions, re-running and listing them; [Schemas](/extraction/schemas) covers versions and labels, and [Large documents](/extraction/large-documents) how long inputs are handled. ## How It Works 2kw.ai takes a standard JSON Schema definition and uses it to extract structured output from unstructured text. You define the fields, types, and constraints — 2kw.ai returns validated, schema-conformant JSON. The workflow is: **define schema → commit version → send text → receive structured output**. **Note — Schemas use JSON Schema:** If you've used JSON Schema before, you already know the format. If not —it's just a way to describe what fields you expect, their types, and which ones are required. ## Running Extractions ### Synchronous ### `POST /v1/extractions` The standard extraction endpoint. Send text and, for inputs that fit a synchronous run, get the structured data back in the same response. Larger inputs are handed to background processing automatically; see the responses below the example. Send `schemaId`, `model` (a platform model name such as `gpt-5.1`, or `provider/model` for your own provider) and the input: `inputText`, up to 10 `inputImages`, or both. `schemaVersionId` pins a version; without it the latest active one runs. [Every field: API reference](https://app.2kw.ai/docs/api-reference). **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/extractions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "schemaId": "your-schema-id", "inputText": "Invoice #INV-2024-001 from Acme Corp. Total: $1,250.00", "model": "gpt-5.1" }' ``` **Python** ```python import requests response = requests.post( f"{BASE_URL}/v1/extractions", headers=headers, json={ "schemaId": "your-schema-id", "inputText": "Invoice #INV-2024-001 from Acme Corp. Total: $1,250.00", "model": "gpt-5.1" } ) result = response.json() print(result["result"]["structuredOutput"]) ``` **TypeScript** ```typescript const response = await fetch( `${BASE_URL}/v1/extractions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ schemaId: "your-schema-id", inputText: "Invoice #INV-2024-001 from Acme Corp. Total: $1,250.00", model: "gpt-5.1", }), } ); const data = await response.json(); console.log(data.result.structuredOutput); ``` **Responses.** Always read `status` before you read the result: | HTTP status | `status` | Meaning | |-------------|----------|---------| | `201 Created` | `COMPLETED` | The extraction ran; `result.structuredOutput` holds the data | | `201 Created` | `FAILED` | The extraction ran and failed; `errorMessage` says why | | `202 Accepted` | `PENDING` | The input was too large for a synchronous run and was queued in the background. The response carries a `Location` header and `Retry-After: 5`; poll it as described under [Asynchronous](#asynchronous) | A failed extraction is still a `201`, so checking the HTTP status alone is not enough: ```typescript const response = await fetch(`${BASE_URL}/v1/extractions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ schemaId, inputText, model }), }); if (!response.ok) { throw new Error(`Request rejected: ${response.status}`); } const data = await response.json(); if (data.status === "COMPLETED") { console.log(data.result.structuredOutput); } else if (data.status === "FAILED") { throw new Error(data.errorMessage ?? "Extraction failed"); } else { // PENDING: poll GET /v1/extractions/{id} until COMPLETED or FAILED console.log("Queued, poll", response.headers.get("Location")); } ``` ### Extracting from Images You can also extract structured data from images (up to 10 per request). Each image must be base64-encoded with its MIME type: **Image extraction** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/extractions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "schemaId": "your-schema-id", "inputImages": [ { "data": "iVBORw0KGgoAAAANSUhEUg...", "mimeType": "image/png" } ], "model": "gpt-5.1" }' ``` **Python** ```python import base64 import requests with open("invoice.png", "rb") as f: image_data = base64.b64encode(f.read()).decode() response = requests.post( f"{BASE_URL}/v1/extractions", headers=headers, json={ "schemaId": "your-schema-id", "inputImages": [ { "data": image_data, "mimeType": "image/png" } ], "model": "gpt-5.1" } ) result = response.json() print(result["result"]["structuredOutput"]) ``` Supported image MIME types: `image/png`, `image/jpeg`, `image/gif`, `image/webp`. You can combine `inputText` and `inputImages` in a single request. ### Asynchronous ### `POST /v1/extractions/async` For large inputs, submit an extraction for background processing. You get back a `202 Accepted` with a `Location` header to poll: ```bash HTTP/1.1 202 Accepted Location: /v1/extractions/{id} Retry-After: 5 ``` Poll the `Location` URL until `status` changes from `PENDING`/`PROCESSING` to `COMPLETED` or `FAILED`. ### Estimate Tokens First ### `POST /v1/extractions/estimate` Before running an extraction, you can estimate the cost. Send the same schema and input text you'd use for a real extraction: The request takes `schemaId`, `inputText` and optionally `schemaVersionId`. [Every field: API reference](https://app.2kw.ai/docs/api-reference). **Response:** ```json { "inputTokens": 4200, "estimatedOutputTokens": 350, "strategy": "SINGLE_SHOT" } ``` The `strategy` field tells you which processing path will be used (`SINGLE_SHOT`, `CHUNKED`, or `ASYNC`); see [Large documents](/extraction/large-documents#extraction-strategies). ### Extraction Response The full extraction response includes `structuredOutput`, process `metadata`, and `resultMetadata` with dedup stats and grounding scores: ```json { "id": "extraction-uuid", "status": "COMPLETED", "strategy": "CHUNKED", "result": { "structuredOutput": { "company": "ACME GmbH", "parts": [ { "sourceFile": "gear.geo", "material": "1.4301", "quantity": 5 }, { "sourceFile": "shaft.geo", "material": "Steel", "quantity": 2 } ] }, "metadata": { "inputTokens": 12500, "outputTokens": 340, "durationMs": 4200, "model": "gpt-5.1", "chunksProcessed": 2 }, "resultMetadata": { "dedup": { "applied": true, "itemsRemoved": 1, "itemsMerged": 0, "resequenced": true, "originalItemCount": 3, "finalItemCount": 2 }, "postValidation": { "applied": true, "hallucinatedItemsRemoved": 0, "requiredFieldsRestored": 0, "removedItemKeys": [] }, "scoring": { "methods": ["grounding"], "grounding": { "score": 0.91 }, "fields": { "company": { "grounding": { "score": 1.0, "basis": "exact_match" } } }, "items": { "parts[0]": { "grounding": { "score": 0.92 }, "fields": { "sourceFile": { "grounding": { "score": 1.0, "basis": "exact_match" } }, "material": { "grounding": { "score": 1.0, "basis": "exact_match" } }, "quantity": { "grounding": { "score": 0.5, "basis": "unverifiable" } } } }, "parts[1]": { "grounding": { "score": 0.95 }, "fields": { "sourceFile": { "grounding": { "score": 1.0, "basis": "exact_match" } }, "material": { "grounding": { "score": 0.9, "basis": "normalized_match" } }, "quantity": { "grounding": { "score": 0.5, "basis": "unverifiable" } } } } } } } } } ``` The example is shortened to the grounding scores. A current result also carries `resultMetadata.schemaValidation` ([Schema Validation](/extraction/quality#schema-validation)), `resultMetadata.rules` ([Business Rules](/extraction/quality#business-rules)) and the confidence fields shown under [Composite Confidence](/extraction/quality#composite-confidence). ## Re-running Extractions ### `POST /v1/extractions/{id}/rerun` Need to retry an extraction with the same config? Hit the rerun endpoint. Creates a new extraction —the original stays untouched. The re-run copies the original's schema version, input text and model, and it runs the same way the original did: an extraction that was queued asynchronously is queued again and comes back `PENDING`, a synchronous one is executed inline and comes back with its result. Not every extraction can be re-run: | Response | When | |----------|------| | `404 Not Found` | No such extraction in your organization | | `409 Conflict` | The extraction is still `PENDING` or `PROCESSING` | | `409 Conflict` | The extraction was vision-only —images are not stored and cannot be replayed | | `409 Conflict` | The schema version it ran against has since been deleted | `COMPLETED` and `FAILED` extractions are eligible. Each re-run is billed as a new extraction. ### `POST /v1/extractions/rerun` Re-run up to 100 extractions in one call. Select them either by ID or with the filters of `GET /v1/extractions` (`status`, `schemaVersionId`, `search`), not both: ```bash curl -X POST https://api.2kw.ai/v1/extractions/rerun \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"status": "FAILED", "schemaVersionId": ""}' ``` The call answers `202 Accepted` with one outcome per selected extraction. Every eligible one is queued as a new extraction with status `PENDING`, whatever mode the original ran in; poll its `rerunId`. The rules above decide eligibility, but an ineligible extraction does not fail the call: it comes back `SKIPPED` with a `reason`, an unknown ID comes back `NOT_FOUND`, and the rest are queued all the same. ```json { "queued": 1, "skipped": 1, "results": [ { "extractionId": "ext_1", "outcome": "QUEUED", "rerunId": "ext_9", "reason": null }, { "extractionId": "ext_2", "outcome": "SKIPPED", "rerunId": null, "reason": "Extraction is still in progress. Wait for it to finish before re-running it." } ] } ``` | Response | When | |----------|------| | `400 Bad Request` | Neither or both of `ids` and a filter were given, or the filter matches more than 100 extractions | | `402 Payment Required` | The batch needs more usage than the organisation has left; nothing was queued | ## Listing and Filtering ### `GET /v1/extractions` Filter with `status` (`PENDING`, `PROCESSING`, `COMPLETED` or `FAILED`), `schemaVersionId` and `search` (by model name). Page with `page` (0-based) and `size` (default 20). [Every field: API reference](https://app.2kw.ai/docs/api-reference). ## Use Cases - **Contact extraction** —names, emails, phone numbers from unstructured text - **Invoice processing** —invoice numbers, dates, amounts, line items from documents - **Resume parsing** —skills, experience, education from CVs - **Document analysis** —key fields from contracts, reports, forms - **Data entry automation** —turn free-text notes into structured database records --- Source URL: https://docs.2kw.ai/extraction/schemas # Extraction Schemas A schema describes the fields you want back. Create it once, version it, label the version your callers get, and test it before you rely on it. **Note — Snippet setup:** The Python and TypeScript snippets on this page set `BASE_URL` to the host of the [base URL](/api-keys#base-url) and read your key from `AI_2KW_API_KEY`. Python: `import os, requests`, `BASE_URL = "https://api.2kw.ai"`, `api_key = os.environ["AI_2KW_API_KEY"]` and `headers = {"Authorization": f"Bearer {api_key}"}`. TypeScript: `const BASE_URL = "https://api.2kw.ai"` and `const apiKey = process.env.AI_2KW_API_KEY`. ## Schema Management Before you can extract anything, you need a schema. Schemas are organization-scoped and support full version control. ### Creating a Schema A schema takes two calls: create the schema with a name and an optional description, then commit its JSON Schema definition as the first version. ### `POST /v1/schemas` ```bash curl -X POST https://api.2kw.ai/v1/schemas \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "name": "invoice", "description": "Supplier invoices" }' ``` The response carries the schema's `id`. The JSON Schema is not part of this call: the API reference lists a `content` field on the schema, but a value sent there is not stored. ### `POST /v1/schemas/{schemaId}/versions` ```json { "jsonSchema": { "type": "object", "properties": { "company": { "type": "string" }, "invoice_number": { "type": "string" }, "total": { "type": "number" }, "line_items": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "quantity": { "type": "integer" }, "price": { "type": "number" } } } } }, "required": ["company", "invoice_number"] }, "changeDescription": "First version" } ``` `jsonSchema` is the JSON Schema definition; one that is not a valid JSON Schema is refused with `400`. `changeDescription` says what changed. [Every field: API reference](https://app.2kw.ai/docs/api-reference). The response (`201`) is the new version with its `id`, `versionNumber` and `jsonSchema`, and the `latest` label now points to it. Every later change is another call to this endpoint. ### Version Control Every time you change your schema, you create a new **version**. Versions are immutable —this gives you a full audit trail of every change. You can: - **Commit** new versions with a change description - **Deactivate** versions you don't want used anymore - **Re-activate** a historical version (creates a new version as a copy, preserving the audit trail) - **Pin** extractions to a specific version for reproducibility Each new version auto-increments the version number and updates the `latest` label. **Note — Re-activation creates a copy:** Re-activating version 3 doesn't revert —it creates version N+1 with the same content. The original stays untouched. ### Labels Labels are named pointers to specific schema versions. They decouple your application code from version numbers, enabling controlled rollouts. #### How Labels Work Every schema gets a `latest` label automatically, updated on each new version. You create custom labels for deployment stages: | Label | Points to | Purpose | |-------|-----------|---------| | `latest` | v5 (auto) | Always the newest version | | `production` | v3 | What your API consumers use | | `staging` | v5 | What you're testing | Your application resolves schemas by ID + label —when you're ready to promote, just move the label pointer. #### Managing Labels ### `POST /v1/schemas/{schemaId}/labels` **Create a label:** ```json { "name": "production", "schemaVersionId": "version-uuid" } ``` **Update a label** to point to a different version: ### `PUT /v1/schemas/{schemaId}/labels/{labelName}` ```json { "schemaVersionId": "new-version-uuid" } ``` Label names must be lowercase alphanumeric with hyphens (e.g., `production`, `staging`, `v2-rollback`). The system `latest` label cannot be deleted. ### Resolving Schemas ### `GET /v1/schemas/{schemaId}/resolve` Resolve a schema by ID and optional label to get the JSON Schema definition for a specific version: **Request** **cURL** ```bash curl "https://api.2kw.ai/v1/schemas/{schemaId}/resolve?label=production" \ -H "Authorization: Bearer sk_your_api_key" ``` **Python** ```python response = requests.get( f"{BASE_URL}/v1/schemas/{schema_id}/resolve", headers=headers, params={ "label": "production" } ) resolution = response.json() print(resolution["jsonSchema"]) # The JSON Schema definition print(resolution["versionNumber"]) # 3 ``` The `label` parameter names the label to resolve and defaults to `latest`. **Response:** ```json { "schemaId": "uuid", "schemaName": "invoice-schema", "versionId": "uuid", "versionNumber": 3, "jsonSchema": { "type": "object", "properties": { "company": { "type": "string" }, "total": { "type": "number" } } }, "label": "production" } ``` ### Validate Before Committing ### `POST /v1/schemas/{schemaId}/validate` Check if a schema is valid before you commit it. Returns structured errors and warnings: ```json { "jsonSchema": { "type": "object", "properties": { "name": { "type": "string" } } } } ``` The response has three fields: `valid`, `errors` and `warnings`. **Errors** mean the schema document itself cannot be used. The same checks run when you commit a version, so `POST /v1/schemas/{schemaId}/versions` answers `400` for a document that fails them. The typical errors are: - `JSON Schema must be a JSON object` - `JSON Schema must contain at least one schema keyword (e.g., type, properties, $schema)` - `Invalid JSON Schema: ` Check the JSON syntax, make sure the document is an object with at least one JSON Schema keyword, and validate it against the JSON Schema 2020-12 specification. Errors are about the schema, never about extraction output. An extraction whose output does not match its schema does **not** fail: there is no failed status and no retry. The mismatches are recorded on the result instead, see [Schema Validation](/extraction/quality#schema-validation). #### Schema-safety warnings **Warnings** are advisory and never block saving a version: `valid` stays `true`. The validate endpoint returns them on request, and the schema editor in the console lints live while you type. Each warning names a stable rule id, a JSON pointer to the exact location in your schema, a message and, where one applies, a suggested fix. The editor offers a one-click fix for the first three rules. | Rule | Trigger | Why it is risky | Fix | |------|---------|-----------------|-----| | `required-non-nullable` | A required field declares a `type` that does not include `null` | The model must emit *some* value even when the document has none | Allow null (`"type": ["string", "null"]`) or remove the field from `required` | | `required-enum` | A required field has an `enum` that contains neither `"insufficient_evidence"` nor `null` | The model must pick a listed value even when the document contains none | Add `"insufficient_evidence"` or `null` to the enum, or remove the field from `required` | | `min-items` | `minItems` greater than 0 on any array | Forces the model to invent entries for documents that have none | Remove `minItems` and check cardinality after extraction instead, for example with a [business rule](/extraction/quality#business-rules) | | `provider-rewrite` | The schema uses constraints your provider does not enforce | Constraints look enforced but are ignored at generation time, or the schema is rejected | Informational, no automatic fix. See *Provider enforcement* below | A required field that carries an `enum` is reported as `required-enum`, not `required-non-nullable`. A field without a `type` keyword, or one whose `anyOf`/`oneOf` includes a `{"type": "null"}` branch, is already nullable and is not flagged. The pointer targets the property for `required-non-nullable` and `required-enum`, and the offending keyword for `min-items` and `provider-rewrite`. A `provider-rewrite` warning about prompt-only providers uses the empty pointer, which means the whole schema. **Example.** This schema requires two fields, one of them an enum without an escape value: ```json { "jsonSchema": { "type": "object", "properties": { "invoice_number": { "type": "string" }, "status": { "type": "string", "enum": ["paid", "unpaid"] } }, "required": ["invoice_number", "status"] } } ``` With only the platform's default provider in play, the response is: ```json { "valid": true, "errors": [], "warnings": [ { "rule": "required-non-nullable", "pointer": "/properties/invoice_number", "message": "Required field 'invoice_number' cannot be null. When the document does not contain this value, models fabricate one rather than fail.", "suggestion": "Allow null via \"type\": [\"string\", \"null\"], or remove 'invoice_number' from \"required\"." }, { "rule": "required-enum", "pointer": "/properties/status", "message": "Required enum field 'status' forces the model to pick a listed value even when the document contains none — the highest-risk fabrication construct.", "suggestion": "Add an escape value \"insufficient_evidence\" to the enum, or remove 'status' from \"required\"." } ] } ``` An organization that has an Anthropic or Mistral provider configured also gets a `provider-rewrite` warning at the empty pointer. #### Escape values Make fields optional and nullable by default. Require a field only when a document without it should count as unprocessable. For enums, include the escape value `insufficient_evidence` so the model has a legal way to say "not in the document". A `null` entry in the enum counts as an escape too. An escape value reads as a deliberate absence; a fabricated value looks exactly like a real one. #### Provider enforcement Extraction runs on the provider behind the model you choose, and providers differ in what they enforce while generating: - **Anthropic and Mistral** receive the schema as prompt text, without constrained decoding. Required fields, enums and every other constraint are advisory. - **OpenAI, Azure OpenAI and xAI** receive the schema as a structured-output `json_schema`. Constraint keywords outside the enforced subset are ignored, and Azure OpenAI may reject a schema that uses them. The flagged keywords are `pattern`, `format`, `minLength`, `maxLength`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `maxItems`, `uniqueItems`, `minProperties` and `maxProperties`. `minItems` is not enforced either; it is reported by the `min-items` rule, which carries the fix. - **Vertex AI and Ollama** are not checked, so they produce no `provider-rewrite` warnings. The linter checks your schema against every provider configured for your organization, plus Azure OpenAI, which serves the platform's built-in models. 2kw.ai does not enforce these keywords after extraction either. It does check every output against its schema and records the violations, for enforced and advisory keywords alike, see [Schema Validation](/extraction/quality#schema-validation). ### Test Against Sample Text ### `POST /v1/schemas/{schemaId}/test` Run an extraction against sample text **without persisting** the result. Great for iterating on your schema: **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/schemas/{schemaId}/test \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "jsonSchema": { "type": "object", "properties": { "name": { "type": "string" }, "email": { "type": "string" } } }, "sampleText": "Contact Jane Smith at jane@acme.com", "model": "gpt-5.1" }' ``` **Python** ```python response = requests.post( f"{BASE_URL}/v1/schemas/{schema_id}/test", headers=headers, json={ "jsonSchema": { "type": "object", "properties": { "name": {"type": "string"}, "email": {"type": "string"} } }, "sampleText": "Contact Jane Smith at jane@acme.com", "model": "gpt-5.1" } ) result = response.json() print(result["extractedData"]) # {"name": "Jane Smith", "email": "jane@acme.com"} ``` The response includes token usage and processing time so you can estimate costs: ```json { "success": true, "extractedData": { "name": "Jane Smith", "email": "jane@acme.com" }, "inputTokens": 85, "outputTokens": 22, "processingDurationMs": 890, "modelUsed": "gpt-5.1" } ``` ### All Schema Endpoints The endpoints in one place. [Every field: API reference](https://app.2kw.ai/docs/api-reference). | Method | Endpoint | What it does | | --- | --- | --- | | `GET` | `/v1/schemas?search=invoice&page=0&size=20` | List schemas, paginated, optionally filtered by `search` | | `POST` | `/v1/schemas` | Create a schema (`name`, `description`) | | `GET` | `/v1/schemas/{id}` | Get a schema | | `PUT` | `/v1/schemas/{id}` | Update a schema's `name` or `description` | | `DELETE` | `/v1/schemas/{id}` | Delete a schema and all its versions. Needs the admin role | | `POST` | `/v1/schemas/{schemaId}/versions` | Commit a new version (`jsonSchema`, `changeDescription`) | | `GET` | `/v1/schemas/{schemaId}/versions?page=0&size=20` | List versions, paginated | | `GET` | `/v1/schemas/{schemaId}/versions/{versionId}` | Get one version | | `GET` | `/v1/schemas/{schemaId}/versions/latest` | The latest version; `404` while there is none. Deprecated: use `resolve` | | `PUT` | `/v1/schemas/{schemaId}/versions/{versionId}/activate` | Re-activate a historical version as a new copy | | `DELETE` | `/v1/schemas/{schemaId}/versions/{versionId}` | Deactivate a version (soft delete); answers `204`. Needs the admin role | | `GET` | `/v1/schemas/{schemaId}/labels` | List labels | | `POST` | `/v1/schemas/{schemaId}/labels` | Create a label (`name`, `schemaVersionId`) | | `PUT` | `/v1/schemas/{schemaId}/labels/{labelName}` | Point a label at another version (`schemaVersionId`) | | `DELETE` | `/v1/schemas/{schemaId}/labels/{labelName}` | Delete a label; `latest` cannot be deleted. Needs the admin role | | `GET` | `/v1/schemas/{schemaId}/resolve?label=production` | Resolve a label (default `latest`) to its version | | `POST` | `/v1/schemas/{schemaId}/validate` | Validate a JSON Schema without saving it | | `POST` | `/v1/schemas/{schemaId}/test` | Extract from sample text without saving the result | --- Source URL: https://docs.2kw.ai/extraction/quality # Quality and Rules How trustworthy an extracted value is, and the rules you can add to check it: grounding, confidence, schema validation and business rules. Scores and rule outcomes come back with every extraction ([Running extractions](/extraction#running-extractions)). [Every field: API reference](https://app.2kw.ai/docs/api-reference). ## Quality Scoring Every text extraction result includes quality scores that help you assess how trustworthy the extracted data is. These scores are purely informational — `structuredOutput` is never modified. ### Grounding vs Confidence 2kw.ai distinguishes between two types of quality signals: **Grounding** measures whether an extracted value has evidence in the source text. If your schema extracts a `sourceFile` field and the value `"gear.geo"` appears in the input, that's grounded. If `"phantom.geo"` doesn't appear anywhere in the input, it was likely hallucinated by the model. Grounding is deterministic, free (no extra LLM calls), and available on every extraction. **Confidence** combines the grounding score with the other evidence 2kw.ai already has about a value: the [schema violations](#schema-validation) recorded for it and the verdicts of your [business rules](#business-rules). It is arithmetic over those signals. No model is asked how sure it is, no extra call is made, and the same result always produces the same score. See [Composite Confidence](#composite-confidence) for how the number is built. The `scoring.methods` array tells you which methods ran: `grounding`, and `confidence` when the composite was computed. **Note — Scores never modify output:** Grounding and confidence scores are metadata only. `structuredOutput` always contains the complete extraction result, exactly as the model produced it (after deduplication for chunked extractions). Your application decides how to use the scores — flag low-scoring items, highlight them in the UI, or filter them client-side. ### How Grounding Works For each extracted field, 2kw.ai searches the original input text for the extracted value: | Basis | Score | Meaning | |-------|-------|---------| | `exact_match` | 1.0 | Value found verbatim in the source text | | `normalized_match` | 0.9 | Found after normalizing separators (underscores, hyphens, spaces) | | `partial_match` | 0.8 | Substring or filename stem found (e.g. `gear` matches `gear.geo`) | | `unverifiable` | 0.5 | Numbers, booleans, or strings shorter than 3 characters — can't be meaningfully searched | | `not_found` | 0.0 – 0.1 | String value not found in source text. Key fields score **0.0** (strong hallucination signal), regular fields score **0.1**. | Per-item scores are weighted averages of their fields, with **key fields** (identifiers like filenames, IDs, names) weighted 3x higher than regular fields. This means a hallucinated filename dominates the item score even if other fields look fine. Unverifiable fields (numbers, booleans, short strings) are excluded from this average — they don't drag the score down or inflate it. ### Composite Confidence Every text extraction that is scored carries a **composite confidence** per scored field: one number in `[0, 1]`, built from the field's grounding score, its schema violations and its business-rule verdicts. Like the other quality signals it is recorded only. Nothing is blocked, no status changes and nothing is retried. #### The scores are not calibrated `resultMetadata.scoring.confidence.calibrated` is `false`, and it is the most important field in this section. The numbers are an ordering, not a probability: `0.7` does **not** mean "70% likely to be correct". The constants below have not been fitted against labelled outcomes. Use the scores to rank fields against each other within one schema version, derive any threshold from your own observed results, and do not show a confidence number to an end user as a probability. #### How the score is computed The grounding score is the base. Negative evidence attributed to the field either **deducts** from it or **caps** it. Nothing raises it: a rule that passed adds to the field's provenance, never to its score. | Signal attributed to the field | Effect | Amount | |--------------------------------|--------|--------| | One or more schema violations | caps | `0.2` | | A failed rule with `automationBlocker: true` | caps | `0.1` | | A failed rule with `severity: ERROR` | caps | `0.3` | | A failed rule with `severity: WARNING` | deducts, once per failure | `0.2` | | A failed rule with `severity: INFO` | deducts, once per failure | `0.05` | | A rule that passed | no change to the score, adds `rule` provenance | — | | A rule outcome with verdict `SKIPPED` or `ERROR` | nothing | — | ``` base = grounding score penalty = sum of the deductions above cap = the smallest applicable cap, if any score = clamp(min(round(base − penalty, 2 decimals), cap), 0, 1) ``` `severity` and `automationBlocker` act independently. A blocking `WARNING` failure deducts `0.2` and caps at `0.1`; when several caps apply, the smallest wins. Deductions add up without limit (three failed `WARNING` rules deduct `0.6`) and the result is clamped at `0`. Caps beat deductions because the two meet in a `min`. The `SKIPPED` and `ERROR` verdicts do not count: the input was absent or the rule could not run, and neither says anything about the value. Scores are rounded to two decimals, the same grid as the grounding scores. #### The basis `confidence.basis` names the one cause that decided the number. It is always one of six values: | `basis` | Meaning | |---------|---------| | `blocker_capped` | A rule marked `automationBlocker` failed, and its `0.1` cap is the score | | `schema_capped` | The value violates its schema, and the `0.2` cap is the score | | `rule_capped` | An `ERROR`-severity rule failed, and the `0.3` cap is the score | | `rule_penalized` | Rule failures deducted from the grounding score and no cap applied | | `low_information` | Grounding could not verify the value, and nothing negative touched it | | `grounded` | Nothing lowered the grounding score; the composite is the grounding score | The order of the table is the priority order. A cap claims the basis only when it binds, that is, when it is at or below the score the deductions produced. A field grounded at `0.1` that also has a schema violation reads `grounded` at `0.1`, because the `0.2` cap would have raised it. On an exact tie the cap takes the label. Read the basis as "what decided this number", not as "everything wrong with this field": the violation is still listed in `resultMetadata.schemaValidation`. #### `low_information` is not a middle score Grounding checks a value by searching for it in the source text. That cannot work for numbers, booleans, objects, strings shorter than three characters, or arrays with nothing searchable in them. Without a field hint they score `0.5` with grounding basis `unverifiable`, because nothing was checked. When such a field has no violation and no failed rule, its composite stays `0.5` with basis `low_information`: *this number tells you nothing about this field*. - A `0.5` that means "unverified" and a `0.5` that came out of a real comparison are different claims. Only the basis tells them apart. - Averaging a document's scores without separating out `low_information` mostly measures how many numeric fields the schema has. - Do not infer the basis from a field's type. On array-item fields a [field hint](#extraction-configuration) is applied first (`system_assigned` scores `1.0` without looking at the document), so read the basis. Negative evidence still applies: a `low_information` field that violates its schema is capped like any other, and its basis changes. #### Provenance `provenance` lists the checks that looked at the value and did not object. It is not a second score. | Entry | Written when | |-------|--------------| | `grounding` | Grounding found the value in the source text (`exact_match`, `normalized_match`, `partial_match`), or the field has the `system_assigned` hint | | `rule` | At least one business rule attributed to this field returned `PASS` | | `threshold` | A composite threshold is configured for this field and the score met it | Entries appear in that order. `history` and `human` are reserved and not written yet. `system_assigned` is the one entry that is not a check: the value was trusted because of the field's configuration, not compared against the input. An empty list is an answer: the composite was computed and nothing vouches for the value. It does not move with the score. A field can score `0.5` with `[]` (nothing was checked) or `0.2` with `["grounding"]` (the text was found, then a schema violation capped it). The list is `null` only when `confidence` is `null`. #### Thresholds and flags Two independent thresholds can be configured per property on the schema version (see [Extraction Configuration](#extraction-configuration)), and they judge different numbers: | Config key | Compares | Flag `source` | |------------|----------|---------------| | `validation.properties.{property}.confidenceThreshold` | the **grounding** score | `LEGACY_GROUNDING` | | `confidence.properties.{property}.threshold` | the **composite** score | `COMPOSITE` | Both are keyed by property name: a root field's own name, an array property's name (which covers every scored field of every element), or `$root` for a schema whose root is an array. A score **strictly below** its threshold records a flag; a score exactly at the threshold passes. Meeting a composite threshold also adds `threshold` provenance. One field can raise both flags in the same extraction. A flag is an observation only: nothing is blocked, retried or changed. At most **100 flags** are recorded per result. `flagCount` is the true total and `truncated` says whether the list was cut, so read the count, not the length of the list. The composite threshold takes a plain JSON number in `[0, 1]`. Anything else (a quoted `"0.8"`, a boolean, a number out of range) counts as not configured, without a warning. If a composite threshold never flags anything, check its type first, then the property name, then whether the field was scored at all. #### Which field a signal belongs to Schema violations and rule outcomes both carry a JSON pointer. Each is attributed to the **deepest scored field whose pointer is a prefix of it**: `/vendor/vatId` lands on the root field `vendor`, and `/parts/0/price` on the item field `parts[0].price`. A signal that matches no scored field lowers no score; it stays where it was recorded. The common case is a root field the model omitted or returned as `null`: it has no score, so the `required` violation that names it has nothing to lower. Rule outcomes count only when they name one concrete location. An outcome with a wildcard pointer or the empty pointer is not attributed to any field. Known limitations: - A signal on an array element as a whole (`/parts/0` rather than `/parts/0/price`) lowers nothing, because only an element's fields are scored. - Flags name fields flatly (`parts[0].price`). A root field literally named `parts[0].price`, or `$root`, shares that flag key with the item field. Their scores stay separate everywhere else in the result. #### When confidence is null A `null` confidence never means "not confident". It means the number was not computed: 1. **Vision extractions** carry no grounding, so they have no scoring at all: `resultMetadata.scoring` is `null`. 2. **Validation is switched off** for the schema version (`validation.enabled: false`). That also switches off grounding, so there is nothing to build on. 3. **Nothing was scored.** Either there was no source text, or no property of the output could be scored. In the second case the scoring block can still show `methods: ["grounding"]` and a `grounding.score` of `1.0` (the average of nothing) while `fields` and `items` are empty. Read those two maps, not the presence of the block. 4. **The extraction was scored before composite confidence existed.** 5. **The confidence step could not complete** for this extraction. The grounding scores are left as they were. If this happens on every extraction for one schema version, contact support. The quickest positive check is `"confidence"` in `scoring.methods`. Before you gate anything on a score, check that `resultMetadata.scoring.confidence` is present and treat its absence as "unknown", never as "clean". A missing summary and a summary with `flagCount: 0` look nothing alike, and only the second is a statement about your data. #### Reading the payload Confidence sits inside `resultMetadata.scoring`, next to the grounding scores it is built from: ```json { "methods": ["grounding", "confidence"], "grounding": { "score": 0.95, "basis": null }, "thresholds": { "parts": 0.5 }, "fields": { "invoiceNumber": { "grounding": { "score": 1.0, "basis": "exact_match" }, "confidence": { "score": 0.2, "basis": "schema_capped" }, "provenance": ["grounding"] }, "total": { "grounding": { "score": 0.5, "basis": "unverifiable" }, "confidence": { "score": 0.5, "basis": "low_information" }, "provenance": [] } }, "items": { "parts[0]": { "grounding": { "score": 0.9, "basis": "normalized_match" }, "fields": { "price": { "grounding": { "score": 0.9, "basis": "normalized_match" }, "confidence": { "score": 0.7, "basis": "rule_penalized" }, "provenance": ["grounding"] } } } }, "confidence": { "calibrated": false, "flags": [ { "field": "invoiceNumber", "confidence": 0.2, "threshold": 0.8, "source": "COMPOSITE" } ], "flagCount": 1, "truncated": false } } ``` - **`fields`** holds root-level properties and **`items`** holds array elements, keyed `parts[0]` (or `[0]` when the schema's root is an array). A property the model omitted or returned as `null` appears in neither. - **An item's own `grounding.basis`** names the basis of its weakest scored field, the one that pulled the item score down. It reads `unverifiable` when none of the item's fields could be checked. The top-level `grounding.basis` is always `null`. - **`flags[].field`** uses the same keys: `invoiceNumber`, `parts[0].price`, `[2].name`. - **`flags[].confidence`** is the grounding score for `LEGACY_GROUNDING` flags and the composite for `COMPOSITE` flags. Always read it together with `source`. A field that appears twice, once per source, is two signals, not a duplicate. - **`thresholds`** is a hint map for the console. It lists only array properties and `$root`, and flags do not depend on it: a grounding threshold on a root field raises flags without ever appearing here. ### Schema Validation Every extraction output is checked against its schema version before it is scored. The check only records: violations are listed on the result, nothing is blocked, the status does not change and nothing is retried. For chunked extractions only the final merged output is checked, never the individual chunks. If you want non-conforming output to fail hard, enforce it in your own code from this object. The result is in `resultMetadata.schemaValidation`: ```json { "applied": true, "violationCount": 2, "truncated": false, "violations": [ { "fieldPath": "/total", "keyword": "type", "message": "string found, number expected", "source": "MODEL", "property": null }, { "fieldPath": "/invoiceNumber", "keyword": "type", "message": "null found, string expected", "source": "RESTORED_NULL", "property": null } ] } ``` | Field | Description | |-------|-------------| | `fieldPath` | RFC 6901 JSON pointer into the output (`~` escaped as `~0`, `/` as `~1`). The empty string is the document root | | `keyword` | The JSON Schema keyword that failed: `type`, `required`, `minimum`, … | | `property` | The field the violation is about, when the keyword names one (in practice `required`). `null` otherwise | | `message` | Display text only, always English, and its wording can change. Match on `fieldPath`, `keyword` and `property` instead | | `source` | `MODEL`: the model produced a value that does not satisfy the schema. `RESTORED_NULL`: see below | A `required` violation is anchored on the containing object, not on the missing field. A top-level required field the model left out entirely reports `{"fieldPath": "", "keyword": "required", "property": "invoiceNumber"}`, so read the field's name from `property`, never from `message`. `source: RESTORED_NULL` means the field was missing after the chunks were merged and was filled with `null` by post-merge validation. That is a deliberate absence signal: the input most likely does not contain the value. Treat it as "not found", not as a model error. Only chunked extractions produce it. At most **100 violations** are recorded. `violationCount` is always the true total, and `truncated` is `true` when more were found. `applied: false` never means "passed"; a clean output is `applied: true` with an empty `violations` list. It means the check did not run, either because validation is switched off for the schema version (`validation.enabled: false`, which also switches off grounding and confidence scoring) or because it could not run for this extraction. **Absence at a glance:** | Signal | Where | Meaning | |--------|-------|---------| | Grounding basis `default_absent` | `resultMetadata.scoring.items[...].fields[...].grounding.basis` | An array-item field with the `default_absent` hint came back empty or at its default: a blank string, `false`, an empty array, or a number that truncates to `0` or `1` (so `0.5` and `1.9` count too). Scored `0.8` as a deliberate absence. Root-level fields are scored without hints and never get this basis | | `source: RESTORED_NULL` | `resultMetadata.schemaValidation` | A required field was filled with `null` after the chunk merge: deliberate absence | | `source: MODEL` on a `null` | `resultMetadata.schemaValidation` | The model itself returned an invalid or `null` value, with no explanation | A field that keeps coming back as `RESTORED_NULL` points at the schema or the input, not at the model: either the value is not in the documents, or the field should not be `required`. ### Extraction Configuration Each schema version can carry an `extractionConfig` that tunes per-property scoring, dedup, thresholds and [business rules](#business-rules). It is stored alongside the JSON Schema but never sent to the model; it controls pipeline behavior only. The configuration is set on the schema version by support. The API does not accept it when you create a version (`POST /v1/schemas/{schemaId}/versions` takes only `jsonSchema` and `changeDescription`), but you can read it back as `extractionConfig` on the schema version. To change it, contact support with the schema, the version and the settings you want. For a schema whose `parts` property is an array of items with `sourceFile`, `partNumber`, `material` and `quantity`, a configuration looks like this: ```json { "validation": { "enabled": true, "properties": { "parts": { "confidenceThreshold": 0.5, "keyFields": ["sourceFile"], "fieldHints": { "partNumber": { "scoring": "system_assigned" }, "quantity": { "scoring": "default_absent" } } } } }, "confidence": { "properties": { "parts": { "threshold": 0.6 } } } } ``` | Config | Description | |--------|-------------| | `validation.enabled` | Switches schema validation, grounding and confidence scoring on or off for the version. Defaults to on. It does not affect business rules | | `validation.properties.{p}.confidenceThreshold` | Grounding score below which a field is flagged. Records a `LEGACY_GROUNDING` flag (see [Composite Confidence](#composite-confidence)) and is shown in the console. Informational, does not remove items | | `validation.properties.{p}.keyFields` | Override which fields are used as identifiers for dedup and scoring weight. Auto-detected if not set. | | `validation.properties.{p}.fieldHints` | Override scoring behavior for specific fields. Each hint is an object with a `scoring` key. | | `confidence.properties.{p}.threshold` | Composite confidence below which a field is flagged, as a `COMPOSITE` flag. A JSON number in `[0, 1]`; anything else is ignored | | `rules` | The schema version's [business rules](#business-rules) | **Available field hints:** | Hint | Effect | Use for | |------|--------|---------| | `system_assigned` | Always scores 1.0 | Fields assigned by the system (partNumber, auto-generated IDs) | | `default_absent` | Scores 0.8 when value is empty, normal scoring otherwise | Fields where empty means "not applicable" (tolerances, surface treatment) | | `literal` | Marks the field as a verbatim reference that post-merge validation checks against the source text. Grounding does not use it: the field scores `0.5` as `unverifiable`, so keep it off key fields | Fields that reference entities in the source (filenames, document IDs, reference codes) | **Note — Most fields don't need hints:** Grounding scoring works automatically for string fields by searching the source text. You only need hints for fields where the default scoring doesn't apply — typically 2-3 fields per schema. ## Business Rules Business rules are deterministic checks over an extraction's output: a VAT ID that matches a pattern, a total that equals the sum of its line items, a date in ISO form. They need no model call and no calibration data. The same output always gets the same verdict. Rules are part of the schema version's [extraction configuration](#extraction-configuration), in a top-level `rules` array, and are set by support like the rest of it. They run on every extraction of that version, right after [schema validation](#schema-validation) and before scoring. `validation.enabled` does not switch them off; each rule has its own `enabled` flag. Rules only record. Every outcome, passes included, is written to `resultMetadata.rules`; nothing is blocked, the status does not change and nothing is retried. ```json { "validation": { "enabled": true }, "rules": [ { "id": "vat-id-format", "type": "REGEX", "field": "/supplier/vatId", "pattern": "^DE[0-9]{9}$", "severity": "ERROR", "automationBlocker": true, "onFail": "FLAG" } ] } ``` ### Rule fields Every rule accepts these fields, whatever its type: | Field | Required | Default | Meaning | |-------|----------|---------|---------| | `id` | Yes | — | Names the rule in every outcome it produces. Unique within the version's rules | | `type` | Yes | — | `REGEX`, `RANGE`, `ENUM`, `ARITHMETIC`, `CROSS_FIELD` or `FORMAT` | | `severity` | No | `WARNING` | `ERROR`, `WARNING` or `INFO`: how much a failure matters | | `automationBlocker` | No | `false` | Whether a failure should stop straight-through processing | | `onFail` | No | `WARN` | `WARN`, `FLAG`, `REJECT` or `RETRY`. Recorded, not enforced (see below) | | `enabled` | No | `true` | A disabled rule produces no outcome at all | | `failOnMissing` | No | `false` | Report `FAIL` instead of `SKIPPED` when the input is absent | The type-specific fields (`field`, `pattern`, `min`, `max`, …) sit next to these. Enum values are case-sensitive: `"severity": "error"` is not `ERROR`, so that rule is invalid and reports an `ERROR` verdict. An explicit `null` for `severity` or `onFail` also makes the rule invalid, while a `null` for a boolean field falls back to its default. When two rules share an `id`, the first one is evaluated and every later one reports `ERROR` with `duplicate rule id`. ### Field pointers Rules address values with RFC 6901 JSON pointers, the same format as `fieldPath` in [schema validation](#schema-validation). The empty pointer is the document root. A pointer may contain **at most one** `*` segment, which expands over an array. The rule then produces one outcome per location that resolves, each with its own concrete pointer. `/items/*/price` over three items, of which only the first two have a `price`, produces two outcomes (`/items/0/price` and `/items/1/price`) and nothing for the third item. Two wildcards, or a non-empty pointer that does not start with `/`, is a configuration error. A pointer into something the document does not have (an absent key, an index out of range, a wildcard over a non-array) is a miss, not an error. When the pointer as a whole resolves to nothing, the rule reports one `SKIPPED` (or `FAIL` with `failOnMissing`) at the pointer as configured, wildcard included. An explicit `null` at a location that did resolve reports `SKIPPED` (or `FAIL`) at that location. An object key literally named `*` cannot be addressed, because `*` is always the wildcard. ### Rule types | Type | Addresses | Type-specific config | Holds when | Outcomes | |------|-----------|----------------------|------------|----------| | `REGEX` | `field` | `pattern` (Java regex) | The string matches the pattern **in full** | One per resolved location | | `RANGE` | `field` | `min` and/or `max` (inclusive) | The number lies within the bounds | One per resolved location | | `ENUM` | `field` | `values` (non-empty array) | The value equals one of the values | One per resolved location | | `ARITHMETIC` | `field` (no wildcard) | `equals: {op, operands}`, `tolerance` | \|field − sum of operands\| ≤ `tolerance` | Exactly one | | `CROSS_FIELD` | `left`, `right` (no wildcards) | `operator` | The relation holds between the two values | Exactly one | | `FORMAT` | `field` | `format` (named format) | The string satisfies the named format | One per resolved location | Values are read at their JSON type and never converted. A number written as a string, or a string where a number is expected, reports `ERROR`. Numbers are compared as exact decimals, so `1.50` equals `1.5` and `0.1 + 0.2` is exactly `0.3`. #### REGEX ```json { "id": "iban-shape", "type": "REGEX", "field": "/supplier/iban", "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$" } ``` The pattern must match the whole value, not just occur in it, so `"AT123 DE123456789 xx"` does not satisfy a VAT ID pattern. The syntax is Java's regular expression syntax. Strings only: a number reports `ERROR`, as does a missing or invalid `pattern`. #### RANGE ```json { "id": "vat-rate-plausible", "type": "RANGE", "field": "/taxRate", "min": 0, "max": 0.27 } ``` Both bounds are inclusive and at least one is required. A non-numeric bound, or a `min` above `max`, reports `ERROR`. So does a non-numeric value at `field`. #### ENUM ```json { "id": "currency-supported", "type": "ENUM", "field": "/items/*/currency", "values": ["EUR", "CHF", "GBP"] } ``` Values are compared by their text form, case-sensitively, so `"de"` does not satisfy `["DE"]`. Two numbers compare as decimals, so `1.0` satisfies `[1]`. Mixed types fall back to text: the string `"1"` satisfies `[1]`, but the number `1.0` does not satisfy `["1"]`. An object or array at `field` reports `ERROR`. A missing or empty `values` array reports `ERROR`. #### ARITHMETIC ```json { "id": "total-equals-line-items", "type": "ARITHMETIC", "field": "/totals/net", "equals": { "op": "SUM", "operands": ["/items/*/lineNet"] }, "tolerance": 0.01 } ``` `op` is `SUM`; any other operator reports `ERROR`. `tolerance` is an absolute amount, `0` by default, and a difference exactly equal to it passes. Operands may use a wildcard, which is how a line-item sum is written; `field` may not. An ARITHMETIC rule always produces one outcome. Its `actual` text shows both sides and the number of addends, for example `119.05 vs 119 (2 operands)`. Operands that resolve only partly are summed over what exists, and a `null` operand is not counted. The rule reports `SKIPPED` when `field` is absent **or** when no operand resolves; the two outcomes look identical, so check the output to see which side was missing. #### CROSS_FIELD ```json { "id": "net-not-above-gross", "type": "CROSS_FIELD", "left": "/totals/net", "operator": "LTE", "right": "/totals/gross" } ``` `operator` is `EQ`, `NE`, `LT`, `LTE`, `GT` or `GTE`. Two numbers compare as exact decimals. Two strings can only be compared with `EQ` and `NE`; ordering strings reports `ERROR`. Any other pairing (a number against a string, booleans, objects) reports `ERROR`. Neither side may use a wildcard. An absent or `null` side reports `SKIPPED`, and the outcome names the pointer of the missing side. #### FORMAT ```json { "id": "invoice-date-iso", "type": "FORMAT", "field": "/invoiceDate", "format": "DATE_ISO" } ``` | Format | Accepts | Edges | |--------|---------|-------| | `DATE_ISO` | Strict ISO 8601 calendar dates (`2026-08-06`) | No lenient parsing: `2026-8-6` and `06.08.2026` fail | | `EMAIL` | One `@`, a dotted domain, a top-level domain of 2+ letters | A shape check, not a deliverability check. Deliberately loose | | `URL` | Absolute `http` and `https` URLs with a host | International domains must be punycoded (`xn--…`); a host containing `_` is rejected | | `IBAN` | 15–34 characters, country code, check digits, valid mod-97 checksum | Spaces are removed and letters upper-cased before checking | An unknown format name reports `ERROR`. Strings only: a date extracted as a number reports `ERROR`. ### Verdicts Every outcome has exactly one verdict, and the report counts each: | Verdict | Meaning | What it tells you | |---------|---------|-------------------| | `PASS` | The rule was evaluated and held | Evidence the value is not wrong in this way | | `FAIL` | The rule was evaluated and did not hold | A problem in the **document** | | `SKIPPED` | The input the rule needed was absent | The document lacks the field | | `ERROR` | The rule could not be evaluated | A problem in the **rule**: its configuration, or a value type it will not convert | Watch `failCount` for data quality, `errorCount` for rules that need fixing and `skippedCount` for coverage: a rule that is always `SKIPPED` is not checking anything. No verdict ever fails the extraction. See [Rule Verdict Precedence](#rule-verdict-precedence) for which verdict wins when several apply. ### Severity vs automationBlocker The two are independent. `severity` says how much a failure matters: `ERROR` for a defect in the document, `WARNING` for something that needs human attention, `INFO` for observation only. `automationBlocker` says whether a failure should stop straight-through processing. A rule can be `INFO` and blocking, or `ERROR` and non-blocking. Both are copied onto every outcome of the rule, including passes: they describe the rule, the verdict describes what happened. Both also feed into [composite confidence](#composite-confidence). ### onFail is recorded, not enforced `onFail` states what should happen when a rule fails. Today **all four actions behave as `WARN`**: the action is validated and recorded on every outcome, and the extraction result is returned unchanged whatever the verdict. | Action | Intended behavior | |--------|-------------------| | `WARN` | Record and continue (the current behavior for all four) | | `FLAG` | Mark the result for review | | `REJECT` | Reject the extraction result | | `RETRY` | Retry the extraction | If a failing rule should stop your workflow now, enforce it in your own code from `resultMetadata.rules`. ### Reading Rule Outcomes The report is in `resultMetadata.rules`: ```json { "applied": true, "passCount": 1, "failCount": 1, "skippedCount": 1, "errorCount": 1, "truncated": false, "outcomes": [ { "ruleId": "gross-equals-net-plus-vat", "field": "/totals/gross", "verdict": "FAIL", "expected": "sum(/totals/net, /totals/vat) ± 0.01", "actual": "119.05 vs 119 (2 operands)", "severity": "ERROR", "automationBlocker": true, "action": "REJECT" }, { "ruleId": "vat-id-format", "field": "/supplier/vatId", "verdict": "PASS", "expected": "^DE[0-9]{9}$", "actual": "DE123456789", "severity": "ERROR", "automationBlocker": true, "action": "FLAG" }, { "ruleId": "invoice-date-iso", "field": "/invoiceDate", "verdict": "SKIPPED", "expected": "value present", "actual": "absent", "severity": "WARNING", "automationBlocker": false, "action": "WARN" }, { "ruleId": "tax-rate-plausible", "field": "/taxRate", "verdict": "ERROR", "expected": "numeric 'min'", "actual": "non-numeric 'min': ten", "severity": "WARNING", "automationBlocker": false, "action": "WARN" } ] } ``` | Field | Description | |-------|-------------| | `ruleId` | The rule's `id`. Empty when the rule had no `id`, which is then the reason for its `ERROR` | | `field` | The concrete pointer the rule was evaluated at. When nothing resolved, the pointer as configured, wildcard included. Empty when no location applies, such as a duplicate `id` or an unreadable rule | | `verdict` | `PASS`, `FAIL`, `SKIPPED` or `ERROR` | | `expected`, `actual` | Display text only, and the wording can change. For an `ERROR`, `actual` carries the reason (`unknown rule type 'REGEXP'`, `min 10 exceeds max 1`, `duplicate rule id`, `wildcard not allowed in 'field'`, …). Match on `ruleId`, `field` and `verdict`, never on these | | `severity`, `automationBlocker` | As configured on the rule | | `action` | The configured `onFail`. Recorded, not enforced | At most **200 outcomes** are recorded, because passes are recorded too and a rule multiplies over array items. The four counts are always the true totals, and `truncated` is `true` when the list was cut. Read the counts, not the length of the list. `expected` and `actual` are cut at 500 characters and marked with a trailing `…`. `applied: false` never means "all rules passed"; that is `applied: true` with `failCount: 0`. It means nothing was evaluated: the version has no rules, every rule is disabled, or rule evaluation could not run for this extraction. The three look identical in the result. `validation.enabled: false` does not cause it. **A rule that is always `SKIPPED`** usually has a pointer that does not match the real shape of the output. Compare it with an output you got back, not with the schema. Set `failOnMissing: true` if absence is itself the problem, but note that array elements without the field never produce an outcome, so `failOnMissing` cannot flag them; require the field in the schema instead. **A rule that is always `ERROR`** needs fixing: read `actual` for the reason. ### Rule Verdict Precedence When more than one [business rule](/extraction/quality#business-rules) verdict could apply, these decide: - **A broken rule beats an absent field.** A rule's configuration is checked before the document is read. A rule with an invalid pattern, an empty `values` array or a `min` above `max` reports `ERROR` even when its field is also absent, so it counts in `errorCount`, never in `skippedCount`. A rule that can never work says so instead of reporting `SKIPPED` forever. - **A broken rule is one `ERROR`, not one per location.** A configuration problem on a rule whose pointer covers fifty array items produces a single `ERROR`, because the problem belongs to the rule. A value-level `ERROR`, such as a wrong type at one location, is still reported per location, because it belongs to the document. - **`failOnMissing` turns `SKIPPED` into `FAIL` and nothing else.** It does not affect `ERROR`. It reaches a pointer that resolved to nothing (one `FAIL` at the configured pointer) and an explicit `null` at a resolved location (one `FAIL` there). - **A rule that crashes is that rule's `ERROR`.** It never suppresses the rules around it and never fails the extraction. The exception is a pattern so pathological that evaluation cannot continue at all; the whole report is then recorded as `applied: false`. --- Source URL: https://docs.2kw.ai/extraction/large-documents # Large Documents Long inputs are split, extracted and merged for you. This page says what to expect and how the result is checked. You run a long input the same way as a short one ([Running extractions](/extraction#running-extractions)). [Every field: API reference](https://app.2kw.ai/docs/api-reference). ## Extraction Strategies 2kw.ai picks the strategy from the size of the input. You send the same request in every case. | Strategy | What happens | |----------|--------------| | **Single-shot** | Short inputs are processed in one call | | **Chunked** | Longer inputs are split into parts that are extracted one after the other. The results are merged, deduplicated and validated, and each part knows what the earlier parts found, so an item is not extracted twice | | **Async** | Very long inputs are processed in the background; poll for the result | [Estimate the tokens first](/extraction#estimate-tokens-first) to see which strategy an input will take: the response names it in `strategy` (`SINGLE_SHOT`, `CHUNKED` or `ASYNC`). Merging, deduplication and validation need no configuration and work with any schema. ### Deduplication When an extraction is chunked, the same item can appear in more than one part. 2kw.ai groups items that share their identifier fields (like `sourceFile`, `partNumber` or `name`) into one: gaps are filled, exact duplicates removed and sequential numbering fixed. The `resultMetadata.dedup` object tells you what happened: | Field | Description | |-------|-------------| | `applied` | Whether dedup was needed | | `itemsRemoved` | Number of exact duplicates removed | | `itemsMerged` | Number of partial items merged into one | | `resequenced` | Whether sequential numbering was fixed | | `originalItemCount` | Items before dedup | | `finalItemCount` | Items after dedup | **Note — Dedup just works for most schemas:** If your array items have clearly named identifier fields (`sourceFile`, `partId`, `name`, etc.), dedup handles everything automatically. For optional fields, prefer nullable types (`"type": ["string", "null"]`) over defaulting to `""`: a `null` reads as "not found in this part", an empty string as an answer. ### Key Field Auto-Detection Items are matched by their identifier fields. When `keyFields` is not set in your extraction config, 2kw.ai takes the fields whose names read like identifiers: `id`, `name` or `key`, or names that end in `id`, `name`, `file`, `key`, `code` or `number`, or begin with `source` or `file`. The same fields are weighted higher in [grounding scores](/extraction/quality#how-grounding-works). If that picks the wrong fields, or misses yours, set `keyFields` explicitly in your extraction config. ### Post-Merge Validation After deduplication, a chunked extraction goes through a validation pass that catches two types of issues: **Hallucination removal.** Models sometimes invent items near the edge of a part, for example a file name that does not exist in the source. The validator checks each item's literal reference fields (like `sourceFile`) against the input. If a filename appears nowhere in the source, the item is removed. **Required field restoration.** When results are merged, required top-level fields (like `date` or `inquiryNumber`) can be dropped. The validator checks the schema's `required` array and inserts `null` for any missing required field, so the absence is explicit rather than silent. The `resultMetadata.postValidation` object tells you what happened: | Field | Description | |-------|-------------| | `applied` | Whether post-merge validation ran | | `hallucinatedItemsRemoved` | Number of items removed because their identifier wasn't found in the source | | `requiredFieldsRestored` | Number of required fields that were missing and filled with `null` | | `removedItemKeys` | The identifier values of removed items (for debugging) | **Note — Hallucination detection uses literal fields:** By default, only file-reference fields (`sourceFile`, `fileName`, etc.) are checked against the source text. These fields should appear verbatim in the input. Derived values like summaries or computed measurements are never checked — they wouldn't be found via text matching even when correct. To mark additional fields as literal references for post-merge validation, use the `literal` field hint in your extraction config. Grounding scoring ignores the hint, so a `literal` field scores `0.5` as `unverifiable`; do not set it on key fields: ```json "fieldHints": { "sourceFile": { "scoring": "literal" }, "documentId": { "scoring": "literal" } } ``` --- Source URL: https://docs.2kw.ai/document-conversion # Document Conversion Turn PDFs, Word docs, emails, and ZIP archives into clean text —ready for extraction or any downstream processing. ## How It Works Send a document via URL, base64, or file upload and get back its content as Markdown, plain text, JSON, or HTML. 2kw.ai handles format detection, parsing, and text extraction automatically. All you need is an API key. **Note — 17 input formats supported:** PDF, DOCX, XLSX, PPTX, CSV, TXT, HTML, Markdown, AsciiDoc, MSG (Outlook), EML, ZIP, Images (any `image/*` type), DXF (AutoCAD), DWG (AutoCAD), STEP, and GEO (TRUMPF). Output as Markdown, Plain Text, JSON, or HTML. ## Pipelines 2kw.ai offers three processing pipelines. Choose the one that fits your document type: | Pipeline | Description | Best for | |----------|-------------|----------| | `fast` (default) | Fast text extraction | Text-heavy PDFs, DOCX, emails, spreadsheets | | `ocr` | OCR-based extraction with layout analysis | Scanned documents, image-heavy PDFs | | `vlm` | Vision Language Model processing | Complex layouts, mixed text+images, plain images | The `fast` pipeline is the default and works for most documents. Use `ocr` or `vlm` when you need to process scanned or image-heavy content. Not every format supports every pipeline — see the [supported formats table](#input) for the full matrix. ## Convert from URL ### `POST /v1/convert/source` The primary endpoint. Pass one or more document URLs and get back converted text. **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/convert/source \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "sources": [ { "kind": "http", "url": "https://example.com/document.pdf" } ] }' ``` **Python** ```python import requests response = requests.post( "https://api.2kw.ai/v1/convert/source", headers={ "Content-Type": "application/json", "Authorization": "Bearer sk_your_api_key" }, json={ "sources": [ { "kind": "http", "url": "https://example.com/document.pdf" } ] } ) doc = response.json()["documents"][0] print(doc["mdContent"]) ``` **TypeScript** ```typescript const response = await fetch( "https://api.2kw.ai/v1/convert/source", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer sk_your_api_key", }, body: JSON.stringify({ sources: [ { kind: "http", url: "https://example.com/document.pdf", }, ], }), } ); const data = await response.json(); console.log(data.documents[0].mdContent); ``` ### Response ```json { "documents": [ { "filename": "document.pdf", "mdContent": "# Document Title\n\nExtracted content..." } ], "status": "SUCCESS", "errors": [], "processingTime": 1.234 } ``` ### Source Types Each source in the `sources` array needs a `kind` field: **HTTP source** —fetch a document from a URL: ```json { "kind": "http", "url": "https://example.com/document.pdf", "headers": { "Authorization": "Bearer external-token" } } ``` The optional `headers` field lets you pass authentication or custom headers to the source server. **Base64 source** —send the document content directly: ```json { "kind": "base64", "content": "JVBERi0xLjQK...", "filename": "document.pdf", "mimeType": "application/pdf" } ``` ### Batch Conversion Convert multiple documents in a single request: ```json { "sources": [ { "kind": "http", "url": "https://example.com/report.pdf" }, { "kind": "http", "url": "https://example.com/notes.docx" } ], "options": { "abortOnError": false } } ``` When `abortOnError` is `false` (the default), the API processes all documents even if some fail. Failed documents appear in the `errors` array, and the status will be `PARTIAL_SUCCESS` (HTTP 207). ## Convert Uploaded Files ### `POST /v1/convert/file` Upload files directly using `multipart/form-data`: **Request** **cURL** ```bash curl -X POST https://api.2kw.ai/v1/convert/file \ -H "Authorization: Bearer sk_your_api_key" \ -F "files=@document.pdf" \ -F "files=@notes.docx" ``` **Python** ```python import requests response = requests.post( "https://api.2kw.ai/v1/convert/file", headers={"Authorization": "Bearer sk_your_api_key"}, files=[ ("files", open("document.pdf", "rb")), ("files", open("notes.docx", "rb")), ] ) for doc in response.json()["documents"]: print(doc["filename"], doc["mdContent"][:100]) ``` ### File Upload Parameters | Field | Type | Required | Description | |-------|------|----------|-------------| | `files` | file(s) | Yes | One or more files to convert | | `pipeline` | string | No | Processing pipeline: `fast` (default), `ocr`, or `vlm`. A form field or a query parameter (`?pipeline=ocr`, the form the API reference lists); both work | | `options` | JSON part | No | [Pipeline options](#pipeline-options) as a JSON object. Send it as a part with `Content-Type: application/json`, e.g. `-F 'options={"ocrLanguages":["de"]};type=application/json'` | ## OCR and VLM Pipelines For scanned documents, image-heavy PDFs, or complex layouts where the default `fast` pipeline falls short, use the `ocr` or `vlm` pipeline. **Pipeline examples** **OCR Pipeline** ```bash curl -X POST https://api.2kw.ai/v1/convert/source \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "sources": [ { "kind": "http", "url": "https://example.com/scanned-document.pdf" } ], "options": { "pipeline": "ocr", "options": { "ocrLanguages": ["en", "de"], "tableStructure": true } } }' ``` **VLM Pipeline** ```bash curl -X POST https://api.2kw.ai/v1/convert/source \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk_your_api_key" \ -d '{ "sources": [ { "kind": "http", "url": "https://example.com/complex-layout.pdf" } ], "options": { "pipeline": "vlm" } }' ``` ### When to Use Each Pipeline | Scenario | Pipeline | Why | |----------|----------|-----| | Text-based PDFs, Word docs, spreadsheets | `fast` | Fast, reliable text extraction | | Scanned documents, image-heavy PDFs | `ocr` | Layout analysis with OCR for accurate text recovery | | Complex layouts (multi-column, forms, mixed tables) | `vlm` | Understands spatial layout visually | | Plain images with text (PNG, JPG) | `vlm` | Reads the image as a vision model would | **Note — Pipeline speed vs accuracy:** The `fast` pipeline is the fastest and best for most text-heavy documents. The `ocr` and `vlm` pipelines are slower but produce better results for documents where layout matters or text is embedded in images. ## Conversion Options When using the `/source` endpoint, you can pass an `options` object: **Top-level options:** | Field | Type | Default | Description | |-------|------|---------|-------------| | `pipeline` | string | `"fast"` | Processing pipeline: `fast`, `ocr`, or `vlm` | | `timeout` | number | `120` | Timeout per document in seconds | | `abortOnError` | boolean | `false` | Stop batch on first error | | `extractMetadata` | boolean | `true` | Include document metadata in response | | `options` | object | — | [Pipeline options](#pipeline-options), see below | ### Pipeline Options The nested `options.options` object tunes the output and the `ocr`/`vlm` pipelines. On `/file` endpoints, send the same object as the `options` part. Every field is optional; leave one out to use the pipeline's default. | Field | Type | Applies to | Description | |-------|------|------------|-------------| | `outputFormats` | array | all pipelines | Which [outputs](#output) to return: `MD`, `TEXT`, `JSON`, `HTML`. Default: `["MD", "TEXT"]` | | `imageExportMode` | string | `ocr`, `vlm`; on `fast`, email attachments that need OCR | `placeholder` (default) or `embedded` | | `ocrLanguages` | array | `ocr`, `vlm` | OCR languages, for example `["en", "de"]` | | `tableStructure` | boolean | `ocr`, `vlm` | Detect table structure | | `maxPages` | number | `ocr`, `vlm` | Maximum number of pages to process | | `imagesScale` | number | `ocr`, `vlm` | Scale factor for exported images | | `pictureClassification` | boolean | `ocr`, `vlm` | Classify pictures in the document | For example, to get only Markdown and HTML back: ```json { "sources": [{ "kind": "http", "url": "https://example.com/report.pdf" }], "options": { "options": { "outputFormats": ["MD", "HTML"] } } } ``` ## Async Conversion For large documents or batch jobs, use the async endpoints to avoid timeouts. ### Start an Async Job ### `POST /v1/convert/source/async` Same request body as the sync endpoint. Returns `202 Accepted` immediately with a task ID, a `Location` header pointing at the task (`/v1/convert/tasks/{taskId}`), and `Retry-After: 5`: ```json { "taskId": "task-uuid", "taskType": "CONVERT", "taskStatus": "pending", "taskMeta": { "numDocs": 2, "numProcessed": 0, "numSucceeded": 0, "numFailed": 0 } } ``` `taskStatus` is one of `pending`, `processing`, `success`, `partial_success`, or `failure`, and once the task has finished it matches the result's own `status`: `partial_success` means some documents or attachments could not be converted and are listed in the result's `errors`. `taskMeta` counts the files you sent: `numDocs` all of them, `numProcessed` those finished so far, and of these `numSucceeded` those that produced at least one document and `numFailed` those that produced none. A task that fails before it reaches a file, or stops early because of `abortOnError`, leaves the remaining files out of all three counts. An email whose attachments become separate documents still counts as one file. ### Poll Task Status ### `GET /v1/convert/tasks/{taskId}` Check whether the task is still running: ```bash curl https://api.2kw.ai/v1/convert/tasks/{taskId} \ -H "Authorization: Bearer sk_your_api_key" ``` The response has the same shape as above. Add `?wait=30` for long polling (at most 60 seconds). ### Get the Result ### `GET /v1/convert/tasks/{taskId}/result` Once `taskStatus` is `success`, `partial_success` or `failure`, fetch the full result. It has the same shape as a [synchronous response](#response): ```bash curl https://api.2kw.ai/v1/convert/tasks/{taskId}/result \ -H "Authorization: Bearer sk_your_api_key" ``` **Note — File uploads can be async too:** Use `/v1/convert/file/async` with the same multipart format. You'll get back a task ID to poll. ## Supported Formats ### Input | Format | MIME Type | fast | ocr | vlm | |--------|-----------|------|-----|-----| | PDF | `application/pdf` | yes | yes | yes | | DOCX | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | yes | yes | yes | | XLSX | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | yes | yes | yes | | PPTX | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | yes | yes | yes | | HTML | `text/html` | yes | yes | yes | | Markdown | `text/markdown` | yes | yes | yes | | CSV | `text/csv` | yes | yes | yes | | TXT | `text/plain` | yes | — | — | | MSG (Outlook) | `application/vnd.ms-outlook` | yes | — | — | | EML | `message/rfc822` | yes | — | — | | ZIP | `application/zip` | yes | — | — | | Images | `image/*` | — | yes | yes | | AsciiDoc | `text/asciidoc` | — | yes | yes | | DXF (AutoCAD) | `image/vnd.dxf` | yes | — | — | | GEO (TRUMPF) | `application/vnd.trumpf.geo` | yes | — | — | **Note — Container formats:** ZIP archives and email files (MSG, EML) are automatically expanded — each contained file is processed individually through the appropriate pipeline. Nested containers (e.g., a ZIP containing emails with attachments, or ZIPs within ZIPs) are handled recursively. ### Output | Format | Key in Response | Description | |--------|----------------|-------------| | `MD` | `mdContent` | Markdown with headings and structure preserved | | `TEXT` | `textContent` | Plain text, no formatting | | `JSON` | `jsonContent` | Structured JSON representation | | `HTML` | `htmlContent` | HTML markup | ## Status Codes | Status | HTTP Code | Meaning | |--------|-----------|---------| | `SUCCESS` | 200 | All documents converted | | `PARTIAL_SUCCESS` | 207 | Some documents failed | | `FAILURE` | 422 | All documents failed | | `PENDING` | 202 | Async task queued | | `PROCESSING` | 202 | Async task in progress | --- Source URL: https://docs.2kw.ai/files # Files Upload a file once through the OpenAI-compatible Files API, then attach it to as many agent turns as you need, or ingest it into a knowledge base without a second upload. ## How It Works `POST /v1/files` stores the bytes and answers a file object with an id such as `file_607fe7c92e2b4d599d8f7b1a789dddd8`. From then on you pass the id, never the bytes: an `input_file` part on `/v1/responses` attaches the file to a turn, and the knowledge-base attach endpoint ingests it as a document. Every file has a `purpose`, and the purpose decides its lifecycle: | `purpose` | For | Lifetime | Deletable here | |-----------|-----|----------|----------------| | `agent_input` | Attaching to agent turns | Expires 24 hours after it was last used in a turn | Yes | | `agent_output` | Files an agent's shell wrote to `outputs/`; created by the server only | Kept for 30 days from creation; a turn that names it does not extend that | Yes, by the person who asked for it and by organization admins | | `knowledge` | Ingesting into a [knowledge base](/knowledge-bases#uploading-documents) | Never expires; belongs to the knowledge document | No, `409 file_in_use` | A file reference in a response (`output_files`) carries the file's `expires_at`, so a client can say a file is gone before it asks for it. A read after that time answers `410 file_expired`. There is no format allowlist at upload: any file up to the size cap is stored. Whether a file is useful is decided where it is used — a tool that cannot read a format refuses it when it is called. ## Uploading a File ### `POST /v1/files` A `multipart/form-data` request with two fields, `file` and `purpose`. `purpose` is read as a request parameter, so the query form the API reference lists, `POST /v1/files?purpose=agent_input`, works as well as the form field shown here. ```bash curl -X POST https://api.2kw.ai/v1/files \ -H "Authorization: Bearer sk_your_api_key" \ -F "purpose=agent_input" \ -F "file=@quote.eml;type=message/rfc822" ``` ```json { "id": "file_607fe7c92e2b4d599d8f7b1a789dddd8", "object": "file", "bytes": 84, "created_at": 1790322596, "expires_at": 1790408996, "filename": "quote.eml", "purpose": "agent_input" } ``` `created_at` and `expires_at` are Unix seconds. `expires_at` is `null` for a `knowledge` file, which never expires. The stored `filename` is the uploaded name with any directory part, control characters and anything past 255 characters removed. A `purpose` other than `agent_input` or `knowledge` is refused. That includes `agent_output`: those files are written by an agent's sandbox, never uploaded. ```json { "error": { "message": "Invalid or not permitted purpose: assistants", "type": "invalid_request_error", "param": "purpose", "code": "invalid_purpose" } } ``` ## Retention An `agent_input` file lives for **24 hours** from its last use. Every `/v1/responses` request that names the file in its own `input` resets `expires_at` to 24 hours from that moment, so a file you keep attaching never runs out. **Note — Only the current request's files are renewed:** A file named in the conversation's history — an earlier turn replayed through `previous_response_id` or a conversation — is shown to the model again but is **not** renewed. An attachment from the start of a long conversation therefore lapses 24 hours after the turn that last sent it, even while the conversation goes on. The model then sees it marked as no longer available. Send the id again, or upload the file again, to bring it back. Once a file is past `expires_at` or deleted, every read of it answers `410 file_expired`. A background sweep runs every 15 minutes: it marks lapsed files deleted and removes the bytes of files that have been deleted for more than **7 days**. Until the sweep has run, a lapsed file still appears in `GET /v1/files` while `GET /v1/files/{fileId}` already answers `410`. A `knowledge` file has no expiry. Its lifecycle belongs to the knowledge-base document it was ingested into; deleting the document is how it goes away. ## Reading and Deleting ### `GET /v1/files/{fileId}` Returns the file object, in the same shape as the upload response. ### `GET /v1/files/{fileId}/content` Streams the bytes as a download: ```http HTTP/1.1 200 Content-Type: message/rfc822 Content-Length: 84 Content-Disposition: attachment; filename="=?UTF-8?Q?quote.eml?="; filename*=UTF-8''quote.eml X-Content-Type-Options: nosniff ``` `Content-Type` is the type declared at upload, or `application/octet-stream` when none was. The response is always an attachment with `nosniff`, so an uploaded HTML or SVG file is downloaded, never rendered. ### `GET /v1/files/{fileId}/preview` Serves a raster preview of a file, for the caller that requested it. The answer is the file itself when its first bytes say it is a PNG, JPEG, WebP or GIF of at most 20 MB, and otherwise its `.preview.png` companion, if the sandbox wrote one beside it. The type is decided by the bytes, never by the file's name or its stored `Content-Type`. A file that is neither, a `.png` whose bytes are HTML included, answers `404` with the code `no_preview`. The response is an attachment with `nosniff` and `Content-Security-Policy: default-src 'none'; sandbox`, so a preview is never rendered on the API's origin. ### Held files An agent whose shell is set to `outputs: held` (see "Delivered files" under [Tools](/agents/tools#tools)) writes `agent_output` files that are **held** until the person who asked for them approves handing them over. A held file: - cannot be downloaded: `GET /v1/files/{fileId}/content` answers `409 file_held`, for the requester and for an admin alike; - is visible only to the person who asked for it. `GET /v1/files/{fileId}` and the listing show it to them and answer `404` to everyone else, and `/preview` answers that person and no one else; - can be deleted by that person and by organization admins, but an admin cannot read it; - cannot be named in an `input_file` from another conversation: the request is refused with `409 file_held` before the model runs. ```json { "error": { "message": "File file_607fe7c92e2b4d599d8f7b1a789dddd8 is held until its requester delivers it", "type": "invalid_request_error", "param": "id", "code": "file_held" } } ``` The agent reads its own held files freely inside the conversation that produced them. A held file's name and size are visible to anyone who can read that conversation, because the tool result that reports them is part of it. Delivering the files ends the hold: from then on `/content` answers `200`. ### `DELETE /v1/files/{fileId}` Deletes an `agent_input` file: ```json { "id": "file_607fe7c92e2b4d599d8f7b1a789dddd8", "object": "file", "deleted": true } ``` The delete takes effect at once — reads and `/v1/responses` answer `410 file_expired` from then on — and the bytes are removed 7 days later. A `knowledge` file cannot be deleted here: ```json { "error": { "message": "File file_4e8282efb2f94ea2a9a33e43f513a58e belongs to a knowledge base; delete the document instead", "type": "invalid_request_error", "param": null, "code": "file_in_use" } } ``` ### Not found versus gone `404` means the id is unknown **to you**: it covers an id that never existed, a file of another organization, and a file uploaded by another end user of an [embedded panel](/embeddable-surface). All three answer the same body, so a 404 never reveals that a file exists. `410 file_expired` means the file was yours and has been deleted or has passed its lifetime. ```json { "error": { "message": "No such file: file_00000000000000000000000000000000", "type": "invalid_request_error", "param": "id", "code": null } } ``` ## Listing Files ### `GET /v1/files` Lists the organization's files that are not deleted, newest first, in OpenAI's list envelope. | Query parameter | Meaning | |-----------------|---------| | `purpose` | `agent_input`, `agent_output` or `knowledge`. Any other value answers `400 invalid_purpose`. A held `agent_output` file is listed only for the person who asked for it | | `limit` | How many files to return, `1` to `100`. Default `20` | | `after` | A file id: return the files that come after it. Pass the previous list's `last_id` | | `order` | `desc` (newest first, the default) or `asc` | ```bash curl "https://api.2kw.ai/v1/files?purpose=agent_input&limit=2" -H "Authorization: Bearer sk_your_api_key" ``` ```json { "object": "list", "data": [ { "id": "file_607fe7c92e2b4d599d8f7b1a789dddd8", "object": "file", "bytes": 84, "created_at": 1790322596, "expires_at": 1790408996, "filename": "quote.eml", "purpose": "agent_input" }, { "id": "file_aad6de1928cc4966886107337d747c94", "object": "file", "bytes": 84, "created_at": 1790322587, "expires_at": 1790408987, "filename": "quote.eml", "purpose": "agent_input" } ], "first_id": "file_607fe7c92e2b4d599d8f7b1a789dddd8", "last_id": "file_aad6de1928cc4966886107337d747c94", "has_more": true } ``` While `has_more` is `true`, ask again with `after` set to `last_id`; the OpenAI SDKs' auto-pagination does this for you. An `after` that names no file of the organization answers `404`. The list covers the whole organization; it is not available to chat-only users or to the embedded panel (see [Permissions](#permissions)). ## Limits and Quotas | Limit | Value | Applies to | Error | |-------|-------|------------|-------| | File size | 25 MB | Every upload | `413 file_too_large` | | Active files | 50 `agent_input` files not yet deleted | Each end user of an embedded panel | `429 file_quota_exceeded` | | Upload volume | 200 MB in any rolling 24 hours | Each end user of an embedded panel | `429 file_quota_exceeded` | | File references | 100 distinct `file_id`s per `/v1/responses` request | Every turn | `400 invalid_file` | Uploads with an API key or a signed-in member count against the size cap only; the two quotas bound each end user of an [embedded panel](/embeddable-surface) separately. ```json { "error": { "message": "File is 27000000 bytes; the limit is 26214400 bytes", "type": "invalid_request_error", "param": "file", "code": "file_too_large" } } ``` A `429` carries `"type": "rate_limit_error"`. An end user frees quota by deleting files or waiting for them to expire; the rolling byte budget frees up as uploads age past 24 hours. ## Using a File in a Turn Reference an `agent_input` file with an `input_file` part in a message on [`POST /v1/responses`](/agents#running-an-agent): ```json { "type": "message", "role": "user", "content": [ { "type": "input_text", "text": "Create an inquiry from this email." }, { "type": "input_file", "file_id": "file_607fe7c92e2b4d599d8f7b1a789dddd8" } ] } ``` Only `file_id` is accepted. `file_data` and `file_url` are refused with `400 unsupported_file_source`: upload the bytes first, then reference the id. **Every file is checked before the turn runs.** Each `file_id` in the request must be a well-formed id, belong to your organization (and to the same end user, for a panel caller) and be live. The first one that fails ends the request with an error and nothing is sent to the model, so a bad reference costs no tokens: ```json { "error": { "message": "File file_607fe7c92e2b4d599d8f7b1a789dddd8 has expired or was deleted", "type": "invalid_request_error", "param": null, "code": "file_expired" } } ``` The files that pass are renewed for another 24 hours (see [Retention](#retention)). ### What the model sees Each attached file becomes one line after the message's text, with filename, type, size and id: ```text Create an inquiry from this email. [Attached file: quote.eml · message/rfc822 · 84 B · id=file_607fe7c92e2b4d599d8f7b1a789dddd8] ``` The model gets the metadata, not the file's contents. A file from the conversation's history that has lapsed since is rendered as `[Attached file: quote.eml · id=file_… · no longer available]`, so the model does not try to use it. An attached image (`image/jpeg`, `image/png`, `image/webp` or `image/gif`, up to 20 MB) additionally reaches the model as a picture, next to its line, so the model can describe or read it. The ten most recent images of a conversation are sent this way on every turn; older ones, larger files and other types keep the line only. If the model does not accept image input, the turn is retried once with the line only and the model is told it cannot see images. ### Handing a file to a tool Whenever a turn carries files or offers a connector tool, the model is told it can hand a file's bytes to a tool by setting a base64 argument to the string `backbone:file:{id}` — the whole argument value, nothing around it. A placeholder inside longer text stays text. Where that placeholder is replaced depends on where the tool runs: | Tool | Receives | |------|----------| | An [MCP connector](/agents/connectors#connectors-mcp-servers) tool | The file's bytes, base64, wherever the placeholder sits in the arguments. Backbone substitutes them after the call is approved and just before it is sent | | A [relay tool](/embeddable-surface#relay-tools) of an embedded panel | The file's bytes, base64. The panel fetches them with `GET /v1/files/{fileId}/content` and substitutes them before the call reaches your page | | A [function tool](/agents/tools#tools) (your webhook) | Nothing: the call is refused, and the model is told this tool cannot take a file | | A client-executed tool in your own client | The literal string `backbone:file:{id}`. Fetch the bytes yourself with `GET /v1/files/{fileId}/content` if you need them | For a connector tool: - **Whose files.** Only the requester's own: files they uploaded, and files an agent produced for them, such as a file a connector returned or a sandbox output. A colleague's upload is not sent, even to someone who can read it; upload it yourself to send it. - **Limits.** At most 10 files and 5 MiB of files per call. A file named twice in one call counts twice toward the call's size, and so do large other arguments. - **Exact bytes.** The bytes are checked against the stored file before they are sent. - **Approval.** The automatic approver never decides a call that carries a file; it goes to a person. A tool set to *Always allow*, or covered by a [remembered approval](/agents/approvals#approvals), sends a file without asking, as it runs every such call without asking. - **When a file cannot be sent**, the call fails, nothing reaches the connector, and the model reads one of these codes: `file_not_found` (not the requester's file, or unknown), `file_held` (an output not yet delivered to the user), `file_expired`, `file_unreadable`, `too_many_files`, `file_too_large`, `file_argument_in_header` (the tool cannot take a file in that argument), or `files_busy` (try again later). The stored tool call always keeps the placeholder, so the conversation, the approval and the trace show what the model asked for rather than the file's bytes. Files a connector's tool returned have ids too: see [Files in tool results](/agents/connectors#connectors-mcp-servers). ## Knowledge Documents from a File Upload with `purpose=knowledge`, then ingest the file into a knowledge base by id with `POST /v1/knowledge-bases/{knowledgeBaseId}/documents/attach` and `{"fileId": "file_…"}`. The request, its answers and the ingestion that follows are on the [Knowledge page](/knowledge-bases#uploading-documents). A `knowledge` file can only be uploaded by an organization member or an API key, never from an embedded panel. It cannot be deleted through `/v1/files`; delete the knowledge document instead. ## Files from the Embedded Panel The [embeddable surface](/embeddable-surface) panel uploads what the user drops, pastes or picks with the paperclip at once, with `purpose=agent_input` and the panel's own signed assertion, and sends the resulting ids as `input_file` parts with the message. The panel refuses a file over 25 MB, and more than 10 files on one message, before anything is uploaded. Files uploaded from a panel belong to the end user who uploaded them. Another end user of the same installation gets `404` for them, and the per-end-user quotas in [Limits and Quotas](#limits-and-quotas) apply. When the model hands such a file to a relay tool, the panel substitutes the bytes as described in [Handing a file to a tool](#using-a-file-in-a-turn). ## Permissions | Caller | Upload | Retrieve, content, delete | List | |--------|--------|---------------------------|------| | `OWNER`, `ADMIN`, `MEMBER` | Both purposes | Every file of the organization | Yes | | `VIEWER` | No | No | Yes | | `USER` (chat-only) | Both purposes | Only files they uploaded; others answer `404` | No | | Embedded panel end user | `agent_input` only | Only files they uploaded; others answer `404` | No | An API key acts with the organization role it was created with. ## Errors Errors on `/v1/files` use the OpenAI error envelope: `{ "error": { "message", "type", "param", "code" } }`. | Status | `code` | When | |--------|--------|------| | `400` | `invalid_purpose` | Unknown `purpose` on upload or list, or `knowledge` from an embedded panel | | `404` | `null` (`param: "id"`) | Unknown id, another organization's file, or another end user's file | | `409` | `file_in_use` | `DELETE` on a `knowledge` file | | `410` | `file_expired` | The file was deleted or passed its lifetime | | `413` | `file_too_large` | The file is over 25 MB | | `429` | `file_quota_exceeded` | An embedded panel end user is over the active-file count or the daily upload volume | On `/v1/responses`, file references add three: | Status | `code` | When | |--------|--------|------| | `400` | `invalid_file` | Malformed, unknown, foreign or out-of-scope `file_id`, a part with no source, or more than 100 distinct files | | `400` | `unsupported_file_source` | `file_data` or `file_url` instead of `file_id` | | `410` | `file_expired` | A referenced file was deleted or passed its lifetime | --- Source URL: https://docs.2kw.ai/cli # CLI Manage your 2kw.ai schemas, prompts, extractions, and more — straight from the terminal. ## Overview The 2kw.ai CLI gives you full access to the platform without leaving your terminal. Define schemas, run extractions, convert documents, chat with AI models, and manage your entire workflow from the command line. The package installs one program under three names: `2kw`, `backbone` and `bb`. They are the same command, so `bb schemas list` and `backbone schemas list` do what `2kw schemas list` does. The examples on this page use `2kw`, the name the CLI itself prints in its hints. ## Installation Install the CLI globally via npm: ```bash # Latest stable release npm install -g @2kw/ai # Pre-release (dev channel) npm install -g @2kw/ai@dev ``` The CLI will notify you when a newer version is available. Verify the installation: ```bash 2kw --version ``` ## Updating Update to the latest version: ```bash npm update -g @2kw/ai ``` The CLI automatically checks for newer versions and will notify you when an update is available. ## Authentication #### Step 1 — Sign in through the browser ```bash 2kw auth login ``` This prints a code, opens your browser and signs you in as a member of your organization. Credentials are stored locally in `~/.config/backbone/config.json`, and each command mints a short-lived organization token from them. #### Step 2 — Or use an API key ```bash 2kw auth login --api-key sk_... ``` Create the key first on the **API Keys** page in the sidebar. A key is what CI and other unattended runs want; `--manual` prompts for one instead of taking it on the command line. #### Step 3 — Verify ```bash 2kw auth status ``` This validates your credentials against the API and shows available models. You can also authenticate via environment variables or CLI flags: | Method | Example | |--------|---------| | Environment variables | `AI_2KW_API_KEY=sk_...` `AI_2KW_BASE_URL=https://...` | | CLI flags | `--api-key sk_... --base-url https://...` | | Local config file | `.backbone` JSON file in the current directory | Either credential drives almost every command. The exception is `connectors public-hosts`: approving a host your agents may connect to is a person's decision, so those three commands take the browser sign-in only and refuse an API key whatever role it carries. ## Claude Code plugin The 2kw plugin for [Claude Code](https://code.claude.com/docs/en/overview) teaches your coding agent to use this CLI: it can run your 2kw agents, change them, and send its own session traces to 2kw. Its source is public at [github.com/2kw-ai/claude-plugin](https://github.com/2kw-ai/claude-plugin). #### Step 1 — Install the CLI The plugin's skills call the `2kw` command, so it has to be on your `PATH`: ```bash npm install -g @2kw/ai ``` #### Step 2 — Install the plugin Run these two commands inside Claude Code: ```text /plugin marketplace add 2kw-ai/claude-plugin /plugin install 2kw@2kw ``` #### Step 3 — Connect your account Ask Claude to set up 2kw, or invoke the `2kw:init` skill. It checks the CLI version, runs `2kw auth status`, and starts `2kw auth login` when there are no working credentials. The skills use the CLI's credentials and active [context](#contexts), so you never paste a key into the chat. | Skill | What it does | |-------|--------------| | `2kw:init` | Checks that the CLI is installed and recent enough and that your credentials work, and names the fix when they don't. | | `2kw:agents` | Runs your 2kw [agents](/agents) on a task, shows you every approval pause to decide, and creates, changes, versions and exports agents from `agent.yaml`. Say "ask the invoice agent to…" or "give agent X access to knowledge base K". | | `2kw:trace-claude-code` | Sends Claude Code's own OpenTelemetry traces to your organization, so your coding sessions show up in the [trace viewer](/observability) next to your other traces. | The plugin also registers the 2kw [MCP server](/mcp-server). The skills don't need it, but its tools read the API key from the environment: export `AI_2KW_API_KEY` (and `AI_2KW_BASE_URL` if you don't use `https://api.2kw.ai`) in the shell you start Claude Code from. If the key isn't set, the MCP server doesn't start, and the skills keep working. ### Tracing your Claude Code sessions Ask Claude to "trace Claude Code". The skill asks you once how much to capture, shows the change before it writes anything, then adds the trace export to your global `~/.claude/settings.json`. That file applies to every project you open, client work included. | Level | Captures | |-------|----------| | `minimal` | Trace structure, operation names, timings and status | | `standard` (recommended) | Everything in `minimal`, plus the prompts you type | | `full` | Everything in `standard`, plus tool content: the contents of every file Claude reads, including files you did not write | Quit and relaunch Claude Code afterwards; it reads the setting only at startup. After a few messages, the skill confirms that traces arrive with `2kw tracing list --source-service claude-code`. To stop exporting, ask Claude to turn 2kw tracing off. That change also takes effect after the next restart. **Note — Key in settings.json:** The trace export authenticates with your API key, which is stored in plain text in `~/.claude/settings.json`. On a shared or synced machine, use a dedicated key that you can revoke on its own. Traces show what the agent did. Token counts and cost are not part of them yet. ## Commands ### Global Options | Flag | Description | |------|-------------| | `--api-key ` | Override the API key | | `--base-url ` | Override the base URL | | `--json` | Output raw JSON instead of formatted tables | | `--no-color` | Disable colored output | ### Schemas Define and manage extraction schemas within your organization. ```bash 2kw schemas list 2kw schemas get 2kw schemas create -n "Invoice Schema" 2kw schemas update -n "Updated Name" 2kw schemas delete ``` #### Schema Versions ```bash 2kw schemas versions list --schema 2kw schemas versions create --schema -c '{"type":"object","properties":{}}' 2kw schemas versions get --schema 2kw schemas versions activate --schema ``` #### Schema Labels ```bash 2kw schemas labels list --schema 2kw schemas labels create --schema -n "production" --version-id 2kw schemas labels update "production" --schema --version-id 2kw schemas labels delete "production" --schema ``` #### Schema Testing ```bash # Validate a JSON Schema definition 2kw schemas validate -c '{"type":"object"}' # Test extraction against sample text 2kw schemas test -t "John Doe, john@example.com" -m gpt-5.1 # Resolve active or labeled schema 2kw schemas resolve 2kw schemas resolve -l production ``` ### Prompts Version-controlled prompt templates with variables and labels. ```bash 2kw prompts list 2kw prompts get 2kw prompts create -n "My Prompt" 2kw prompts update 2kw prompts delete # Compile template with variables 2kw prompts compile --vars '{"name":"World"}' # Test prompt against a model 2kw prompts test -m gpt-5.1 ``` Prompts also support `versions` and `labels` sub-commands with the same syntax as schemas. ### Extractions Extract structured data from text, files, or images. ```bash # Extract from text 2kw extractions create --schema -m gpt-5.1 --text "John Doe, john@example.com" # Extract from a file 2kw extractions create --schema -m gpt-5.1 --file ./invoice.pdf # Extract from images 2kw extractions create --schema -m gpt-5.1 --images ./page1.png ./page2.png # Async extraction 2kw extractions create --schema -m gpt-5.1 --text "..." --async # List and manage 2kw extractions list --status COMPLETED 2kw extractions get 2kw extractions rerun 2kw extractions rerun --status FAILED --schema-version # queue up to 100 # Estimate token usage 2kw extractions estimate --schema -m gpt-5.1 --text "..." ``` ### Document Conversion Convert documents (PDF, DOCX, images, etc.) to Markdown, text, HTML, or JSON. ```bash # Convert local files 2kw convert file ./document.pdf --format md 2kw convert file ./scan.png --pipeline vlm --format md # Convert from URL 2kw convert url https://example.com/doc.pdf --format text # Async conversion 2kw convert file ./large.pdf --format md --async 2kw convert status 2kw convert status --wait # Block until done 2kw convert result ``` ### AI Gateway Chat with AI models, run agents through the Responses endpoint, create embeddings, and list available providers. ```bash # Chat completion 2kw ai chat -m gpt-5.1 --message "Explain recursion in one sentence" 2kw ai chat -m gpt-5.1 --message "Hello" --system "You are a pirate" --temperature 0.9 # Responses endpoint — direct model call or a stored agent 2kw ai respond "Summarize this ticket" --model gpt-5.1 2kw ai respond "Summarize this ticket" --agent support-bot --conversation conv_123 echo "Summarize this ticket" | 2kw ai respond --agent support-bot # With --agent, --model picks another model from the agent's list for this request 2kw ai respond "Summarize this ticket" --agent support-bot@prod --model gpt-4.1-mini # List available models 2kw ai models ``` ### Agents Run agents and manage them from a file. Commands to list, create, version and label agents also exist (`2kw agents --help`). ```bash # Run an agent on a task (name or id, optionally @label) 2kw agents run invoice-checker "Check invoice INV-204" 2kw agents run invoice-checker@prod --input-file task.txt --json 2kw agents run invoice-checker "And the next one?" --conversation conv_123 2kw agents run invoice-checker@prod "Check invoice INV-204" --model openai/gpt-4o 2kw agents run invoice-checker@prod "Check invoice INV-204" --mode plan # look, don't change anything # See which agents are published, without their configuration 2kw agents catalog --search invoice # See which skills an agent's latest published version binds 2kw agents skills --agent # Create an agent (published as version 1, runnable at once) or add a version, with an ordered model list; the first model is the default 2kw agents create --name invoice-checker --models azure-openai/gpt-4.1,openai/gpt-4o 2kw agents versions create --agent -m azure-openai/gpt-4.1 -m openai/gpt-4o # Resume a run that paused for approval: decide every pending approval in one call, # naming the agent as the run did, with the same @label and #model 2kw agents decide invoice-checker@prod --response resp_91 --approve-all 2kw agents decide invoice-checker@prod --response resp_91 --reject-all --reason "wrong PO" 2kw agents decide invoice-checker@prod --response resp_91 --approve apreq_1 --reject apreq_2 2kw agents decide invoice-checker@prod --response resp_91 --approve-all --mode ask # leave plan mode and approve # Answer a run that paused on a client tool call (exit code 4): run the tool yourself, # then send its result, or say it failed, together with any pending approvals 2kw agents decide invoice-checker@prod --response resp_92 --output c9=@result.json 2kw agents decide invoice-checker@prod --response resp_92 --fail c9='not available in this client' # What the policy gate would do to each tool call, without running the agent 2kw agents policy invoice-checker 2kw agents policy invoice-checker --version --tool create_note --installation 2kw agents policy invoice-checker --mode plan # as a conversation in plan mode would be gated # Configure from agent.yaml 2kw agents init invoice-checker -o agent.yaml 2kw agents schema > agent.schema.json 2kw agents apply -f agent.yaml --dry-run 2kw agents apply -f agent.yaml --label prod 2kw agents export invoice-checker@prod -o prod.yaml ``` `agents list` returns the full configuration and needs `VIEWER` or above. `agents catalog` is the chat-facing read: published agents only, as id, name and description, with no instructions, tools, options, HITL policy or model list. `agents skills` lists the skills an agent's latest published version binds, as name, description, version, ref and source plugin; an agent with no published version lists none. These two are the agent commands a chat-only `USER` key may run. An agent carries an ordered model list. `agents create`, `agents update` and `agents versions create` take it as `--models a,b` or as a repeated `--model`; a single `--model` still sets one model. `agents list` and `agents versions list` show the `models` column. `run --model ` (or `ai respond --agent … --model `) runs one request on another model from the list by sending `#`; the server refuses a model that is not on the list. `decide` continues on the label and model it is given, so pass the same `@label` and `#model` the run used (`invoice-checker@prod#openai/gpt-4o` after `run invoice-checker@prod --model openai/gpt-4o`); the run's `next` command already carries both. Without `#model` the continuation runs on the default model. `--reason` is recorded on every decision in the call, approvals included. `--mode plan|ask|auto` on `run` and `decide` sets the [conversation mode](/agents/approvals#conversation-mode): `plan` is read-only, `ask` makes every call that needs approval wait for you, `auto` lets the automatic approver decide the calls the agent allows it. The mode is stored on the conversation. Without `--mode` the conversation keeps its mode, or the agent's default mode when it has none. A mode the agent does not offer is refused with `conversation_mode_not_offered`, and the error lists the modes on offer. An interactive `run` sends its `--mode` with every approval round. Any other value is a usage error (exit code `2`). `agents policy` shows the effective tool policy of a version (the latest unless `--version` names one): for each tool the verdict, the composed action, the policy class, where the class came from and the matched rules. `--tool` answers for one tool, `--installation` resolves that installation's module tool catalog, and `--mode plan|ask|auto` answers as a conversation in that mode would be gated, with `effectiveMode` and where it came from; a mode that changed the answer shows as `conversation_mode.` in the matched rules (see [Conversation Mode](/agents/approvals#conversation-mode)). An invalid `--mode` is a usage error (exit code `2`) and sends nothing. `run` asks for each approval and continues only when stdin and stderr are both terminals and none of `--json`, `--raw` or `--no-input` is set. Otherwise it stops at the first pause; `--json` prints the result as a run envelope: | Field | Meaning | |-------|---------| | `status` | `completed`, `requires_approval`, `requires_tool_output` or `incomplete` | | `mode` | The conversation mode the run ran under: `plan`, `ask`, `auto`, or `null` when none is set | | `agent`, `version` | The agent and version that ran | | `text` | The agent's answer so far | | `responseId`, `conversationId` | Pass to `decide --response` and `run --conversation` | | `toolCalls` | Tools that ran, each with its `status` | | `pendingApprovals` | Calls waiting for a decision: `approvalId`, `tool`, `arguments`, `policyClass`, `reason`, the approver's reason when a request was escalated (null otherwise), and `preview`, an object or null: set only for the built-in `SkillsApply`, with the same shape as the `preview` of a `backbone:approval_request` (see [Editing Skills in Chat](/skills#editing-skills-in-chat)) | | `pendingToolCalls` | Calls only a client application can answer. A connector's `mcp__