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.
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)
├── <script src="https://<surface-origin>/v1/embed.js" …> ← the loader
│ • mounts an <iframe> (inline or drawer)
│ • asks YOUR mint endpoint for short-lived assertions
│ • runs relay tool calls through YOUR tool proxy
└── <iframe src="https://<surface-origin>/panel?…"> ← the panel
• the chat UI, calls 2kw.ai with the assertion as Bearer
• POST https://api.2kw.ai/v1/responses model: "agent/<agentId>"
2kw.ai
• verifies assertions (Ed25519, per installation)
• decides which origins may frame the panel
• stores the tool catalog your application publishes
One credential exists, and it is yours: an Ed25519 signing key your application generates and keeps. Its public half is registered on 2kw.ai. Your application mints a short-lived assertion for each logged-in user; the panel presents it; 2kw.ai verifies it. 2kw.ai never holds a secret for your installation and has no mint endpoint of its own.
The surface origin is the panel app's origin. It is shown with your pairing code and is the src of the script tag; you never configure it separately.
Prerequisites
- An agent with a model configured. An installation is bound to exactly one agent.
- Your application served over HTTPS. The loader needs a secure context; a page on plain
http://mounts the frame and then fails the handshake. - One same-origin JSON endpoint in your application that mints an assertion for the logged-in user (the mint endpoint). Relay tools need one more.
- A Content Security Policy, if you have one, that allows the surface origin in
script-src,frame-srcandconnect-src. - An
ADMINorOWNERcredential in your 2kw.ai organization for the setup calls.
Setting Up
Register an installation
An installation is one deployment of your application: one ERP instance, one intranet. Create it in the console under Agents → your agent → Embed → Add installation, or over the API:
curl -X POST https://api.2kw.ai/v1/installations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_your_api_key" \
-d '{ "name": "production", "description": "Customer ERP, production", "agentId": "agt_…" }'
The response carries a pairing string such as K7PX-4MQ2@https://api.2kw.ai: a single-use code, valid ten minutes, followed by the API base URL your application will call. It also names the surfaceOrigin for the script tag. The installation starts PENDING.
Lost the code, or re-pairing after a key rotation? POST /v1/installations/{id}/pairing-code issues a new one without touching the keys you have.
Enrol from your application
Your application takes the pairing string, generates an Ed25519 keypair if it has none, and redeems the code. No credential is needed for this call:
curl -X POST https://api.2kw.ai/v1/surface/enrol \
-H "Content-Type: application/json" \
-d '{
"code": "K7PX-4MQ2",
"kid": "3f9c1a7e0b2d4c68",
"publicKey": "<raw 32-byte Ed25519 public key, base64>",
"origin": "https://erp.example.com",
"proof": "<base64 Ed25519 signature>"
}'
proof is the signature, with the private key, over the UTF-8 bytes of code\nkid\npublicKey\norigin, where code is upper-cased with the hyphen removed and the other three are exactly as sent.
2kw.ai registers the key, adds the origin, activates the installation and answers:
{
"installationId": "inst_…",
"agentId": "agt_…",
"surfaceOrigin": "https://<surface-origin>",
"backboneOrigin": "https://api.2kw.ai",
"activeKids": ["3f9c1a7e0b2d4c68"]
}
Store all of it. backboneOrigin is the aud claim of every assertion you mint. The API base URL is the part of the pairing string after @.
| Answer | Meaning |
|---|---|
404 | Code invalid, expired or already used. All three on purpose |
400 | The proof does not verify |
409 | Two keys are already active, or the kid is already registered. Revoke one first |
429 | More than ten attempts a minute from one address |
Paste the snippet
The console shows the snippet for each installation. Replace the mint URL placeholder with the path of your mint endpoint before deploying:
<script src="https://<surface-origin>/v1/embed.js"
data-installation="inst_…"
data-agent="agt_…"
data-mint-url="/REPLACE-WITH-YOUR-MINT-URL"
data-mode="drawer"
data-color-scheme="auto"></script>
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:
<div style="height: 640px">
<script src="https://<surface-origin>/v1/embed.js" data-installation="…" data-agent="…" data-mint-url="…"></script>
</div>
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; 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 <data-mint-url>?token=<csrf>
Content-Type: application/json
Accept: application/json
X-Requested-With: XMLHttpRequest
X-Embed-Protocol: 1
X-CSRF-Token: <csrf>
{"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.
{ "assertion": "<compact JWS>", "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 |
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 <assertion with adm: true>
Content-Type: application/json
{
"manifest": "<the manifest JSON as a string, exactly the bytes you signed>",
"signature": "<base64url Ed25519 signature over those bytes>",
"kid": "3f9c1a7e0b2d4c68"
}
- Authenticated with an assertion, not an API key: the installation is taken from the assertion.
kidnames the installation key the signature is checked against.- A byte-identical manifest is accepted idempotently. Answers
{ "content_hash", "synced_at" };403for a bad signature,422for a malformed manifest,409for a manifest older than the one on record. GETon the same path returns the active catalog'scontent_hashand timestamps, or nulls when nothing was synced. The org-side history isGET /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 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.
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 <data-execute-url>?token=<csrf>
{ "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.
Up to 8 rounds of tool calls run per turn. The agent's approval policy 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 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 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.
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.
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 |
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 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 with Azure AI Speech fast transcription on the platform's built-in Azure resource in Sweden Central, 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 | 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.