Skip to content

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 agentCreated and activated in the app under Chat agents (app.kadmo.ai/chat-agents) by an account admin
A publishable keykc_pk_…, minted on the agent's Keys & embed tab and locked to the origins you name
An originThe 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 ​

  1. Open Chat agents, pick the agent, and open the Keys & embed tab.
  2. Under Publishable keys, click Mint a key.
  3. Give it a Label that tells two live keys apart during a rotation, for example Production — example.com.
  4. Under Allowed origins — one per line, list every origin that serves the widget (see Allowed origins).
  5. 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-dom

The snippet the Keys & embed tab hands you does three things:

StepWhat
1Import @kadmo/core/styles.css first: the tokens and every part.
2Import @kadmo/chat/styles.css second: the boxes those parts sit in.
3Render 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:

HeaderWhat it carries
AuthorizationBearer kc_pk_…, the publishable key
Content-Typeapplication/json
X-Kadmo-UserWho 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:

IdVerdict
alex@example.comNo: an address is guessable from a business card
4172No: a sequential database id is guessable by counting
alex.tanNo: 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:

variantWhat 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
panelThe same panel without a launcher, sized by your container
inlineThe thread and the composer, filling your container. No header, no history
PropTypeWhat it does
variant'dock' | 'panel' | 'inline'Which shell. Default dock
tokenstringThe kc_pk_… publishable key. Omit it only when a KadmoProvider is above
apiBasestringThe gateway. Defaults to https://gw.kadmo.ai
userEndUserDescriptor | nullThe signed-in visitor. null is an explicit anonymous visitor
locale'de' | 'en'Pin the interface language. Omit it to follow the agent's own
contextHostContext | (() => HostContext)The page, reported on every turn. A function is read again on each turn
themeRecord<string, string>--kadmo-* overrides on top of the agent's theme
open / onOpenChangeboolean / (open) => voidControl the dock yourself
onError(event) => voidEvery refusal, boot included: { code, message, retryAfterSeconds? }
onThreadChange(event) => voidThe conversation the visitor is in: { threadId, entry }
paletteHintbooleanForce the ⌘K hint on or off. Omitted, it follows what is mounted
classNamestringAdded 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 cmdk

Import @kadmo/core/styles.css, then @kadmo/palette/styles.css, and render KadmoPalette with your token. Its main props beyond those of the chat shells:

PropWhat it does
providersYour quick search: a list of providers, each one object with an id and a search(query, signal) function that returns groups of rows. Optional
homeYour groups for the empty box, shown first
onNavigateCalled 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
onErrorA 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.

  1. 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.
  2. 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, onNavigate and your search providers are built inside the island.
  3. 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".
  4. 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" }
}
FieldContract
idRequired. Opaque, unguessable and stable per person (rule 3). Cut at 200 characters
identityanonymous 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_nameOptional, cut at 120 characters. Note the underscore
localeOptional, cut at 20 characters
traitsOptional 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 ​

RuleWhy
The kc_sk_… key never reaches a browserIt 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 usedThreads 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 onSessionExpiredA 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'sA 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.com does not cover https://www.example.com
  • https://example.com does not cover http://example.com
  • https://example.com does not cover https://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:

WhereCapDefault
EdgeRequests per key, fixed 60-second window60 per minute
EdgeRequests per IP address, fixed 60-second window120 per minute
EdgeSearch calls per key120 per minute
AgentTurns per end user, rolling 10 minutes20
AgentTurns per day (UTC)2,000
AgentModel cost per day (UTC)$25
AgentSearch calls per end user60 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 answersThe visitor sees
invalid_key, origin_not_allowed, gateway_forbidden, session_invalidNothing at all. One console.warn for you
agent_inactiveThe launcher, disabled, with a short note in the visitor's language
not_configured, plane_disabled, gateway_unavailable, offlineThe launcher opens normally; the panel says what is wrong
rate_limited, cost_cappedThe 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:

  1. 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.
  2. Deploy your site with the new key.
  3. Verify it works: the widget boots and a turn completes.
  4. 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.

CodeHTTPWhat it meansShell state
invalid_key401Unknown, revoked, or not a publishable keyhidden
origin_not_allowed403This origin is not on the key's allowlist. No CORS granthidden
gateway_forbidden403The edge could not authenticate itself to the apphidden
session_invalid401Tampered, for another agent, minted by a revoked key, or dated in the futurehidden
identity_required403The agent requires a verified identity and this caller has nonehidden
agent_inactive403An admin paused the agentdisabled
plane_disabled503The embed channel is not enabled on this deploymentpanel
not_configured409No model provider resolves for this agentpanel
gateway_unavailable502The edge could not reach the apppanel
rate_limited429A rate cap fired. Carries retry_after_sretry
cost_capped429The daily cost cap fired. Carries retry_after_sretry
session_expired401The normal end of a short session. Re-mint and retrytransient
invalid_request400 / 413Malformed or too largetransient
not_found404No such thread, or a thread belonging to another end usertransient
server_error500An error on Kadmo's sidetransient

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.