Reference
REST reference
The public /v1/embed/* endpoints, SSE event types, error codes, the admin /v1/embed-keys API and the SDK.
Everything the widget does goes through six public endpoints under /v1/embed/ plus the hosted bundle. You only need them if you build a custom UI (a native app, a server-side integration, a different chat surface). Teams automating key management use the admin endpoints under /v1/embed-keys with a platform API key, or @hive/sdk.
Public endpoints
hive:ready.- Auth
- Publishable key in the
X-Hive-Embed-Keyheader +Originon the allowlist - Rate limit
- 60/min per IP
- Errors
EMBED_KEY_INVALIDEMBED_KEY_DISABLEDEMBED_ORIGIN_DENIEDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
X-Hive-Embed-Key | header, string | The publishable key hive_pk_… (a header, never the URL, so it stays out of access logs). |
Response
| Field | Type | Description |
|---|---|---|
key_id | uuid | Embed key id. |
agent | { name, emoji, avatar } | Agent display name, emoji, and talking avatar (avatar is null unless the key enables it). See Talking avatars. |
theme | EmbedTheme | Server theme. |
config | EmbedDisplayConfig | Server display config. |
identity_required | boolean | Anonymous sessions refused when true. |
voice | { handsfree, ptt } | Effective voice flags for this key’s agent. |
- No session and no credentials are involved; a page view costs exactly this one call.
- Browsers omit
Originon same-origin GETs, so a missing header is evaluated as the implied page origin (Refererorigin, else the request’s own origin) against the allowlist.
curl https://console.agenticworkforce.me/v1/embed/config \
-H "Origin: https://www.acme.com" -H "X-Hive-Embed-Key: $EMBED_KEY"
# → 200
# { "key_id": "…", "agent": { "name": "Docs assistant", "emoji": null },
# "theme": { "primary": "#4f46e5" }, "config": { "welcome": "Hi!" },
# "identity_required": false }model/gltf-binary), for a widget whose key shows a 3D face. The ?v= query is a cache-buster; the route does not read it.- Auth
- Publishable key in the
X-Hive-Embed-Keyheader +Originon the allowlist - Rate limit
- 6/min per IP, shared with
GET /v1/embed/avatar/photo - Errors
EMBED_KEY_INVALIDEMBED_KEY_DISABLEDEMBED_ORIGIN_DENIEDNOT_FOUNDRATE_LIMITEDVALIDATION
404 NOT_FOUNDunlessconfig.avatar.enabledis true, the agent’s avatarmodeis3d, and the model bytes exist under that agent’s storage prefix.Cache-Control: private, max-age=86400,X-Content-Type-Options: nosniff. A shared cache must not store the bytes — they are served only against the key.- The widget fetches this after lazy-loading
hive-embed-avatar3d.js. See Talking avatars.
?v= query is a cache-buster; the route does not read it.- Auth
- Publishable key in the
X-Hive-Embed-Keyheader +Originon the allowlist - Rate limit
- 6/min per IP, shared with
GET /v1/embed/avatar/model - Errors
EMBED_KEY_INVALIDEMBED_KEY_DISABLEDEMBED_ORIGIN_DENIEDNOT_FOUNDRATE_LIMITEDVALIDATION
404 NOT_FOUNDunlessconfig.avatar.enabledis true, the agent’s avatarmodeisphoto, and the photo bytes exist under that agent’s storage prefix.Cache-Control: private, max-age=86400,X-Content-Type-Options: nosniff. Content type follows the stored file.- The widget fetches this from
hive-embed-avatar.jsand falls back to the 2D look if the download fails. See Talking avatars.
- Auth
- Publishable key in the body +
Originheader on the allowlist - Rate limit
- 30 mints/min per IP, then 10 × the key’s
rate_limit_per_minper key - Errors
EMBED_KEY_INVALIDEMBED_KEY_DISABLEDEMBED_ORIGIN_DENIEDEMBED_IDENTITY_REQUIREDEMBED_IDENTITY_INVALIDEMBED_IDENTITY_UNAVAILABLERATE_LIMITEDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
key | string | Publishable key hive_pk_…. |
visitor_id | string (8–128 url-safe chars) | Stable per-browser visitor id generated by the widget. |
identity | string (JWT), optional | HS256 JWT signed with the key’s identity secret. Required when the key has identity_required. |
Response
| Field | Type | Description |
|---|---|---|
token | string | Opaque signed session token — send as Authorization: Bearer. |
expires_at | ISO date-time | Token expiry (15 minutes). The widget re-mints lazily: when < 30 s remain before a call, or after a 401. |
ttl_sec | number | Token lifetime in seconds (900). |
visitor_id | string | Echo of the visitor id the token is bound to. |
end_user | { id, name } | null | Present when an identity JWT was accepted. |
key_id | uuid | The embed key’s id (never the key itself). |
agent | { name, emoji, avatar } | Display name, emoji, and the talking avatar (null unless the key’s config.avatar.enabled is true and the agent has a face). See Talking avatars. |
theme | EmbedTheme | Server-side theme stored on the key. |
config | EmbedDisplayConfig | Server-side display config stored on the key. |
identity_required | boolean | Whether anonymous sessions are refused for this key. |
voice | { handsfree, ptt } | Effective voice flags (deployment ∧ tenant ∧ agent manifest ∧ key). Both false when voice is off. |
- The widget mints lazily — on the first open (launcher), on mount (inline) or on the first
send()— never on page load. - Identified mints (a valid
identityJWT) are audited (embed.session, target = the contact); anonymous mints are counted in metrics only. - When the key allows
*and the caller sent noOrigin, the token is origin-less (o: "*"); otherwise it is pinned to the request origin.
curl -X POST https://console.agenticworkforce.me/v1/embed/session \
-H "Origin: https://www.acme.com" -H "Content-Type: application/json" \
-d '{"key":"'"$EMBED_KEY"'","visitor_id":"visitor-9f1c2b3a4d5e"}'
# → 200
# { "token": "…", "expires_at": "2026-09-03T10:15:00.000Z", "ttl_sec": 900,
# "visitor_id": "visitor-9f1c2b3a4d5e", "end_user": null, "key_id": "…",
# "agent": { "name": "Docs assistant", "emoji": null },
# "theme": { "primary": "#4f46e5" }, "config": { "welcome": "Hi!" },
# "identity_required": false }
# Identified visitor: add the backend-signed JWT
# -d '{"key":"…","visitor_id":"…","identity":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}'- Auth
- Session token (
Authorization: Bearer) + the sameOriginthe token was minted for - Rate limit
- key
rate_limit_per_min(default 60) and 20 messages/min per visitor - Errors
EMBED_UNAUTHORIZEDEMBED_ORIGIN_MISMATCHEMBED_ORIGIN_DENIEDEMBED_KEY_DISABLEDEMBED_RUN_IN_FLIGHTEMBED_THREAD_FULLEMBED_THREAD_REQUIRED_FOR_ATTACHMENTINVALID_ATTACHMENTRATE_LIMITEDAGENT_NOT_PUBLISHEDNOT_FOUNDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
content | string (1–8000 chars) | The user message (trimmed). |
thread_id | uuid, optional | Continue an existing thread owned by this visitor; omit to start a new one. |
attachment_ids | uuid[], optional (max 8) | Documents uploaded to this owned thread through the attachment endpoint and included in this turn. |
Response
| Field | Type | Description |
|---|---|---|
thread_id | uuid | The thread (new or continued). |
run_id | uuid | The queued run — follow it on the events endpoint. |
message_id | uuid | The stored user message. |
- Thread ownership:
threads.created_by=embed:eu:<end_user_id>(identified) orembed:vid:<visitor_id>; a foreignthread_idis a 404, never a 403. - The run is a normal
chatrun withinput._origin = { embed_key_id, visitor_id, end_user_id? }— the console shows origin "Embedded widget". - One reply at a time per thread: a message while the previous run is queued/running is a
409 EMBED_RUN_IN_FLIGHT(with therun_id); a thread holds at most 200 messages (409 EMBED_THREAD_FULL).
curl -X POST https://console.agenticworkforce.me/v1/embed/messages \
-H "Origin: https://www.acme.com" -H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"How do I embed the widget in Next.js?"}'
# → 202
# { "thread_id": "…", "run_id": "…", "message_id": "…" }- Auth
- Session token + matching
Origin;multipart/form-data - Rate limit
- key
rate_limit_per_minand 20 uploads/min per visitor - Errors
EMBED_UNAUTHORIZEDEMBED_ORIGIN_MISMATCHEMBED_ORIGIN_DENIEDEMBED_KEY_DISABLEDTOO_MANY_FILESUNSUPPORTED_TYPEFILE_TOO_LARGENO_FILERATE_LIMITEDNOT_FOUNDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
files | multipart files (1–8) | PNG, JPEG, WebP or PDF; at most 20 MB per file. |
Response
| Field | Type | Description |
|---|---|---|
items[] | { id, thread_id, filename, mime, size_bytes, kind, page_count, created_at } | Stored attachment metadata. Pass each id in the next message’s attachment_ids. |
- The thread must already exist and belong to this visitor. The widget enables attachments after the first accepted turn.
- Uploaded bytes are stored privately; the response exposes metadata only.
- Auth
- Session token + matching
Origin - Rate limit
- per-visitor read bucket: 60/min shared by history, cancel and event streams
- Errors
EMBED_UNAUTHORIZEDEMBED_ORIGIN_MISMATCHEMBED_ORIGIN_DENIEDEMBED_KEY_DISABLEDRATE_LIMITEDNOT_FOUNDVALIDATION
Response
| Field | Type | Description |
|---|---|---|
thread_id | uuid | The thread. |
items[].id | uuid | Message id. |
items[].role | 'user' | 'agent' | Only user/agent turns are returned (no system/tool rows). |
items[].text | string | Plain text of the message. |
items[].run_id | uuid | null | Run that produced an agent message. |
items[].created_at | ISO date-time | Creation time. |
curl "https://console.agenticworkforce.me/v1/embed/threads/$THREAD_ID/messages" \
-H "Origin: https://www.acme.com" -H "Authorization: Bearer $SESSION_TOKEN"
# → 200
# { "thread_id": "…", "items": [
# { "id": "…", "role": "user", "text": "How do I …", "run_id": null, "created_at": "…" },
# { "id": "…", "role": "agent", "text": "Use the React wrapper …", "run_id": "…", "created_at": "…" } ] }?since= backfill and a 15 s heartbeat.- Auth
- Session token + matching
Origin;Accept: text/event-stream - Rate limit
- per-visitor read bucket (60/min) + at most 2 live streams per visitor and key (
429 EMBED_TOO_MANY_STREAMS,Retry-After: 5); finished runs are served from the backfill without taking a slot - Errors
EMBED_UNAUTHORIZEDEMBED_ORIGIN_MISMATCHEMBED_ORIGIN_DENIEDEMBED_KEY_DISABLEDEMBED_TOO_MANY_STREAMSRATE_LIMITEDNOT_FOUNDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
since | query, integer, optional | Highest step idx already seen (from llm.delta / tool.* payloads); the server backfills persisted steps after it, then streams live. |
event:is the eventtype;data:is the JSON payload. The stream ends afterrun.completedorrun.failed.- Use
fetch+ReadableStream(the token travels in theAuthorizationheader;EventSourcecannot set headers and is unsupported). - When the agent has output guardrails,
llm.deltais withheld and the final text arrives only inrun.completed.
curl -N "https://console.agenticworkforce.me/v1/embed/runs/$RUN_ID/events?since=-1" \
-H "Origin: https://www.acme.com" -H "Authorization: Bearer $SESSION_TOKEN" \
-H "Accept: text/event-stream"
# event: run.started
# data: {"type":"run.started","run_id":"…","status":"running"}
#
# event: tool.called
# data: {"type":"tool.called","idx":1}
#
# event: tool.result
# data: {"type":"tool.result","idx":1,"ok":true}
#
# event: llm.delta
# data: {"type":"llm.delta","idx":2,"text":"Use the "}
#
# event: llm.delta
# data: {"type":"llm.delta","idx":2,"text":"React wrapper…"}
#
# event: run.completed
# data: {"type":"run.completed","status":"succeeded","text":"Use the React wrapper…"}- Auth
- Session token + matching
Origin - Rate limit
- per-visitor read bucket (60/min)
- Errors
EMBED_UNAUTHORIZEDEMBED_ORIGIN_MISMATCHEMBED_ORIGIN_DENIEDEMBED_KEY_DISABLEDRATE_LIMITEDNOT_FOUNDVALIDATION
Response
| Field | Type | Description |
|---|---|---|
run_id | uuid | The run. |
status | string | Run status at the time of the request. |
curl -X POST https://console.agenticworkforce.me/v1/embed/runs/$RUN_ID/cancel \
-H "Origin: https://www.acme.com" -H "Authorization: Bearer $SESSION_TOKEN"
# → 202 { "run_id": "…", "status": "running" }?ticket= on the upgrade.- Auth
- Session token + matching
Origin - Rate limit
- per-visitor read bucket (60/min)
- Errors
EMBED_UNAUTHORIZEDEMBED_ORIGIN_MISMATCHEMBED_ORIGIN_DENIEDEMBED_KEY_DISABLEDVOICE_DISABLEDRATE_LIMITEDNOT_FOUNDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
thread_id | uuid, optional | Continue this owned thread; omitted ⇒ the first voice turn opens a new one. |
Response
| Field | Type | Description |
|---|---|---|
ticket | string | Opaque signed ticket — spend it once. |
expires_in | integer | Lifetime in seconds (60). |
url | string | null | The WebSocket URL (wss://…/v1/embed/voice); null when the deployment has no public API origin — derive it from apiUrl then. |
- Refused with
403 VOICE_DISABLEDunlessGET /v1/embed/configreports an effectivevoice.pttorvoice.handsfreeof true (deployment ∧ tenant ∧ agent manifest ∧ key — most-restrictive wins).
?ticket=): one long-lived socket carrying many utterances and many governed turns — 20 ms PCM16 16 kHz frames up, the server’s energy endpointer decides end of turn, the run executes on the worker like every other embed message, synthesized sentences stream back as binary frames with agent.delta captions.- Auth
- Single-use voice ticket +
Originmatching the session origin - Rate limit
- one open voice socket per visitor and
VOICE_EMBED_MAX_SESSIONS_PER_KEYper key (429 VOICE_BUSY); uplink byte-rate cap; 60 s no-speech idle close; per-agent session length cap - Errors
EMBED_UNAUTHORIZEDVOICE_TICKET_USEDEMBED_ORIGIN_MISMATCHEMBED_KEY_DISABLEDVOICE_BUSYVOICE_SESSION_PROTOCOLVOICE_HANDSFREE_DISABLEDVOICE_DISABLEDVOICE_RATENOT_FOUNDAGENT_NOT_PUBLISHEDVALIDATION
- Control frames are JSON text (
hello,speech_start,speech_end,commit,barge_in {heard_chars?},mute,text,bye); audio frames are binary. The server answers withready,state,utterance.committed,transcript,turn.started,agent.delta,audio_start+ binary audio, asentence {index, chars}marker before each sentence’s audio (chars= cumulative reply characters — what a client reports back asbarge_in.heard_chars),replace,resumed,interrupted {heard_chars?},turn,error. hello.mode: "handsfree"is refused (VOICE_HANDSFREE_DISABLED, socket closed) unless the effectivevoice.handsfreeflag is true;pttrequiresvoice.ptt.- Refusals on the upgrade are plain problem+json responses (the browser sees a failed handshake). Inside the session,
error.fatal: false(e.g.VOICE_TRANSCRIBE_FAILED, orVOICE_RATEwhen the key’srate_limit_per_min— counted per utterance and per typedtext, queued ones included — is spent) keeps the session listening;error.fatal: trueis followed by the close. ready.handsfreereports the GRANTED capability (all opt-in layers), not the requested mode;ready.session_ididentifies this socket’s voice session. At most one turn runs, and talking over the agent stops its VOICE, never its work. While the agent is THINKING, speech and typedtextare queued, never an interruption: each utterance is transcribed at once (utterance.committed+transcript), up to four wait (oldest dropped) and everything queued runs as ONE merged follow-up turn after the current reply. While it SPEAKS,barge_in {heard_chars}(or a typedtext) PAUSES the voice while the run keeps working; the server transcribes the interruption and answersresumedfor an acknowledgment ("mm-hmm", "okay" and their Arabic equivalents) or "continue" — also after ~2.5 s of nothing — orinterrupted {heard_chars}when speech stops for good: "enough" ⇒ the run finishes silently, no new turn; "cancel" ⇒ the run is cancelled; anything else ⇒ the run finishes silently and the words become the next turn. The spoken commands are matched in English and Arabic.barge_inwithin 300 ms ofaudio_startis ignored (echo guard). A spoken sentence that trails off on a connector ("and", "focusing on", or the Arabic counterparts) is held up to 1.5 s (3 s max) for the rest; typed text is never held.mutenever cancels a run.turn.barged_inis true whenever the voice was paused or stopped;turn.heard_charssays how much was heard.turn.errorcarries the errorcodeonly (never a provider message).- Deltas are withheld — and only the governed final text is spoken — when the agent has output guardrails, exactly like the SSE relay.
Access-Control-Allow-Origin on denial).- Auth
- none
curl -i -X OPTIONS https://console.agenticworkforce.me/v1/embed/session \
-H "Origin: https://www.acme.com" -H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
# HTTP/1.1 204 No Content
# access-control-allow-origin: https://www.acme.com
# access-control-allow-methods: GET, POST, OPTIONS
# access-control-allow-headers: authorization, content-type, x-hive-embed-key
# access-control-max-age: 600
# vary: originhive-embed.js (script tag, IIFE), hive-embed.mjs (ESM), react.mjs (React wrapper; react stays external), hive-embed-voice.js (the voice UI, loaded lazily on the first mic tap), hive-embed-avatar.js (the talking face, loaded when the key shows one) and hive-embed-avatar3d.js (the 3D renderer, loaded only for a 3D face).- Auth
- none (public,
Access-Control-Allow-Origin: *) - Errors
NOT_FOUNDEMBED_BUNDLE_UNAVAILABLE
Cache-Control: public, max-age=3600, stale-while-revalidate=86400,ETag(304 onIf-None-Match),X-Content-Type-Options: nosniff./embed/v1/is the major line; patches update it in place; breaking changes ship at/embed/v2/.
curl -I https://console.agenticworkforce.me/embed/v1/hive-embed.js
# HTTP/1.1 200 OK
# content-type: application/javascript; charset=utf-8
# cache-control: public, max-age=3600, stale-while-revalidate=86400
# etag: "…"
# access-control-allow-origin: *
# x-content-type-options: nosniffSSE event types
GET /v1/embed/runs/:id/events relays a redacted subset of the platform’s run events. Each frame is event: <type> + data: <JSON>; the JSON always repeats type.
| type | data | Meaning |
|---|---|---|
run.started | { run_id, status } | The worker picked the run up. |
llm.delta | { idx, text } | A streamed chunk of the assistant reply — append to the pending bubble. idx is the step counter the chunk belongs to (see ?since). Withheld when the agent has output guardrails: then only the final text arrives in run.completed. |
tool.called | { idx } | A tool is running — no name, no arguments. The widget shows a generic "Working…" pill. |
tool.result | { idx, ok } | The tool finished — success flag only (no output). |
run.waiting | {} | Parked for a human approval (HITL). The widget shows "Waiting for a human…". Decided in the console, never in the widget. |
run.resumed | {} | The approval was decided and the run continues. |
run.completed | { status, text } | Terminal. text is the final assistant text when present (null otherwise). The stream closes. |
run.failed | { code } | Terminal. A stable code only — no internal message. The widget shows an error bubble with Retry. |
heartbeat | {} | Every 15 s while the run is live; keeps proxies from closing the stream. |
Not relayed — by design, so a widget never sees internals: step.started, llm.thinking, retrieval.result, node.started, node.completed, handoff, tool.progress, plus every payload field beyond the ones listed (no tool names, arguments or output, no prompts, no token or cost figures, no step metadata). idx on step-bound events is the persisted step index — pass the last one you saw as ?since= when reconnecting.
Consuming the stream
// Reading the relay with fetch (what the widget does). EventSource cannot send
// the Authorization header, so it is not supported.
const res = await fetch(`${apiUrl}/v1/embed/runs/${runId}/events?since=-1`, {
headers: { authorization: `Bearer ${token}`, accept: 'text/event-stream' },
signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, idx); buffer = buffer.slice(idx + 2);
const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim();
if (data) handle(JSON.parse(data)); // { type: 'llm.delta', text } …
}
}
// Or: import { createSseParser } from '@hive/embed' and feed it chunks.Reconnect with ?since=<last step idx> to backfill what you missed; the widget retries three times with exponential backoff before reporting STREAM_LOST. A run that is already terminal returns its backfill and closes immediately.
Error codes
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Origin not allowed for this embed key",
"status": 403,
"code": "EMBED_ORIGIN_DENIED",
"detail": "Add https://evil.example to the embed key's allowed origins."
}| Code | Status | Where | Meaning | What to do |
|---|---|---|---|---|
EMBED_KEY_INVALID | 401 | public | Unknown publishable key — it was never issued, was rotated, or was revoked. | Copy the current key from the console (Agent → Embed). After a rotation, redeploy the new key. |
EMBED_KEY_DISABLED | 401 | public | The key exists but enabled is false (paused) or it is revoked. | Re-enable the key in the console, or create a new one. |
EMBED_ORIGIN_DENIED | 403 | public | The request Origin is not on the key’s allowlist (or a non-browser client sent no Origin and the key does not allow *). No Access-Control-Allow-Origin header is sent, so browsers report a CORS error. | Add the exact origin (https://host[:port], no path) to the key’s allowed origins. Local dev: http://localhost:5173 and http://127.0.0.1:5173 are different origins. |
EMBED_ORIGIN_MISMATCH | 403 | public | A session token — or a voice ticket on the GET /v1/embed/voice upgrade — is being used from a different origin than it was minted for. A mismatched voice ticket is spent (first use wins). | Do not share tokens or tickets across origins; let each page mint its own session and its own ticket right before new WebSocket(...). |
EMBED_UNAUTHORIZED | 401 | public | Missing, malformed or expired session token (a key cannot be rebound to another agent after creation). | Mint a new session (POST /v1/embed/session). The widget does this automatically on 401. |
EMBED_IDENTITY_REQUIRED | 401 | public | The key has identity_required and the session request carried no identity JWT. | Mint an identity JWT on your backend for signed-in users and pass it as user: { token }. |
EMBED_IDENTITY_INVALID | 401 | public | The identity JWT was rejected; reason is one of malformed, alg, signature, expired, claims, exp_too_far, iat_in_future (iat more than 5 min ahead), not_yet_valid (nbf more than 60 s ahead). | Sign with HS256 and the key’s CURRENT identity secret; include sub and an exp at most 24 h ahead; keep server clocks in sync; keep meta ≤ 16 keys. |
EMBED_RUN_IN_FLIGHT | 409 | public | A second message was posted to a thread whose previous run is still queued or running; run_id names the run in flight. | Wait for run.completed / run.failed (or cancel the run) before sending the next message. The widget disables the composer while a reply streams. |
EMBED_THREAD_FULL | 409 | public | The thread holds the maximum of 200 messages (user + agent). | Start a new thread: post the next message without thread_id (the widget offers Reset). |
EMBED_THREAD_REQUIRED_FOR_ATTACHMENT | 400 | public | Attachment ids were submitted before an owned conversation thread existed. | Send the first text turn to create the thread, then upload and attach documents to a later turn. |
EMBED_TOO_MANY_STREAMS | 429 | public | This visitor already holds the maximum of 2 live event streams for the key; Retry-After: 5 is set. Finished runs are always served from the backfill without taking a slot. | Abort streams you no longer read (AbortController.abort() on the fetch; EventSource cannot be used here) — one open stream per tab is enough. |
EMBED_IDENTITY_UNAVAILABLE | 401 | public | An identity JWT was sent but the key has no identity secret configured. | Create the key with with_identity_secret: true or issue a secret via POST /v1/embed-keys/:id/identity-secret. |
EMBED_BUNDLE_UNAVAILABLE | 503 | public | The API is running but the widget bundle was not built into the deployment. | Operators: run pnpm --filter @hive/embed build and restart the API (the API image does this automatically). |
INVALID_ATTACHMENT | 400 | public | An attachment does not belong to this conversation, does not exist, or was already sent. | Use only fresh attachment ids returned by the upload endpoint for this same thread. |
TOO_MANY_FILES | 413 | public | A single embedded-chat turn attempted to upload more than eight files. | Select no more than eight PNG, JPEG, WebP or PDF files for one turn. |
UNSUPPORTED_TYPE | 415 | public | An embedded-chat attachment used a media type outside the accepted set. | Upload PNG, JPEG, WebP or PDF files only. |
FILE_TOO_LARGE | 413 | public | An embedded-chat attachment exceeded the 20 MB per-file upload limit. | Compress or split the document so every uploaded file is at most 20 MB. |
NO_FILE | 400 | public | The multipart attachment request contained no file parts to store. | Add at least one supported file to the multipart request before submitting it. |
RATE_LIMITED | 429 | both | A token bucket is empty (per IP on session mint, per key or per visitor on messages). Retry-After and retry_after (seconds) are set. | Back off for retry_after seconds. Raise the key’s rate_limit_per_min if the traffic is legitimate. |
AGENT_NOT_PUBLISHED | 409 | public | The bound agent has no published version yet. | Publish the agent in the console (Agent → Versions) before embedding it. |
AGENT_DISABLED | 409 | public | The bound agent is disabled: its identity was suspended or revoked, so it starts no new conversations. | An owner or an administrator resumes it on the agent’s Identity tab in the console. |
NOT_FOUND | 404 | both | Unknown id — or a thread/run that belongs to a different visitor (existence is never revealed). | Forget the stored thread_id and start a new thread (the widget does this on 404). |
VOICE_DISABLED | 403 | public | Voice is not enabled for this widget: the deployment has no voice gateway, the tenant has not allowed hands-free voice, the agent manifest has voice.ptt: false, or the key’s config.voice leaves both handsfree and ptt off (the default). | Turn on config.voice.ptt and/or config.voice.handsfree on the embed key (console → Agent → Embed) and check GET /v1/embed/config reports the effective voice flags as true. |
VOICE_TICKET_USED | 401 | public | The voice ticket on the WebSocket upgrade was already spent — tickets are single-use (and expire after 60 s). | Mint a fresh ticket (POST /v1/embed/voice/ticket) right before every new WebSocket(...); the widget does this on each connect. |
VOICE_BUSY | 429 | public | A voice socket is already open for this visitor (one microphone per visitor), or the embed key reached its concurrent voice-session ceiling. | End the other voice session first; for busy sites raise VOICE_EMBED_MAX_SESSIONS_PER_KEY on the deployment. |
VOICE_HANDSFREE_DISABLED | 403 | public | The hello asked for mode: "handsfree" but continuous listening is not granted for this widget: the tenant has not allowed hands-free voice, the agent manifest has voice.handsfree.enabled: false (the default), or the key’s config.voice.handsfree is off. Sent as a fatal error frame, then the socket closes (1008). | Check GET /v1/embed/config — voice.handsfree must be true — or open the session with mode: "ptt" (push-to-talk) when only voice.ptt is on. ready.handsfree tells the client which it was granted. |
VOICE_GLASSES_DISABLED | 403 | public | The hello asked for surface: "glasses". The widget socket never grants it: the glasses surface belongs to the smart-glasses companion app on the console voice session (deployment voice ready, tenant allow_voice_glasses, manifest voice.glasses.enabled). Sent as a fatal error frame, then the socket closes (1008). | Leave surface out of the widget’s hello. |
VOICE_PROTOTYPE_DISABLED | 403 | public | The hello asked for surface: "prototype". The widget socket never grants it: the prototype surface is the prototype builder’s Advisor on the console voice session (manifest voice.prototype.enabled). Sent as a fatal error frame, then the socket closes (1008). | Leave surface out of the widget’s hello. |
VOICE_RATE | 429 | public | Inside a voice session. Non-fatal (error.fatal: false): the key’s rate_limit_per_min is spent — every committed utterance and every typed text counts one — so that utterance was dropped and the session keeps listening. Fatal (error.fatal: true, then close 1008): audio arrived faster than 1.5× real time (a client flooding frames rather than streaming a microphone). | Non-fatal: wait a moment and speak again (the budget refills continuously). Fatal: send 20 ms PCM16 frames as they are captured; never replay a buffered file faster than real time. |
VOICE_SESSION_PROTOCOL | 400 | public | The voice WebSocket received a frame outside the protocol: audio before hello, a malformed control message, a second hello, or an agent_id that does not match the ticket. | Send one hello first (protocol: 1, codec: pcm16, sample_rate: 16000, frame_ms: 20), then 640-byte PCM16 frames and the documented control messages only. |
NAME_TAKEN | 409 | admin | A live embed key with this name already exists for the agent. | Pick another name or revoke the old key first. |
VALIDATION | 400 | both | The body or query failed schema validation; detail lists path: message pairs. | Compare with the request tables on this page (content 1–8000 chars, origins without paths, hex colors, …). |
INTERNAL | 500 | both | An unexpected server-side failure — an unhandled exception, or a broken invariant such as the end-user upsert on session mint returning no row. The body carries no detail on purpose. | Retry once after a short delay; if it persists, report the time, route and visitor id to support — nothing on your side triggers this code. |
Admin endpoints — /v1/embed-keys
Tenant-admin operations on embed keys. Authenticate with a platform API key (Authorization: Bearer hive_…) and the tenant (X-Tenant-Id); see API keys. The console’s Embed tab uses exactly these routes. Every mutation is audited.
{ items: EmbedKey[] }) — never the hash or the identity secret.- Auth
- API key (
Authorization: Bearer hive_…) +X-Tenant-Id, tenant admin - Errors
VALIDATION
Request
| Field | Type | Description |
|---|---|---|
agent_id | query, uuid, optional | Filter by agent. |
include_revoked | query, boolean, optional | Include revoked keys (default live only). |
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id (stable across rotations). |
agent_id | uuid | The one agent this key can talk to. |
name | string | Display label, unique per agent among live keys. |
key_prefix | string | hive_pk_ab12cd34… — the only part of the key ever shown again. |
allowed_origins | string[] | Normalized origins (https://app.acme.com); * = any. |
theme | EmbedTheme | Server-side theme. |
config | EmbedDisplayConfig | Server-side display config. |
identity_required | boolean | Refuse anonymous sessions. |
has_identity_secret | boolean | Whether an identity secret exists (the secret itself is never returned). |
rate_limit_per_min | integer 1–10000 | Messages per minute across all visitors of the key (default 60). |
enabled | boolean | Kill switch; takes effect on the next request, sessions included. |
last_used_at | ISO date-time | null | Bumped on session mint (throttled to once a minute). |
revoked_at | ISO date-time | null | Set by revoke; revoked rows are kept for audit. |
created_at / updated_at | ISO date-time | Timestamps. |
curl "https://console.agenticworkforce.me/v1/embed-keys?agent_id=$AGENT_ID" \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID"key (and identity_secret when requested) exactly once.- Auth
- API key +
X-Tenant-Id, tenant admin - Errors
NOT_FOUNDNAME_TAKENVALIDATION
Request
| Field | Type | Description |
|---|---|---|
agent_id | uuid | Agent to bind (must exist in the tenant). |
name | string 1–80 | Display label. |
allowed_origins | string[] ≤ 50 | Origins like https://app.acme.com (no path), or *. Default [] = nothing allowed. |
theme | EmbedTheme, optional | Server-side theme. |
config | EmbedDisplayConfig, optional | Server-side display config. |
identity_required | boolean, default false | Refuse anonymous sessions. |
with_identity_secret | boolean, default false | Also mint an identity secret (returned once). |
rate_limit_per_min | integer 1–10000, default 60 | Per-key message budget. |
enabled | boolean, default true | Kill switch. |
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id (stable across rotations). |
agent_id | uuid | The one agent this key can talk to. |
name | string | Display label, unique per agent among live keys. |
key_prefix | string | hive_pk_ab12cd34… — the only part of the key ever shown again. |
allowed_origins | string[] | Normalized origins (https://app.acme.com); * = any. |
theme | EmbedTheme | Server-side theme. |
config | EmbedDisplayConfig | Server-side display config. |
identity_required | boolean | Refuse anonymous sessions. |
has_identity_secret | boolean | Whether an identity secret exists (the secret itself is never returned). |
rate_limit_per_min | integer 1–10000 | Messages per minute across all visitors of the key (default 60). |
enabled | boolean | Kill switch; takes effect on the next request, sessions included. |
last_used_at | ISO date-time | null | Bumped on session mint (throttled to once a minute). |
revoked_at | ISO date-time | null | Set by revoke; revoked rows are kept for audit. |
created_at / updated_at | ISO date-time | Timestamps. |
key | string | The publishable key — shown once. |
identity_secret | string, optional | HS256 secret for identity JWTs — shown once, only with with_identity_secret. |
curl -X POST https://console.agenticworkforce.me/v1/embed-keys \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "'"$AGENT_ID"'",
"name": "Marketing site",
"allowed_origins": ["https://www.acme.com", "http://localhost:5173"],
"theme": { "primary": "#0f766e", "radius": 14 },
"config": { "welcome": "Hi! How can we help?", "locale": "auto" },
"with_identity_secret": true,
"rate_limit_per_min": 120
}'
# → 201 { …EmbedKey, "key": "hive_pk_…", "identity_secret": "…" } (both shown once)- Auth
- API key +
X-Tenant-Id, tenant admin - Errors
NOT_FOUNDVALIDATION
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id (stable across rotations). |
agent_id | uuid | The one agent this key can talk to. |
name | string | Display label, unique per agent among live keys. |
key_prefix | string | hive_pk_ab12cd34… — the only part of the key ever shown again. |
allowed_origins | string[] | Normalized origins (https://app.acme.com); * = any. |
theme | EmbedTheme | Server-side theme. |
config | EmbedDisplayConfig | Server-side display config. |
identity_required | boolean | Refuse anonymous sessions. |
has_identity_secret | boolean | Whether an identity secret exists (the secret itself is never returned). |
rate_limit_per_min | integer 1–10000 | Messages per minute across all visitors of the key (default 60). |
enabled | boolean | Kill switch; takes effect on the next request, sessions included. |
last_used_at | ISO date-time | null | Bumped on session mint (throttled to once a minute). |
revoked_at | ISO date-time | null | Set by revoke; revoked rows are kept for audit. |
created_at / updated_at | ISO date-time | Timestamps. |
curl https://console.agenticworkforce.me/v1/embed-keys/$KEY_ID \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID"identity_required, rate limit or enabled (at least one field).- Auth
- API key +
X-Tenant-Id, tenant admin - Errors
NOT_FOUNDVALIDATION
Request
| Field | Type | Description |
|---|---|---|
name | string, optional | |
allowed_origins | string[], optional | Replaces the whole list. |
theme | EmbedTheme, optional | Replaces the whole theme. |
config | EmbedDisplayConfig, optional | Replaces the whole config. |
identity_required | boolean, optional | |
rate_limit_per_min | integer, optional | |
enabled | boolean, optional | Pause without revoking. |
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id (stable across rotations). |
agent_id | uuid | The one agent this key can talk to. |
name | string | Display label, unique per agent among live keys. |
key_prefix | string | hive_pk_ab12cd34… — the only part of the key ever shown again. |
allowed_origins | string[] | Normalized origins (https://app.acme.com); * = any. |
theme | EmbedTheme | Server-side theme. |
config | EmbedDisplayConfig | Server-side display config. |
identity_required | boolean | Refuse anonymous sessions. |
has_identity_secret | boolean | Whether an identity secret exists (the secret itself is never returned). |
rate_limit_per_min | integer 1–10000 | Messages per minute across all visitors of the key (default 60). |
enabled | boolean | Kill switch; takes effect on the next request, sessions included. |
last_used_at | ISO date-time | null | Bumped on session mint (throttled to once a minute). |
revoked_at | ISO date-time | null | Set by revoke; revoked rows are kept for audit. |
created_at / updated_at | ISO date-time | Timestamps. |
curl -X PATCH https://console.agenticworkforce.me/v1/embed-keys/$KEY_ID \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"allowed_origins": ["https://www.acme.com", "https://help.acme.com"], "enabled": true}'- Auth
- API key +
X-Tenant-Id, tenant admin - Errors
NOT_FOUNDVALIDATION
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id (stable across rotations). |
agent_id | uuid | The one agent this key can talk to. |
name | string | Display label, unique per agent among live keys. |
key_prefix | string | hive_pk_ab12cd34… — the only part of the key ever shown again. |
allowed_origins | string[] | Normalized origins (https://app.acme.com); * = any. |
theme | EmbedTheme | Server-side theme. |
config | EmbedDisplayConfig | Server-side display config. |
identity_required | boolean | Refuse anonymous sessions. |
has_identity_secret | boolean | Whether an identity secret exists (the secret itself is never returned). |
rate_limit_per_min | integer 1–10000 | Messages per minute across all visitors of the key (default 60). |
enabled | boolean | Kill switch; takes effect on the next request, sessions included. |
last_used_at | ISO date-time | null | Bumped on session mint (throttled to once a minute). |
revoked_at | ISO date-time | null | Set by revoke; revoked rows are kept for audit. |
created_at / updated_at | ISO date-time | Timestamps. |
key | string | The new publishable key — shown once. |
curl -X POST https://console.agenticworkforce.me/v1/embed-keys/$KEY_ID/rotate \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID"
# → 200 { …EmbedKey, "key": "hive_pk_NEW…" }- Auth
- API key +
X-Tenant-Id, tenant admin - Errors
NOT_FOUNDVALIDATION
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Key id. |
identity_secret | string | The new secret — shown once. Store it server-side only. |
curl -X POST https://console.agenticworkforce.me/v1/embed-keys/$KEY_ID/identity-secret \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID"
# → 200 { "id": "…", "identity_secret": "…" }- Auth
- API key +
X-Tenant-Id, tenant admin - Errors
NOT_FOUNDVALIDATION
curl -X DELETE https://console.agenticworkforce.me/v1/embed-keys/$KEY_ID \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $TENANT_ID"
# → 204With @hive/sdk
The TypeScript SDK wraps the admin endpoints as client.embedKeys:
| Method | Calls | Returns |
|---|---|---|
list({ agentId?, includeRevoked? }) | GET /v1/embed-keys | { items: EmbedKey[] } |
create(input) | POST /v1/embed-keys | EmbedKeyCreated (with key, identity_secret?) |
get(id) | GET /v1/embed-keys/:id | EmbedKey |
update(id, input) | PATCH /v1/embed-keys/:id | EmbedKey |
rotate(id) | POST /v1/embed-keys/:id/rotate | EmbedKeyCreated (new key) |
rotateIdentitySecret(id) | POST /v1/embed-keys/:id/identity-secret | { id, identity_secret } |
revoke(id) | DELETE /v1/embed-keys/:id | void |
import { HiveClient } from '@hive/sdk';
const hive = new HiveClient({
baseUrl: 'https://console.agenticworkforce.me',
apiKey: process.env.HIVE_API_KEY!, // hive_… from the console (API keys)
tenantId: process.env.HIVE_TENANT_ID!,
});
// Create a key for a new customer site
const created = await hive.embedKeys.create({
agent_id: AGENT_ID,
name: 'Customer: Acme',
allowed_origins: ['https://www.acme.com'],
with_identity_secret: true,
});
console.log(created.key); // hive_pk_… — hand to the customer once
console.log(created.identity_secret); // store in THEIR backend secrets, never in a browser
// Rotate on schedule
const rotated = await hive.embedKeys.rotate(created.id);
// Pause / resume
await hive.embedKeys.update(created.id, { enabled: false });
// Clean up
await hive.embedKeys.revoke(created.id);