Agentic Workforce ME Developer PortalDocs 1.0 · Widget 0.1.0

Reference

Authentication & security

Publishable keys, origin allowlists, identity JWTs, session tokens, CSP, rate limits, rotation, PII and the threat model.

Publishable keys and the origin allowlist

An embed key (hive_pk_ + 43 URL-safe characters, format ^hive_pk_[A-Za-z0-9_-]{43}$) is designed to be visible in your page source. It is:

  • bound to one agent — it cannot reach any other agent, thread, file or tenant data;
  • bound to an origin allowlist (up to 50 entries). Origins are exact — scheme, host and port, no path. Every request carries the browser’s Origin; a mismatch is a 403 EMBED_ORIGIN_DENIED sent without Access-Control-Allow-Origin, so a foreign page cannot even read the response (the OPTIONS preflight itself answers 204; enforcement is on the actual request). An empty list allows nothing. The single wildcard entry * allows every origin — acceptable while prototyping, not in production;
  • hashed at rest (SHA-256) — the console shows the full key once, at creation or rotation. Afterwards only the prefix hive_pk_xxxxxxxx… is visible;
  • rate-limited and switchablerate_limit_per_min, enabled, rotate, revoke (below).

Someone who copies the key from your page can do exactly what a visitor of your site can do: send rate-limited messages to that one agent. They cannot read other visitors’ conversations, cannot change configuration and cannot use the key to call any other API.

Identified end users (identity JWT)

By default visitors are anonymous (a random visitor id). To attach conversations to your signed-in users, enable Identify signed-in users on the key. The console issues an identity secret — a 43-character base64url string, shown once — that only your backend may hold. Your backend signs a short-lived HS256 JWT with it and your page passes the token to the widget as user: { token }.

Claims

ClaimTypeRequiredDescription
substring 1–256yesYour user id → end_users.external_id (upserted per tenant).
expinteger (unix seconds)yesExpiry. Must be in the future and at most 24 h ahead.
iatinteger (unix seconds)noIssued-at. Rejected (iat_in_future) when more than 5 minutes ahead of the server clock.
nbfinteger (unix seconds)noNot-before. Honored with 60 s of clock skew; rejected (not_yet_valid) beyond that.
namestring ≤ 200noDisplay name shown in the console Contacts page.
emailemail ≤ 320noStored on the contact with a pii_tags: ["email"] marker.
metaobject ≤ 16 keys; values string ≤ 500 | number | booleannoFree-form attributes stored under end_users.attributes.embed.
  • Header must be {"alg":"HS256","typ":"JWT"} (typ may be omitted). Any other alg — including none — is rejected.
  • exp is required, must be in the future and at most 24 hours ahead. One hour is a sensible default; the widget sends the token only when it mints a session, so short lifetimes cost nothing.
  • iat and nbf are optional but checked when present: iat more than 300 s in the future or nbf more than 60 s in the future is rejected. Keep your server clock in sync (NTP).
  • The signature is HMAC-SHA256 over base64url(header).base64url(payload) with the secret string’s UTF-8 bytes as the key — pass the secret to your JWT library as-is; do not base64-decode it.
  • Whole token ≤ 4096 characters. Unknown claims are ignored; meta is limited to 16 keys with string (≤ 500 chars), number or boolean values.

Minting the token on your backend

Expose an authenticated endpoint that returns a token for the current user. Samples with no JWT library (hand-rolled HS256 is 6 lines) and with PyJWT. Omit optional claims rather than sending null "email": null fails validation and the whole token is rejected with EMBED_IDENTITY_INVALID (reason: claims); every sample drops empty values before signing.

server.tsTypeScript
import express from 'express';
import { createHmac } from 'node:crypto';

const app = express();
const IDENTITY_SECRET = process.env.HIVE_EMBED_IDENTITY_SECRET!; // server-side only

const b64url = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
// Omit optional claims rather than sending null — "email": null is rejected.
const compact = <T extends object>(o: T) =>
  Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null));

function mintHiveIdentity(user: { id: string; name: string; email?: string | null; plan?: string | null }) {
  const now = Math.floor(Date.now() / 1000);
  const header = b64url({ alg: 'HS256', typ: 'JWT' });
  const payload = b64url(compact({
    sub: user.id,
    name: user.name,
    email: user.email,
    meta: user.plan ? { plan: user.plan } : undefined,
    iat: now,
    exp: now + 60 * 60, // 1 hour (max 24 h)
  }));
  const sig = createHmac('sha256', IDENTITY_SECRET).update(`${header}.${payload}`).digest('base64url');
  return `${header}.${payload}.${sig}`;
}

// Behind your own session auth; returns the token for the current user.
app.get('/api/hive-identity', requireLogin, (req, res) => {
  res.json({ token: mintHiveIdentity(req.user) });
});
BrowserTypeScript
// Browser: fetch the token from YOUR backend, then hand it to the widget.
const { token } = await fetch('/api/hive-identity', { credentials: 'include' }).then((r) => r.json());

const chat = HiveChat.init({
  key: 'hive_pk_…',
  user: { token },          // the widget sends it once, when minting the session
});

// Later (token about to expire, or user switched account):
chat.update({ user: { token: freshToken } });   // starts a new session for that user
chat.update({ user: undefined });                // back to an anonymous visitor
ErrorStatusMeaningFix
EMBED_IDENTITY_REQUIRED401The 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_INVALID401The 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_IDENTITY_UNAVAILABLE401An 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_IDENTITY_INVALID carries a reason extension: malformed, alg, signature, expired, claims, exp_too_far, iat_in_future or not_yet_valid.

Session tokens

On page load the widget only fetches the public display config (GET /v1/embed/config, key in the X-Hive-Embed-Key header so it never lands in access logs). The first time the visitor opens the panel or sends a message it exchanges the key (and the optional identity JWT) for a session token via POST /v1/embed/session. The token is an HMAC-signed, opaque string valid for 15 minutes and is sent as Authorization: Bearer on every subsequent call. It is scoped to:

FieldScope
vFormat version (1).
tTenant id.
kEmbed key id.
aAgent id.
oOrigin the token was minted for (* when the key allows any origin and none was sent).
vidVisitor id.
euEnd-user id when identified, else null.
iat / expIssued-at / expiry (unix seconds; 15-minute lifetime).

The widget refreshes it transparently (a re-mint when a call finds the token within 30 s of expiry, or one re-mint and retry after a 401) and never places it in a URL — which is also why the SSE endpoint cannot be consumed with EventSource.

What the browser holds — and what it does not

In the browserNever in the browser
  • the publishable key (public by design)
  • the current session token (memory only; 15-minute TTL)
  • the visitor id and current thread id (storage of your choice)
  • the identity JWT you hand it (short-lived, sent once)
  • the redacted run events: text deltas, anonymous tool activity, statuses
  • your platform API key
  • the identity secret
  • tool credentials, connection secrets, model keys
  • raw run state, prompts, tool names, arguments, results or costs
  • other visitors’ threads (404 by construction)
  • cookies — the widget sets none

Content-Security-Policy

Two directives, both for the origin your snippet points at (https://console.agenticworkforce.me for the hosted platform):

HTTP
Content-Security-Policy:
  default-src 'self';
  script-src  'self' https://console.agenticworkforce.me;
  connect-src 'self' https://console.agenticworkforce.me;
  # style-src: nothing to add — the widget uses a constructed stylesheet.
  # img-src:   nothing to add — no external images are loaded.

Styles are installed with a constructed stylesheet (adoptedStyleSheets), which style-src does not govern — no 'unsafe-inline', no nonce, no hash. No frame-src (no iframes), no img-src (the agent avatar is text), no third-party origins. The only fallback:

HTTP
# Only if you must support Safari < 16.4 under a strict style-src:
style-src 'self' 'unsafe-inline';

Rate limits

BucketLimitApplies to
Config reads per IP60 / minGET /v1/embed/config, per client IP.
Session mints per IP30 / minPOST /v1/embed/session, per client IP.
Session mints per key10 × rate_limit_per_minPOST /v1/embed/session, across all visitors of the key.
Messages per keyrate_limit_per_min (default 60, max 10,000)POST /v1/embed/messages, across all visitors of the key.
Messages per visitor20 / minPOST /v1/embed/messages, per visitor (or identified user).
Reads per visitor60 / minHistory, cancel and opening GET …/events, per visitor.
Live streams per visitor2 concurrentOpen GET …/events connections; a third one is 429 EMBED_TOO_MANY_STREAMS.
Messages per thread200Beyond that POST /v1/embed/messages answers 409 EMBED_THREAD_FULL; the visitor starts a new conversation.

When a token bucket is exhausted the API answers:

HTTP
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 42

{ "type": "about:blank", "title": "Too Many Requests", "status": 429,
  "code": "RATE_LIMITED", "detail": "Embed rate limit exceeded. Try again shortly.",
  "retry_after": 42 }

The widget shows a retryable error bubble (code RATE_LIMITED) and keeps the thread; the visitor can send again after Retry-After. Adjust rate_limit_per_min on the key for legitimate high traffic; the per-IP, per-visitor, stream and thread guards are fixed. One reply at a time per thread is also enforced: sending while a run is active returns 409 EMBED_RUN_IN_FLIGHT. The platform’s global limiter still applies on top.

Key rotation, pausing and revocation

  • Rotate issues a new publishable key and invalidates the old one immediately. Existing sessions keep working until they expire (≤ 15 min); the next mint with the old key fails with 401 EMBED_KEY_INVALID. Update your snippet, then rotate — or rotate first and accept a short outage.
  • Pause (enabled: false) rejects every call with 401 EMBED_KEY_DISABLED without changing the key. Visitors see the widget’s offline notice; flipping it back needs no redeploy.
  • Revoke soft-deletes the key. Tokens minted with it stop working on their next request. Threads and audit rows are retained.
  • Rotate the identity secret independently of the key. Old identity JWTs fail with EMBED_IDENTITY_INVALID (signature).

All of these are one click in the console, or an API call:

Shell
# Rotate: the old key stops working immediately; the response carries the new one (once).
curl -X POST https://console.agenticworkforce.me/v1/embed-keys/{id}/rotate \
  -H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID"

# Pause without breaking anything (visitors see the offline notice; re-enable any time)
curl -X PATCH https://console.agenticworkforce.me/v1/embed-keys/{id} \
  -H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'

# Revoke for good (soft delete; sessions minted with it fail on their next call)
curl -X DELETE https://console.agenticworkforce.me/v1/embed-keys/{id} \
  -H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID"

Every create, update, rotate, secret issue and revoke is written to the tenant’s append-only audit log. See the admin API.

PII guidance

  • Send only what you need in the identity token: sub is enough for continuity; name/email make the Contacts page useful; meta is for routing attributes (plan, region), not for profiles.
  • Identity fields are stored on the contact (end_users) with PII tags. They are never injected into prompts, run state or checkpoints.
  • Message content is what the visitor typed; it becomes a user message on a governed run. Tell visitors not to paste secrets, and use the agent’s guardrails for input/output filtering. Message content is logged at debug level only.
  • Anonymous visitor ids are random and stored client-side; use storage: 'session' or 'none' on shared devices (kiosks).
  • Data residency, retention and deletion follow your tenant’s platform agreement.

Threat model summary

Condensed from the platform specification (docs/07-embed.md §9):

ThreatControl
Publishable key copied from page sourceMeant to be public: bound to one agent and an origin allowlist, per-key rate limit, hashed at rest, rotate/revoke in one click. No tenant data is readable with it.
Origin spoofing (curl with a fake `Origin`)Only allows what a visitor on the allowed site can do: send rate-limited messages. Non-browser callers cannot read cross-origin responses anyway; abuse is bounded by buckets and enabled: false.
Session token theft (XSS on the host page)15-minute TTL, bound to origin and visitor; cannot mint new tokens without the key; cannot read other visitors’ threads. Tokens never appear in URLs.
Cross-visitor thread accessThread creator must equal the token subject and the thread’s agent must equal the token agent, else 404.
Cross-tenant accessEvery query runs under the tenant of the token with row-level security forced on embed_keys; covered by the isolation test suite.
Abuse / costPer-key rate_limit_per_min (default 60), per-visitor 20 messages/min and 60 reads/min, per-IP config reads 60/min and session mints 30/min, 2 concurrent streams per visitor, 200 messages per thread, one in-flight reply per thread; the global limiter still applies; enabled is the kill switch.
Prompt injection through the widgetSame surface as any chat: content is a user message; the agent’s input/output guardrails apply unchanged; embed messages never carry a system role or tool results.
Identity forgeryHS256 with a per-key secret only your backend holds; alg pinned; exp ≤ 24 h, iat/nbf clock-skew checked; identity_required makes anonymous sessions impossible for that key.
PIIIdentity fields land in end_users with PII tags; never in prompts or checkpoints; meta capped at 16 keys. Message content is logged at debug only.
Secret exposureIdentity secret encrypted (AES-GCM) at rest and decrypted only inside the session handler; the session-token key stays in memory; tests assert no secret appears in list/get responses.
Clickjacking of the console previewThe console preview iframe is same-origin only; the public bundle never renders console UI.