Embedding CMD+K inside your product (partners)
You build a product other companies install on their sites — a changelog widget, a support panel, an in-app help center — and you want each of your customers to plug their own CMD+K assistant into it. This guide covers the partner integration end to end: the embed key that replaces origin allowlists, the verify endpoint for your settings UI, the server-side management API for building a settings panel, every loader attribute, and the v1 postMessage protocol.
The integration model
A normal CMD+K install trusts the page origin: the widget owner lists their site's origins in the dashboard and every other origin is rejected. That model breaks when the embedding page belongs to your product running on sites nobody can list in advance. The partner model replaces the origin check with a credential:
- Your customer (the widget owner) opens their widget's Install tab in the CMD+K dashboard and generates an embed key in the "Third-party embed" section.
- They paste their widget ID and the embed key into your product's settings UI.
- You verify the pair against
/api/public/widget/verify, then render the loader script withdata-cmdk-embed-keywherever your product is embedded.
The embed key
Format: ck_embed_ followed by 32 random hex characters. One key per widget.
- What it unlocks: any public widget API request carrying the key (as the
ekquery parameter — the loader and iframe handle this automatically) skips the origin allowlist. Nothing else changes: auth mode, per-user JWTs, tool exposure, and rate limits behave exactly as on a first-party embed. - Wrong key = hard reject: a request that supplies an incorrect or revoked key is refused with
403even if it comes from an allowlisted origin. There is no silent fallback to the origin path, and repeated failures are rate-limited per widget + IP. - Rotation: generating a new key overwrites the old one in place — the old key stops working the moment rotation completes. Revoking deletes the key entirely; partner embeds stop until a new key is generated, while origin-allowlisted first-party embeds keep working untouched.
- No key supplied: the widget behaves exactly as before this feature existed — origin allowlist only.
Verify endpoint
Built for partner settings screens: validate a pasted widget ID + embed key instantly and show the widget's name as confirmation. CORS is open to any origin (the request authenticates by key), it is rate-limited per IP, and it never returns secrets or workspace identifiers.
curl -X POST https://commandplusk.com/api/public/widget/verify \
-H "Content-Type: application/json" \
-d '{"widgetId":"<WIDGET_ID>","embedKey":"ck_embed_..."}'Responses (always HTTP 200 for a well-formed request):
// success
{ "valid": true, "widget": { "name": "Acme Assistant", "brandingVisible": true } }
// failures
{ "valid": false, "reason": "not_found" } // no widget with that ID
{ "valid": false, "reason": "invalid_key" } // key doesn't match (e.g. rotated)
{ "valid": false, "reason": "revoked" } // widget has no active embed keybrandingVisible tells you whether the widget will show "Powered by CMD+K" — useful if your product's embed chrome wants to account for it.
Partner management API
Once the embed works, your backend can manage the widget on the customer's behalf — power a real settings panel inside your product — via three endpoints. They are authenticated by a second credential, the management key:
- ck_pmk_ + 32 hex — generated by the customer next to the embed key (widget Install tab → "Third-party embed" → "Management key"). Server-side only — unlike the embed key it grants config access, so it must never appear in page HTML or client code.
- Two-key design: the embed key ships in HTML and is therefore low-privilege by construction — it can never read config, write config, or read usage. The management key can, and never works as an embed key. Wrong or revoked keys are a hard 403 (with per-widget rate limiting on failures); the API never reveals whether a widget ID exists.
All three endpoints are POST with a JSON body carrying widgetId + mgmtKey, and CORS is open (auth is by key):
# 1. Read the safe config subset
curl -X POST https://commandplusk.com/api/public/partner/widget/get \
-H "Content-Type: application/json" \
-d '{"widgetId":"<WIDGET_ID>","mgmtKey":"ck_pmk_..."}'
# → { ok: true, widget: { name, appearance: { accent, position, launcher,
# placeholder, display, panelWidth, theme }, suggestedPrompts,
# showBranding, modes, allowedOriginsCount, createdAt, updatedAt } }
# 2. Update a whitelisted subset
curl -X POST https://commandplusk.com/api/public/partner/widget/update \
-H "Content-Type: application/json" \
-d '{"widgetId":"<WIDGET_ID>","mgmtKey":"ck_pmk_...",
"patch":{"accent":"#7C3AED","theme":"dark","panelWidth":480}}'
# → { ok: true, widget: <updated safe view> }
# 3. Daily usage aggregates (default 30 days, max 90)
curl -X POST https://commandplusk.com/api/public/partner/widget/usage \
-H "Content-Type: application/json" \
-d '{"widgetId":"<WIDGET_ID>","mgmtKey":"ck_pmk_...","days":30}'
# → { ok: true, days, since, series: [{ day, conversations, messages }] }The patch whitelist — anything else is a 400, never silently dropped:
- accent — hex color,
#RRGGBB. - position —
left|right. - launcher —
bubble|avatar|tab|bar|pill|embed|custom. - placeholder — input placeholder, ≤120 chars.
- display —
overlay|inline|fullpage. - panelWidth — integer 320–720.
- theme —
light|dark|auto. - suggestedPrompts — up to 6 of
{ label ≤60, prompt ≤500, mode? }— replaces the list wholesale.
What the management API can never do
auth_mode, allowed_origins (reads only get a count), the JWT signing secret, the agent's system prompt / agent_config, branding or whitelabel flags, modes, or MCP tool bindings — and it can't read chat transcripts or end-user identities (usage is daily counts only). Every successful update is written to an audit log the widget owner controls, and the owner can revoke or rotate the management key at any time from the dashboard.Installing the loader
Inject the loader script into the page your product controls, with the customer's values:
<script async src="https://commandplusk.com/embed/widget.js"
data-cmdk-widget="<CUSTOMER_WIDGET_ID>"
data-cmdk-embed-key="<CUSTOMER_EMBED_KEY>"></script>The loader fetches the widget's dashboard-configured appearance, injects a launcher, and mounts the chat iframe. The embed key is forwarded on every API call the loader and the iframe make, so the whole surface works from any origin.
Signed-in visitors work too
data-cmdk-token exactly as described in End-user authentication. The embed key and the user token are independent: one authorizes the embed, the other identifies the visitor.All loader attributes
Complete inventory of the data-cmdk-* attributes the loader reads (script attributes always win over the widget's dashboard config):
- data-cmdk-widget — required — the widget UUID.
- data-cmdk-embed-key — partner embeds — the widget's embed key (
ck_embed_…). Bypasses the origin allowlist. - data-cmdk-token — signed-in mode — short-lived HS256 JWT identifying the current visitor.
- data-cmdk-mode — starting assistant mode (e.g.
assistant,onboarding) when the widget has several. - data-cmdk-theme —
light|dark|auto. - data-cmdk-accent — accent color override (hex).
- data-cmdk-position —
right(default) orleft. - data-cmdk-launcher —
bubble|avatar|tab|bar|pill|embed(full-page surface) |custom(nothing injected — open from your own UI). - data-cmdk-placeholder — input placeholder text.
- data-cmdk-display —
overlay(floating window) |inline(docked side panel that pushes page content) |fullpage(viewport takeover). - data-cmdk-panel-width — inline panel width in px (320–720, default 420).
- data-cmdk-target — CSS selector of the container the
barlauncher mounts into (see target-container mode below). - data-cmdk-locale — UI locale for walkthrough chrome ("Next", "Skip", …). Defaults to
navigator.language.
Target-container mode
For embedding CMD+K inside a panel you own (instead of floating over the page), use the bar launcher plus data-cmdk-target. The loader mounts an inline ⌘K command bar into your container; selecting a suggestion or pressing Enter opens the chat surface with the message pre-sent.
<div id="my-panel-assistant"></div>
<script async src="https://commandplusk.com/embed/widget.js"
data-cmdk-widget="<CUSTOMER_WIDGET_ID>"
data-cmdk-embed-key="<CUSTOMER_EMBED_KEY>"
data-cmdk-launcher="bar"
data-cmdk-target="#my-panel-assistant"></script>Alternatively, data-cmdk-launcher="custom" injects nothing and leaves opening entirely to your own buttons via window.CommandK.open() or the cmdk:host:open message below.
postMessage protocol — v1 (stable)
The protocol below is what ships in the loader today. It is versioned as v1 (stable): message types and payload shapes listed here won't change incompatibly; new optional fields or new message types may be added.
Host page → widget
Your code posts these to its own window (the loader listens for same-window messages) — from anywhere in your product's UI:
- cmdk:host:open — open the chat. Payload:
{ text?: string, submit?: boolean }—textprefills the composer;submit(defaulttruewhen text is present) sends it immediately. - cmdk:host:close — close the chat. No payload.
- cmdk:host:toggle — toggle open/closed. No payload.
- cmdk:host:set_theme — switch theme. Payload:
{ theme: "light" | "dark" | "auto" }.
// e.g. from your product's "Ask AI" button:
window.postMessage({ type: "cmdk:host:open", text: "How do I invite my team?" }, window.location.origin);The same controls (plus a few walkthrough helpers) are exposed as functions on window.CommandK:
CommandK.open(text?, submit?) // open, optionally prefill/send
CommandK.close() // close
CommandK.toggle() // toggle
CommandK.isOpen() // boolean
CommandK.setTheme("dark") // "light" | "dark" | "auto"
CommandK.startWalkthrough(id) // run a published guided tour by id
CommandK.listWalkthroughs() // [{ id, name }]
CommandK.snoozeWalkthroughs(ms)
CommandK.resetWalkthroughs() // clear first-visit/opt-out flags (QA)
CommandK.fireTourEvent(name) // trigger event-based toursWidget iframe → loader
Messages the chat iframe posts to the host page's loader (all scoped to the CMD+K origin — the loader ignores them from anywhere else). You normally never handle these yourself; they are listed so partners know exactly what crosses the boundary:
- cmdk:ready — iframe booted; loader replies with cmdk:init.
- cmdk:close — visitor closed the chat from inside the iframe.
- cmdk:collapse / cmdk:expand — fullpage display only — swap between full-viewport and floating window.
- cmdk:request_host_context — ask the loader to (re)publish the current page snapshot and element inventory.
- cmdk:host:highlight_element —
{ target_id?, selector?, note? }— spotlight an element on the host page for ~3.5s. - cmdk:host:walkthrough_start —
{ title, steps: [{ target_id?, selector?, label?, message, placement?, nav?, completeOn? }] }— start a guided tour overlay;cmdk:host:walkthrough_nextadvances,cmdk:host:walkthrough_end{ summary? }tears down. - cmdk:host:request_fill —
{ target_id?, selector?, value, label? }— ask to fill a field. Always gated by an explicit visitor consent dialog rendered by the loader. - cmdk:host:request_click —
{ target_id?, selector?, label? }— ask to click an element. Consent-gated; destructive-looking targets always force a confirm. - cmdk:host:wait_for_element —
{ target_id, timeout_ms? }— wait for a target to appear; loader replies withcmdk:host:wait_for_element_result. - cmdk:host:take_page_screenshot — only works if the host page defines
window.__cmdkCaptureScreenshot(): Promise<dataUrl>; loader replies withcmdk:host:screenshot_result.
Loader → widget iframe
- cmdk:init —
{ token, mode, theme }— handshake after cmdk:ready. - cmdk:prefill —
{ text, submit }— seed the composer (from cmdk:host:open). - cmdk:set_theme —
{ theme }— live theme switch. - cmdk:set_collapsed —
{ collapsed }— fullpage collapse state sync. - cmdk:host:context —
{ context }— sanitized snapshot of the host page (title, path, headings; query strings and fragments are stripped client-side). - cmdk:host:page_schema —
{ schema }— inventory of visible interactive elements (opaque ids + labels/roles only; CSS selectors never leave the host page). - cmdk:host:wt_state / cmdk:host:wt_event — walkthrough progress state and lifecycle events, so the assistant stays aligned mid-tour.
- cmdk:wt:miss —
{ target_id }— a tour step's target wasn't found on this page. - cmdk:host:screenshot_result / cmdk:host:wait_for_element_result — replies to the corresponding iframe requests above.
Security model summary
- The embed key authorizes the embed surface, never a user. Visitor identity still comes from the widget's auth mode (anonymous or per-user JWT).
- A supplied-but-wrong key is a hard 403 on every endpoint regardless of origin, and failed attempts are rate-limited per widget + IP — a bad key can never downgrade to the origin-allowlist path.
- Rotation and revocation are instant and owner-controlled from the dashboard; no partner action can extend a key's life.
- The loader's host-page bridges (fill, click, screenshot) are consent-gated in the visitor's browser and only accept messages from the CMD+K origin.
Next steps
FAQ
- Why can't I just use allowed origins?
- Your product runs on your customers' customers' sites — origins neither you nor the widget owner can enumerate in advance. The embed key authenticates the embed itself instead of the page origin.
- What happens when the customer rotates or revokes the key?
- The old key stops working immediately (hard 403 on every widget API). Your verify endpoint call will return {valid:false, reason:'invalid_key'} (rotated) or 'revoked'. Surface that state and ask the customer to paste the new key.
- Is the embed key a secret?
- It is a low-privilege, revocable credential: it only unlocks what an allowlisted public embed could already do (read-only config, anonymous or JWT-authenticated chat). It ships to browsers by design — like a publishable API key — but don't log it server-side and do use the rotation flow if it leaks somewhere unexpected.
- Does the embed key change what the assistant can do?
- No. It only bypasses the origin allowlist. Auth mode, JWT verification, MCP tool exposure, rate limits, and quotas all apply exactly as they do for a first-party embed.
- Can I style or position the widget inside my panel?
- Yes — every data-cmdk-* attribute below works. For embedding into a container you control, use launcher='bar' with data-cmdk-target, or launcher='custom' and open the chat from your own UI via window.CommandK.open().
- What's the difference between the embed key and the management key?
- The embed key (ck_embed_…) ships in page HTML and only lets the widget render and chat from foreign origins — it can never read or change configuration. The management key (ck_pmk_…) is a second, server-side credential that authenticates the partner management API: read the safe config subset, update a whitelisted set of appearance fields, and read daily usage counts. Never put the management key in a browser.
- Can the management API change the widget's system prompt, origins, or auth mode?
- No. The update endpoint validates the patch against a strict whitelist (appearance fields + suggested prompts + placeholder); any other key — auth_mode, allowed_origins, jwt_secret, agent_config, branding/whitelabel, modes, bindings — is rejected with a 400, never silently dropped. Reads never include secrets, workspace ids, or the origin allowlist's values.