Appearance
Embed a chat agent
Put a Kadmo chat agent on your own site: a chat dock in the corner, a panel you place yourself, an inline thread, or a ⌘K palette that searches your own data and answers questions in the same box. This page is the whole integration contract: with a publishable key and what is below, a developer can ship the widget on their own origin. It covers installing the packages, the three rules that keep an integration safe, signed sessions, your Content Security Policy, origins, caps, key rotation and every error code.
What you need
| A chat agent | Created and activated in the app under Chat agents (app.kadmo.ai/chat-agents) by an account admin |
| A publishable key | kc_pk_…, minted on the agent's Keys & embed tab and locked to the origins you name |
| An origin | The scheme and host your page is served from. A key allows nothing until it lists one |
A second key type exists: kc_sk_…, a secret key. It is only for signed sessions, and only your backend ever uses it; it must never appear in a browser. The Keys & embed tab mints publishable keys only. Skip the secret key entirely unless you need signed sessions.
A chat agent's answers are model calls on the Kadmo provider, paid from your account's credit.
Mint a key
- Open Chat agents, pick the agent, and open the Keys & embed tab.
- Under Publishable keys, click Mint a key.
- Give it a Label that tells two live keys apart during a rotation, for example Production — example.com.
- Under Allowed origins — one per line, list every origin that serves the widget (see Allowed origins).
- Click Mint the key.
The key is shown once, together with the install snippet with this key already in it. Copy both, then click I have copied it — close. The key cannot be shown again; if you lose it, mint another.
The tab's Install the widget block always shows the snippet (with a placeholder instead of a key) and the Content security policy line your site needs, each with a Copy button.
Install
bash
pnpm add @kadmo/chat @kadmo/core react react-domThe snippet the Keys & embed tab hands you does three things:
| Step | What |
|---|---|
| 1 | Import @kadmo/core/styles.css first: the tokens and every part. |
| 2 | Import @kadmo/chat/styles.css second: the boxes those parts sit in. |
| 3 | Render the KadmoChat component from @kadmo/chat with one prop, token, set to your kc_pk_… key. |
That is the whole integration: a token, two stylesheets and one element. The stylesheet order matters, because core's tokens must be in the document before the shell that uses them.
The @kadmo packages are not on the public npm registry yet
The pnpm add line above does not resolve today. The snippet is the shape your integration takes; ask Kadmo for a tarball in the meantime. Nothing else on this page changes when the packages are published.
react and react-dom are required peers of @kadmo/chat. They are optional peers of @kadmo/core, whose root entry is a framework-free client (createKadmo) you can drive yourself; its options are publishableKey, apiBase, entry, endUser, host, session and onSessionExpired.
The token is the configuration
There is nothing else to configure. The agent's name, avatar, welcome message, suggested questions, language, theme, feature flags and caps all arrive at boot from the gateway, keyed on the token. Changing any of them is an action in the app, not a redeploy of your site.
The widget talks to exactly one host, the gateway at https://gw.kadmo.ai, under /embed/v1, and it sends exactly three headers:
| Header | What it carries |
|---|---|
Authorization | Bearer kc_pk_…, the publishable key |
Content-Type | application/json |
X-Kadmo-User | Who is asking. See the end-user descriptor |
That list is the gateway's CORS allowlist. A fourth header fails the browser's preflight with no useful error anywhere, which is why everything contextual rides in the request body or the query string, and why a signed session travels inside X-Kadmo-User instead of a header of its own.
Three rules
Everything else on this page is mechanics. These three are easy to get wrong, and each is a real failure rather than a style preference.
1. The publishable key is not a secret
It is designed to sit in a frontend bundle. Anyone who opens the developer tools on your site can read it, and that is fine: it is not what protects your account. Four things are:
- It binds one agent. The key resolves to a single chat agent and cannot reach another, or any other part of your account.
- It is origin-locked. In a browser it works only on the origins you listed.
- It is capped. Rate caps at the edge, and turn, volume and spend caps on the agent, bound what it can cost you. See Caps and what the visitor sees.
- It is revocable. One click, effective immediately, with no grace period.
So publish it freely, and keep the allowlist tight and the caps honest. Do not extend that reasoning to kc_sk_…: the secret key mints sessions for any end user of the agent, so in a bundle it is an impersonation key.
2. The origin allowlist binds browsers, not scripts
The gateway compares the Origin header with your key's allowlist. A browser sets that header itself and a page cannot forge it, so for real visitors on real sites the allowlist is a hard control: a stolen key pasted into someone else's page gets 403 origin_not_allowed on its first call.
Outside a browser, Origin is free text. curl, a server or a scraper can send any origin it likes, including one of yours. The allowlist does not stop that and was never able to.
What bounds a key in the wrong hands is the caps and the revoke button. Size the caps for the traffic you expect, watch the agent's Monitoring tab, and revoke on suspicion. Rotating a leaked key is a five-minute job.
3. The end-user id must be opaque and unguessable
In its unsigned form, X-Kadmo-User is a label your site asserts, not a credential. Nothing signs or verifies it. What it does is select a thread list: conversations are scoped by agent and end user and nothing more, and a thread belonging to another end user answers exactly the same 404 as a thread that does not exist.
Together these mean: anyone holding the publishable key, which is anyone at all, who can guess an end-user id can read that person's conversation history. So the id must be opaque and unguessable:
| Id | Verdict |
|---|---|
alex@example.com | No: an address is guessable from a business card |
4172 | No: a sequential database id is guessable by counting |
alex.tan | No: a username is shown in many places |
usr_8Kd2vQ1nPzR7… | Yes: an HMAC of your own user id under a server-side secret |
Compute it on your server, never in the page: an HMAC-SHA-256 of your identity provider's subject claim (prefixed, for example, with end-user:) under the secret you already use for sessions, base64url-encoded and cut to 32 characters (about 192 bits), with a prefix such as usr_.
- Key it on the subject claim, not the e-mail address. An address is re-assignable, and a change would silently split a person's history into two thread lists.
- Use your existing session secret, so the mapping is unguessable outside your servers and rotates when that secret does.
- Keep the id stable for that person forever. It is their history.
When an unguessable id is not enough, for example when conversations contain the person's own account data, do not invent a stronger label: prove the identity with signed sessions.
The shells
@kadmo/chat is one component with three shells:
variant | What it is |
|---|---|
dock (default) | A launcher fixed to the corner, opening a 420×680 panel in a dialog: focus is trapped, Esc closes it, and it fills the screen on phones |
panel | The same panel without a launcher, sized by your container |
inline | The thread and the composer, filling your container. No header, no history |
| Prop | Type | What it does |
|---|---|---|
variant | 'dock' | 'panel' | 'inline' | Which shell. Default dock |
token | string | The kc_pk_… publishable key. Omit it only when a KadmoProvider is above |
apiBase | string | The gateway. Defaults to https://gw.kadmo.ai |
user | EndUserDescriptor | null | The signed-in visitor. null is an explicit anonymous visitor |
locale | 'de' | 'en' | Pin the interface language. Omit it to follow the agent's own |
context | HostContext | (() => HostContext) | The page, reported on every turn. A function is read again on each turn |
theme | Record<string, string> | --kadmo-* overrides on top of the agent's theme |
open / onOpenChange | boolean / (open) => void | Control the dock yourself |
onError | (event) => void | Every refusal, boot included: { code, message, retryAfterSeconds? } |
onThreadChange | (event) => void | The conversation the visitor is in: { threadId, entry } |
paletteHint | boolean | Force the ⌘K hint on or off. Omitted, it follows what is mounted |
className | string | Added to the shell's own root element |
Pass context as a function if your app navigates without full page loads, so each turn reports the page the question was actually asked from.
A visitor who never opens the chat downloads only the launcher: about 3 KB gzipped plus the framework-free client. The conversation runtime loads on the first click. The package's own build enforces this.
Once opened, the dock stays mounted between opens, so closing and reopening returns the visitor to the same thread. Only the header's new chat starts a fresh one.
One provider, two shells
Given a token, a shell builds a client of its own. Mount KadmoProvider yourself when two shells should share one client and therefore one conversation, so a thread opened in the palette continues in the dock. Give the provider the publishableKey (and endUser), and give neither shell a token: the token is exactly what tells a shell to make its own provider.
KadmoProvider is exported from the @kadmo/core/react subpath, the React half of the core package; it is not exported from @kadmo/chat or from @kadmo/core's root.
The trade: a provider you mount has already loaded the conversation runtime, so the deferred launcher no longer applies. On an internal staff surface that is fine. On a public marketing page, mount the dock alone with a token and let it defer.
The command palette
@kadmo/palette is the ⌘K box: one input that is both your quick-search field and the composer, one list that carries your own rows above the assistant's.
bash
npm i @kadmo/palette @kadmo/core cmdkImport @kadmo/core/styles.css, then @kadmo/palette/styles.css, and render KadmoPalette with your token. Its main props beyond those of the chat shells:
| Prop | What it does |
|---|---|
providers | Your quick search: a list of providers, each one object with an id and a search(query, signal) function that returns groups of rows. Optional |
home | Your groups for the empty box, shown first |
onNavigate | Called when one of your rows is chosen and carries no action of its own |
hotkey | ⌘K / Ctrl-K toggles the box. Default true; pass false when you own the shortcut |
onError | A provider of yours threw, or search was refused |
A provider is asked on the same debounce as the assistant, and its rows are laid out above the assistant's. Encode the visitor's query before you put it in a URL.
cmdk is a peer dependency for a reason: its item registry is React context, so your rows register in the box only if your copy of cmdk and the palette's are the same module. With two copies the rows render and the arrow keys never reach them, with no error anywhere.
Already have a ⌘K palette? Use the headless entry, @kadmo/palette/headless, to put the assistant inside your own cmdk box: it exports AskRow, AssistantTurn, HelpRows and PaletteRuntime, and the hooks usePaletteAsk and usePaletteSearch. Wrap them in KadmoProvider and PaletteRuntime. Nothing is needed before the first ⌘K, so load the box with the hotkey.
A host that is not a React application
Astro, Rails, Django, Laravel or a server-rendered template: the widget still mounts as one React island, and four rules carry over.
- One island, not two. Each island is its own React root, and two roots cannot share a provider. A page that wants both the palette and the dock mounts one island with both under one provider, and gives neither shell a
token. Two islands would be two clients and two conversations. - The boundary takes data, not functions. Island props are serialised into the page, and a function does not survive that. The publishable key, the end-user descriptor and the gateway override cross as props; the page-context function,
onNavigateand your search providers are built inside the island. - Resolve the configuration on the server, and gate it there. Decide in one place whether the assistant is on for this request, and require both your feature flag and the key: either alone mounts an island that can only fail its boot. Read runtime settings at request time as strings; a value frozen at build time, or parsed into a number, can read as "off" while every printout says "on".
- The end user is a pseudonym computed on the server: rule 3, in the one place where it is easiest to skip.
Without a framework, the three server-rendered pieces are the same: an empty mount element, the island's props in a JSON script block, and the module script that mounts it, emitted only when your server said yes, so a page without a key never downloads the island.
The end-user descriptor
X-Kadmo-User carries base64url-encoded JSON. The client builds it from the user prop (or the endUser option); you never encode it yourself.
json
{
"id": "usr_8f3a…",
"identity": "asserted",
"display_name": "Alex",
"locale": "de",
"traits": { "role": "admin" }
}| Field | Contract |
|---|---|
id | Required. Opaque, unguessable and stable per person (rule 3). Cut at 200 characters |
identity | anonymous records an anonymous visitor; any other value, or none, records asserted. A descriptor can never claim verified: that comes only from a signed session |
display_name | Optional, cut at 120 characters. Note the underscore |
locale | Optional, cut at 20 characters |
traits | Optional organisational context. See below |
Over-long values are cut, never refused, and a malformed header decodes to an anonymous visitor rather than a 400. A broken descriptor therefore shows up as "my history is empty", not as an error, so check the wiring once by reading end_user back from the boot response.
No descriptor at all is supported. The client creates a stable anon_… id and keeps it in the browser's local storage, so history works for a visitor you never named. In private browsing, or with site data blocked, it falls back to an id per page load.
clearAnonymousId() removes that stored id, and a full page reload after sign-out lets the next visitor start clean. Do not rely on it alone on a shared computer: a loaded bundle that still holds the old id can write it back. If two people share a browser, name them with a per-person opaque id.
Traits
Traits carry organisational context, such as what kind of account this is, which support tier or which audience, so the agent can adjust escalation and wording. They select wording, never rights.
- Only declared keys are kept. An unknown key is dropped, not refused.
- Each declared key has a type; a value of the wrong type is rejected even though the key is known.
- A value that looks like personal data about the end user is rejected, with the reason.
- Nothing throws. A hostile descriptor becomes an empty set of traits.
The vocabulary is fixed in the product and still growing, so do not guess at it: read the boot response, which names every key that did not survive and why.
json
{
"traits": {
"accepted": { "role": "admin" },
"dropped": ["internal_tier"],
"rejected": [{ "key": "support_package", "reason": "looks like end-user personal data (e-mail address)" }]
}
}Signed sessions
Rule 3 makes an id hard to guess. A signed session makes it impossible to forge, and is what you want when the conversations contain the person's own account data.
Your backend mints a short-lived token with the secret key and hands it to your own page. The widget sends it in the same X-Kadmo-User header, and the end user is recorded as verified.
The Keys & embed tab does not mint secret keys. An account admin mints one with the app's keys API for the agent, POST https://app.kadmo.ai/api/chat-agents/<agent-id>/keys with the body {"kind":"secret"}, while signed in to the app; the response shows the key once. Ask Kadmo if you need help with this step.
bash
# On your server, never in a page.
curl -sX POST https://app.kadmo.ai/api/embed/session \
-H "Authorization: Bearer $KADMO_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"user":{"id":"usr_8f3a","display_name":"Alex","locale":"de"},"ttl_seconds":900}'json
{
"token": "kcs_v1.…",
"expires_in": 900,
"expires_at": "2026-09-17T12:34:56.000Z",
"max_ttl_s": 3600,
"end_user": { "id": "usr_8f3a", "identity": "verified" },
"traits": { "role": "admin" }
}Note the host: the mint door is on the app (app.kadmo.ai), not on the gateway. The gateway refuses any bearer that is not a kc_pk_… key, so a secret key has nowhere to go there. Secret key to the app, publishable key to the edge.
Installing the token in the page. With the framework-free client, call setSession(token) on sign-in and on every re-mint, or pass session when you create the client, and give it an onSessionExpired function that fetches a fresh token from your backend. Under the React shells there is one way: mount KadmoProvider yourself, give the shells no token, and call setSession on the client that provider made, which any component inside it reaches with the useKadmo() hook.
The shorthand of a shell with a token does not work for a signed flow: given a token, the shell builds a client your code never holds, so there is nothing to call setSession on. The boot call still succeeds, so the launcher renders, and every call after it is refused, which looks like a widget that disappears on the first click.
A session wins over a descriptor: supplying both means the signed one.
ttl_seconds defaults to 900 and is clamped to 60–3600 seconds rather than refused. Clocks may differ by up to 60 seconds. The whole token is capped at 4,096 bytes, so it cannot overflow a header on your own origin.
Four rules for the signed flow
| Rule | Why |
|---|---|
The kc_sk_… key never reaches a browser | It mints sessions for any end user of the agent. In a bundle it is an impersonation key |
user.id at mint stays identical to the id the unsigned descriptor used | Threads are scoped by it, so changing the scheme when you turn the requirement on orphans every existing conversation |
Re-mint before the session expires, or supply onSessionExpired | A session lasts minutes. Without a re-mint path, expiry looks to the visitor like "my history vanished" |
| Your mint server's clock is within a minute of Kadmo's | A backend running ahead mints sessions dated in the future for their whole life. They are refused as session_invalid, deliberately not session_expired, which would make the widget re-mint an equally future-dated token forever |
Requiring a verified identity
An agent can be set to require a verified identity (the agent setting require_verified_identity; it is off by default). Then an unsigned caller is refused with identity_required on every route except the boot call. The boot call is exempt on purpose: it is the only place a shell can learn the requirement exists, and it reports it as features.identity_required, so your widget can ask your backend for a session before the first content call fails.
Until the requirement is on, the agent accepts both forms. That is a migration state, not a security boundary: while it is off, anyone holding the publishable key can write to any end-user id they can guess, and those threads survive the switch and become that person's history. So on an agent that has ever served unsigned traffic, turn the requirement on before the ids could be guessed, or move to a fresh id scheme and accept the orphaning the second rule warns about.
Revoking a secret key invalidates every session it minted, immediately. Two secret keys can be live at once, which makes rotating the minting key a non-event.
Content security policy
If your site sends a Content-Security-Policy, it needs the gateway on connect-src:
text
connect-src 'self' https://gw.kadmo.ai;If you already send a connect-src, add the origin to the directive you have; do not add a second one. There is no stylesheet, font or script to allow: the icons are inline SVG and the stylesheets are the ones you import. If you set apiBase to another gateway, use that origin instead.
One image can load from elsewhere: the agent's avatar, which an admin sets in the app and the boot response hands over as a URL. If your policy restricts img-src (including through a default-src 'self'), allow the host it is served from, or the avatar is blocked for every visitor.
'self' is part of the line
connect-src has no implicit 'self', and it stops falling back to default-src as soon as it is present. A site that ships connect-src https://gw.kadmo.ai; has just blocked every fetch, XHR, WebSocket and EventSource of its own. A second connect-src added to repair that is ignored, because the first occurrence of a directive wins.
This is the failure integrators hit second: the snippet works locally, and the site's own policy silently blocks every request in production, showing only a console error about a blocked connection.
Allowed origins
An origin is scheme, host and port: no path, no query, no fragment. https://app.example.com is an origin; https://app.example.com/help is not. A trailing slash is dropped, and matching is exact after that.
https://example.comdoes not coverhttps://www.example.comhttps://example.comdoes not coverhttp://example.comhttps://example.comdoes not coverhttps://example.com:8443
List every origin that serves a page with the widget: the apex and www, your staging host, and your preview host if its URLs are stable. An empty list allows nothing, which is the right state for a key you have not wired up yet; the key list shows it as none — no browser may use this key. Change a key's list with Edit origins.
* is accepted and means what it says: every site on the internet may use this key, and a request with no Origin header is allowed too. Any page that gets hold of the key can then spend the agent's budget. Use it for a local experiment, not in production.
A refused origin is the one refusal that carries no Access-Control-Allow-Origin header: withholding the CORS grant is the enforcement. In the browser it therefore shows as a generic CORS failure; the real answer, a 403 with {"error":"origin_not_allowed"}, is in the network tab.
Caps and what the visitor sees
Two layers, and these are the defaults:
| Where | Cap | Default |
|---|---|---|
| Edge | Requests per key, fixed 60-second window | 60 per minute |
| Edge | Requests per IP address, fixed 60-second window | 120 per minute |
| Edge | Search calls per key | 120 per minute |
| Agent | Turns per end user, rolling 10 minutes | 20 |
| Agent | Turns per day (UTC) | 2,000 |
| Agent | Model cost per day (UTC) | $25 |
| Agent | Search calls per end user | 60 per minute |
The turn and cost caps are agent settings, and a single key can carry its own values instead of the agent's (the key list marks them this key only), which is useful for a pilot key on a customer's staging site. The edge windows and the per-end-user search limit are fixed in the product.
A refusal is typed and carries its own countdown:
json
{ "error": "rate_limited", "message": "Too many requests. Try again shortly.", "retry_after_s": 30 }Retry-After is also sent as a header, readable through CORS. The cost cap refuses as cost_capped rather than rate_limited, so you can tell "too fast" from "out of budget".
No refusal renders a broken widget. Every code maps to a state the shell knows how to show:
| Boot answers | The visitor sees |
|---|---|
invalid_key, origin_not_allowed, gateway_forbidden, session_invalid | Nothing at all. One console.warn for you |
agent_inactive | The launcher, disabled, with a short note in the visitor's language |
not_configured, plane_disabled, gateway_unavailable, offline | The launcher opens normally; the panel says what is wrong |
rate_limited, cost_capped | The same, with a countdown |
The first row is deliberate: those are integration errors, and a visitor can do nothing about them.
Rotation and revocation
Two keys can be live at once, which is what makes rotation safe. Mint, deploy, verify, revoke, in that order:
- Rotate the key you are replacing with the tab's Rotate action. Confirm with Mint the successor: the new key carries the old key's allowed origins and its own caps, so there is nothing to re-enter. A plain Mint a key starts from an empty allowlist and the agent's default caps instead; deploy one of those by mistake and every request from your own site comes back
403 origin_not_allowed. - Deploy your site with the new key.
- Verify it works: the widget boots and a turn completes.
- Revoke the old key with Revoke, then Revoke it.
Nothing revokes the old key for you, and nothing expires it: a key has no end date, so an unfinished rotation leaves two live keys.
Revocation is immediate, with no grace period. A revoked key answers exactly like a key that never existed, 401 invalid_key. It cannot be undone, because the key itself is not stored anywhere; the revoked row stays in the list under Revoked so the turns it served stay attributable. If a key leaks, revoke first, then mint and redeploy.
Revoking a secret key also ends every session it minted, in the same instant.
Error codes
Every refusal has the same shape, { "error": …, "message": …, "retry_after_s"?: … }. Branch on the code; the message is for people and may change.
| Code | HTTP | What it means | Shell state |
|---|---|---|---|
invalid_key | 401 | Unknown, revoked, or not a publishable key | hidden |
origin_not_allowed | 403 | This origin is not on the key's allowlist. No CORS grant | hidden |
gateway_forbidden | 403 | The edge could not authenticate itself to the app | hidden |
session_invalid | 401 | Tampered, for another agent, minted by a revoked key, or dated in the future | hidden |
identity_required | 403 | The agent requires a verified identity and this caller has none | hidden |
agent_inactive | 403 | An admin paused the agent | disabled |
plane_disabled | 503 | The embed channel is not enabled on this deployment | panel |
not_configured | 409 | No model provider resolves for this agent | panel |
gateway_unavailable | 502 | The edge could not reach the app | panel |
rate_limited | 429 | A rate cap fired. Carries retry_after_s | retry |
cost_capped | 429 | The daily cost cap fired. Carries retry_after_s | retry |
session_expired | 401 | The normal end of a short session. Re-mint and retry | transient |
invalid_request | 400 / 413 | Malformed or too large | transient |
not_found | 404 | No such thread, or a thread belonging to another end user | transient |
server_error | 500 | An error on Kadmo's side | transient |
When it does not work
Nothing renders at all, and there is no network error. That is the hidden state, working as designed. Open the console for the one warning, then the network tab: 401 invalid_key means the key is wrong or revoked, 403 origin_not_allowed (shown as a CORS failure) means this origin is not on the allowlist, and session_invalid means the session your backend minted does not verify.
The launcher appears, and disappears when a visitor clicks it. The boot call succeeded and the call behind the click did not. identity_required is the usual cause: the agent requires a verified identity and your page is not installing a signed session. Check that you mounted KadmoProvider rather than passing token to the shell, since only the provider path can reach setSession.
It works locally and every request is blocked in production. Your Content-Security-Policy; see the directive. The two ways to get it wrong are dropping 'self' and adding a second connect-src instead of extending the one your site already sends.
The panel opens and says something is wrong. not_configured, plane_disabled or gateway_unavailable come from the deployment, not your page; tell your account admin. For not_configured, check that the account has a model provider, for example credit on the Kadmo provider.
A countdown instead of an answer. A cap fired: rate_limited is too many requests, cost_capped is the day's budget. See Caps.
The visitor's history is empty, or vanished mid-conversation. Empty from the start means the descriptor is not arriving: read end_user.id back from the boot response and check it is the id you sent, not an anon_… one. Vanishing mid-conversation is a session that expired with no onSessionExpired to re-mint it.
A trait you sent is missing. It was dropped as an undeclared key or rejected for its shape; the boot response says which, and why.
Two shells, two conversations. Both were mounted with their own token, or as two islands. Use one provider, one island, and no token on either shell.