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.

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-src and connect-src.
  • An ADMIN or OWNER credential in your 2kw.ai organization for the setup calls.

Setting Up

1

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.

2

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

AnswerMeaning
404Code invalid, expired or already used. All three on purpose
400The proof does not verify
409Two keys are already active, or the kid is already registered. Revoke one first
429More than ten attempts a minute from one address
3

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.

AttributeRequiredDefaultMeaning
data-installationyesInstallation id
data-agentyesAgent id. The panel sends model: "agent/{id}"
data-mint-urlyesSame-origin path of your mint endpoint. The loader refuses to boot without it; the console's placeholder is not a working value
data-mint-csrfnoCSRF 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-urlnoexecute.php beside the mint URLSame-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-hashnoHash of the tool catalog your application currently serves. Lets the panel warn admins when 2kw.ai holds an older catalog
data-modenoinlineinline fills the box the script sits in; drawer renders a fixed right-hand drawer with a launcher button
data-drawer-widthno640Drawer width in px
data-launchernodefaultnone suppresses the built-in launcher
data-launcher-selectornoWith data-launcher="none": a CSS selector for your own button, looked up once at boot
data-color-schemenoautolight, dark or auto
data-accentnoBrand colour. Contrast-checked; an unreadable accent is used as a surface colour only, or dropped
data-radiusnoCSS length for corner radius
data-font-familynoFont 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.

StatusBodyThe panel shows
401{"error":"session_expired"}"log in again"
403anyThe user may not use the assistant
429any"refusing sign-in requests for now"
5xxany"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:

ClaimTypeValue
issstringInstallation id
substringYour user's id. Identifiers only, never names or e-mail addresses
entstringYour tenant or entity id
audstringThe backboneOrigin from enrolment, byte for byte
admbooleantrue for your admins. Unlocks catalog sync and the drift check
iat, nbf, expnumberLifetime at most 5 minutes
jtistring32 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.
  • 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 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 pathPurpose
GET /v1/installationsList, with search, status and agentId filters
POST /v1/installationsCreate, 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-codeA fresh pairing string
PUT /v1/installations/{id}/originsReplace the allowed origins
GET /v1/installations/{id}/keysList verification keys, revoked ones included
POST /v1/installations/{id}/keysRegister a public key by hand (kid, publicKeyBase64)
DELETE /v1/installations/{id}/keys/{kid}Revoke a key
POST /v1/surface/enrolUnauthenticated: 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.

MessageCause
"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

SymptomCauseCheck
Note in your page after about 10 s: the panel could not be loadedOrigin not registered, installation disabled, or unknown id. One indistinguishable answer by designGET /v1/installations/{id}: status and origins, compared with the browser's location.origin
Same note, origin registered and activePage is not a secure context (http://)isSecureContext in the browser console
Note at boot: data-mint-url must be same-originAbsolute or protocol-relative mint URL pointing elsewhereUse a path
Panel loads, every turn fails, mint requests 404The placeholder mint URL was left in, or your application lives under a subdirectoryOpen the mint URL from the snippet in the browser with your session
Panel says "log in again" though the user is logged inMint answered a redirect, an HTML body or 401curl -i the mint URL with the session cookie; check the CSRF token reaches it
401 invalid_assertion on every callaud differs from backboneOrigin; kid not registered; signing with a new key before registering it; sub or ent sent as numbersDecode the JWS payload; compare kid with GET …/keys
403 from 2kw.aiThe end user hit a path outside /v1/responses, /v1/conversations, /v1/dictation, /v1/surface
Relay tool calls never run; the model apologisesNo catalog synced for this agent and installationGET /v1/surface/agents/{agentId}/tool-catalog with an admin assertion
Catalog sync 403Signature computed over re-encoded bytes, or with a revoked or unregistered keySign the exact string you send in manifest
Catalog sync 409generated_at older than the recordRegenerate, or stop sending generated_at
The panel is invisible in inline mode, no errorThe snippet's parent has no heightGive the wrapper a height
No microphone buttonYour 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 messageThe site settings for your origin
The microphone button answers "Dictation is unavailable right now"The transcription service is unavailable or timed outTry again later
A connector's sign-in tab says the sign-in could not be handed back to the panelThe server's sign-in page sends Cross-Origin-Opener-Policy: same-origin; see ConnectorsThe 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.

Was this page helpful?