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.
GET/v1/embed/configPublic display config for a key — what a widget needs before a session exists. The widget calls this on connect and fires hive:ready.
Auth
Publishable key in the X-Hive-Embed-Key header + Origin on the allowlist
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 }
Agent display name + emoji.
theme
EmbedTheme
Server theme.
config
EmbedDisplayConfig
Server display config.
identity_required
boolean
Anonymous sessions refused when true.
No session and no credentials are involved; a page view costs exactly this one call.
Browsers omit Origin on same-origin GETs, so a missing header is evaluated as the implied page origin (Referer origin, else the request’s own origin) against the allowlist.
Continue an existing thread owned by this visitor; omit to start a new one.
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) or embed:vid:<visitor_id>; a foreign thread_id is a 404, never a 403.
The run is a normal chat run with input._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 the run_id); a thread holds at most 200 messages (409 EMBED_THREAD_FULL).
Shell
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": "…" }
GET/v1/embed/threads/:id/messagesRestore the visitor’s own thread (newest 100 messages, oldest first).
Auth
Session token + matching Origin
Rate limit
per-visitor read bucket: 60/min shared by history, cancel and event streams
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
OPTIONS/v1/embed/*204 No ContentCORS preflight. Reflects the request origin; enforcement happens on the actual request (fail-closed: no Access-Control-Allow-Origin on denial).
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, 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.
// Reading the relay with fetch (what the widget does). EventSource cannot send// the Authorization header, so it is not supported.const res =awaitfetch(`${apiUrl}/v1/embed/runs/${runId}/events?since=-1`,{headers:{authorization:`Bearer ${token}`,accept:'text/event-stream'},
signal,});const reader = res.body!.getReader();const decoder =newTextDecoder();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.
HTTP/1.1403 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 is being used from a different origin than it was minted for.
Do not share tokens across origins; let each page mint its own session.
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_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).
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.
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).
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, …).
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.
GET/v1/embed-keysList embed keys ({ items: EmbedKey[] }) — never the hash or the identity secret.
Auth
API key (Authorization: Bearer hive_…) + X-Tenant-Id, tenant admin
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
provision.tsTypeScript
import{HiveClient}from'@hive/sdk';const hive =newHiveClient({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 siteconst 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 scheduleconst rotated =await hive.embedKeys.rotate(created.id);// Pause / resumeawait hive.embedKeys.update(created.id,{enabled:false});// Clean upawait hive.embedKeys.revoke(created.id);