# Ognate — AI Support Chat Widget > Ognate is an embeddable, multi-tenant AI support chat widget backed by a > RAG knowledge base, with human-rep escalation. This file is a self-contained > reference for **customer developers** (and the agents helping them) to do every > integration task: embed the widget (bubble UI or headless), identify users, > refresh tokens, receive and reply to escalations, and expose live customer > data to the assistant. Everything below is the live wire contract. > > **Contract revision:** 2026-08-18 · tracks widget ≥ v0.1.164. This file follows > the running code; if your copy is older, re-fetch from the deployment. > > Conventions used here: > - `https://` — the host serving the widget, admin, and chat > API (all share one CloudFront origin). > - API root prefix is `/chatapiv1`. A deployed call is e.g. > `POST https:///chatapiv1/session/init`. > - `` — the tenant's public channel/key (`tk-...`), shown read-only on > the admin General tab. Used as `data-channel`. This is **not** the secret API key. > - `og_...` — the server-to-server tenant API key (admin Config → API Key). > > Authoritative sources in-repo: `public/widget/widget.d.ts` (typed client > contract), `public/widget/widget.js` (wire truth), `src/api/` (FastAPI app), > admin integration guide `public/admin/js/pages/tenant-detail.js`. When this > file and a route docstring disagree, this file follows the running code. --- ## Decision: which integration do I need? - **Just want a chat bubble on my site, anonymous visitors** → [Embed: bubble UI](#embed-bubble-ui). One script tag. No backend, no API key. - **Custom chat UI / SPA / mobile webview** → [Embed: headless](#embed-headless). `data-headless` + `window.OgnateChat`. - **Identify logged-in users (name, tier, account context)** → [Identity & tokens](#identity--tokens). Needs the `og_...` API key + a server-side token mint. - **Route conversations to human reps (email / Slack / Zendesk / custom)** → [Escalation](#escalation). - **Let the assistant read live per-user data (balance, order status, ...)** → [Customer-data tool](#customer-data-tool-query_user_data). - **Drive the chat protocol directly (server-to-server, no widget)** → [REST API reference](#rest-api-reference). --- ## Embed: bubble UI Default embed. Renders a floating bubble (bottom-right, `z-index:9999`) that toggles a chat panel. Paste just before `` on every page that should show the widget. ```html ``` - `data-api-url` is effectively **required**: if omitted, the widget defaults to `/chatapiv1`, which resolves to `/widget/chatapiv1` (the static origin → 404). Always set it to the real `/chatapiv1` path. - Optional: `data-theme="auto|light|dark"` (default `auto`, scoped to the widget root — does not touch the host page ``), `data-lang="en|he"` (default: `` → `navigator.language` → `en`). - Any element on the page with `[data-action="open-widget"]` also opens the panel (delegated click) — use it for your own "Chat with us" buttons. Branding (title, subtitle, welcome text, accent color) is **server-driven** per tenant (admin Widget tab), not set via attributes. See [Tenant settings](#tenant-settings). ## Embed: headless Add `data-headless` to skip all DOM/CSS injection. The IIFE still bootstraps and exposes `window.OgnateChat` (the full typed client). Build your own UI on top. ```html ``` `window.OgnateChat.ready` is a promise that resolves once bootstrapped — **awaiting it is order-independent** with respect to the IIFE's bootstrap (it resolves immediately if already booted). One caveat: the `OgnateChat` global is created only when the widget script *executes*, so with an `async` script tag `window.OgnateChat` may still be `undefined` if your code runs first. Reference it from the script's `onload` (or from a later non-async script) — don't dereference `window.OgnateChat.ready` from code that may run before the async script loads. ```js // Robust: inject the script and drive it from onload (matches widget.demo.html). const s = document.createElement("script"); s.src = "https:///widget/widget.js"; s.async = true; s.setAttribute("data-channel", ""); s.setAttribute("data-api-url", "https:///chatapiv1"); s.setAttribute("data-headless", ""); s.onload = async () => { const chat = await window.OgnateChat.ready; // global exists by onload chat.on("chunk", ({ full }) => render(full)); // streaming partial text chat.on("reply", ({ text, escalate }) => done(text)); await chat.init(); // mint/resume a session const reply = await chat.sendMessage("hello"); // resolves when stream closes }; document.body.appendChild(s); ``` All script attributes (`data-identity-token`, `data-lang`, `data-theme`, `data-identity-token-refresh-url`) work identically in headless mode. ### Script-tag attributes (full set) | Attribute | Purpose | Required | Default | |---|---|---|---| | `data-channel` | Tenant channel key (`tk-...`). Sent as `X-Tenant-Key`; namespaces local/session storage. | **Yes** | — | | `data-api-url` | API base. Set to `https:///chatapiv1`. | **Yes (in practice)** | `/chatapiv1` (usually wrong) | | `data-theme` | `light` \| `dark` \| `auto`. Scoped to widget root. | No | `auto` | | `data-lang` | UI locale (`en`, `he`). | No | `` → `navigator.language` → `en` | | `data-headless` | Presence flag. Skip DOM, expose `window.OgnateChat`. | No | absent (bubble UI) | | `data-identity-token` | Server-minted signed identity token for a **known user**. | No | falls back to stored anon token | | `data-identity-token-refresh-url` | Same-origin URL the widget GETs to re-mint an expired user token. | No | none (expiry surfaces `tokenExpired`) | ### Headless client API (`window.OgnateChat`) Mirrors `public/widget/widget.d.ts`. | Method | Purpose | |---|---| | `init()` | Idempotent. `POST /session/init` if no token cached. Resolves `{sessionId, escalationEnabled, autoEscalate}`. | | `fetchWidgetConfig()` | `GET /widget/config` — tenant branding before any identity exists. Mints nothing. Resolves `{widget_config, chat_enabled}`, or `null` on any failure (never throws). Call on load; `init()` returns the same branding, but not until the first send. | | `sendMessage(text)` | Send a user turn. Streams via `chunk`; resolves to `{messageId, text, escalate}` when the stream closes. **Throws if the session is escalated** — use `sendRepMessage`. | | `escalate()` | Flip the session to pending escalation (notifies reps). | | `sendRepMessage(text)` | Send a user message during an active escalation. | | `sendFeedback(messageId, "good"\|"bad")` | Fire-and-forget thumbs rating on a streamed reply. | | `listMySessions({cursor?, limit?})` | Page the caller's prior sessions. `next_cursor` is `null` when exhausted. | | `loadSession(sessionId)` | Resume a session by id; fires `sessionSwitched` with replayed history. | | `newSession()` | Force-mint a fresh session; fires `sessionSwitched` with `[]`. | | `deleteSession(sessionId)` | Soft-delete (hide) a session. | | `dispose()` | Abort in-flight requests, stop polling, clear listeners. Call on unmount. | | `state` (getter) | `{sessionId, escalationEnabled, autoEscalate, escalationState, polling}`. | | `on(event, fn)` / `off(event, fn)` | Subscribe/unsubscribe. `on` returns an unsubscribe fn. | | `ready` | Promise resolving the client once bootstrapped. | | `ESCALATION_STATE` | Frozen `{none, pending, joined}`. | ### Events Subscribe with `chat.on("name", fn)`. Errors from **awaited** methods (`init`, `sendMessage`, `escalate`, `sendRepMessage`) reject the returned promise — catch them with `try/catch`. The `error` event is only for **background** failures (poll loop, listener throws, auto-escalate after the await already resolved). | Event | Payload | When | |---|---|---| | `session` | `{sessionId, escalationEnabled, autoEscalate, widgetConfig}` | Once, after `init()`. `widgetConfig` = server branding, may be null. | | `userMessage` | `{text}` | Before a user-typed message is sent. | | `chunk` | `{messageId, text, full}` | Each streamed chunk; `full` = running text. | | `reply` | `{messageId, text, escalate}` | Once the `/chat` stream completes. | | `toolCall` | `{phase: "started"\|"cached"\|"failed", fields, request_id, label}` | Assistant invoked the customer-data tool. May fire >1×/message. | | `escalated` | `{}` | After a successful `escalate()`. | | `repJoined` | `{}` | First rep message arrives. | | `repMessage` | `{id, role: "rep"\|"assistant", content}` | Each rep/assistant message during escalation. | | `sessionSwitched` | `{sessionId, messages}` | After `loadSession`/`newSession`. `messages` = role+content only. | | `sessionEnded` | `{}` | Rep ended the escalation (server closed the session). | | `error` | `Error` | Background-only failures. | | `injectionDetected` | `{messageId, text}` | The assistant reply leaked the per-session injection-canary sentinel (a working prompt injection). The widget already suppressed the reply — the awaited `sendMessage` resolves with empty text. `text` is the raw model output, for your logging/alerting; you may call `escalate()`. Inert unless the server opts your tenant in. | | `tokenExpired` | `{errorCode, message}` | Non-recoverable identity 401. `errorCode` ∈ `identity_token_expired` \| `identity_token_invalid` \| `tenant_key_invalid`. Fires once per page load; surface `message` to the user. | --- ## Identity & tokens The widget always carries one **identity token** in the `X-Identity-Token` header. There are two kinds: - **Anonymous** (`kind=anon`, 365-day TTL): no setup. On the first `POST /session/init` with no `X-Identity-Token`, the server mints an anon token and returns it as `identity_token`; the widget persists it in `localStorage` (key `og-chat:identity-token:`) and resends it. Sessions follow the browser. No API key, no backend needed. - **Identified** (`kind=user`, 12-hour TTL): you mint a signed token server-side for a logged-in user and inject it via `data-identity-token`. Sessions follow the user across browsers/devices. Requires the `og_...` API key. Identity tokens are **HS256 JWTs**, `aud=widget-user`, tenant-bound (`tid`), signed by Ognate (you never sign them yourself). There is **no revocation list** — a leaked user token stays valid until its `exp` (≤12h), and `og_...` key revocation (Step 5) does **not** invalidate already-minted identity tokens, so mint a fresh token per page load / session and weigh the user-token TTL in your threat model. The identity contract (these fixed limits) is shown in the admin portal and surfaced on the admin-only, Cognito-gated `GET /chatapiv1/admin/tenants/{tenant_id}` as `identity_contract` — there is no customer-callable endpoint for it; the values are constants: ```json { "user_token_ttl_s": 43200, "user_token_ttl_hours": 12, "max_claims_bytes": 2048 } ``` ### Step 1 — Mint a user token (server-to-server) `POST https:///chatapiv1/embed/v1/token` - **Server-to-server only.** Any browser `Origin` header → `403`; CORS headers are stripped from the response so browsers cannot read it. Call it from your backend. - Header: `Authorization: Bearer og_...` (your tenant API key). - Body: ```json { "user_id": "acct_12345", "claims": { "tier": "gold", "locale": "en" } } ``` - `user_id` (required): stable per-user id. Must **not** start with `uid:`, `client_id:`, or `anon:`, and must **not** contain `#` or `:`. - `claims` (optional): freeform per-user context, JSON-serialized UTF-8 ≤ **2048 bytes**. Carried inside the signed JWT and validated, but **not currently surfaced to the assistant** — the model does not read these values today. To give the assistant per-user data it can use mid-conversation (or anything larger / dynamic), use the [customer-data tool](#customer-data-tool-query_user_data). - Anonymous→identified history merge needs **no field here** — see [Step 4](#step-4--anonymous--identified-re-linking). - Response: ```json { "identity_token": "eyJ…", "expires_in": 43200, "token_type": "Bearer" } ``` - Errors: `403` (browser Origin present), `401` (missing/bad `Authorization` or unknown/revoked key), `400` (invalid `user_id` or claims over 2 KB). ```python import requests def mint_widget_token(api_key: str, user_id: str, claims: dict) -> str: r = requests.post( "https:///chatapiv1/embed/v1/token", headers={"Authorization": f"Bearer {api_key}"}, json={"user_id": user_id, "claims": claims}, timeout=5, ) r.raise_for_status() return r.json()["identity_token"] ``` ### Step 2 — Hand the token to the widget Server-render it into `data-identity-token` (works for bubble and headless): ```html ``` ### Step 3 — Auto-refresh expired tokens (optional) User tokens expire after 12h; past expiry the widget shows "Identification expired. Please refresh the page." To recover silently, host a **same-origin** GET endpoint that returns a fresh token, and point the widget at it: ```html ``` Behavior: on an `identity_token_expired` 401 the widget GETs the URL with `credentials: "include"` (10s timeout), expects `{ "identity_token": "..." }` back, swaps it in-memory, and retries the failed request once. The widget does **not** refresh on `identity_token_invalid` / `tenant_key_invalid` (those are security events, not stale tokens). Cross-origin refresh URLs must return `Access-Control-Allow-Credentials: true` and a non-wildcard `Access-Control-Allow-Origin`. Pin `ACAO` to your exact known origin(s) — never reflect the request `Origin` back — and require host login on the endpoint: it returns a bearer-equivalent identity token, so a permissive `ACAO` turns it into a token-harvest endpoint. ```python # Your backend (FastAPI example). Reuses your own host session to authorize, # then proxies a mint to /embed/v1/token (Step 1) and returns its JSON verbatim. @app.get("/widget-token") async def widget_token(request: Request) -> dict: user = await require_login(request) # YOUR existing host-auth check async with httpx.AsyncClient(timeout=5) as client: r = await client.post( "https:///chatapiv1/embed/v1/token", headers={"Authorization": f"Bearer {OGNATE_API_KEY}"}, # from your secret store json={"user_id": user.id, "claims": {"email": user.email}}, ) r.raise_for_status() return r.json() # {identity_token, expires_in, token_type} ``` ### Step 4 — Anonymous → identified re-linking **Automatic — nothing to configure.** Before login the widget self-provisions an anon token (stored at `localStorage["og-chat:identity-token:"]`). When you hand it a `data-identity-token` (Step 2), the widget presents the prior anon token to `/session/init` as the `X-Relink-Token` header; the server **verifies its signature** (must be a `kind=anon` token bound to your tenant) before merging the prior conversation onto the identified user. The prior anon identity is *proven*, never asserted — there is no `sub` for you to decode or pass. This fires on the **page load** that carries the identity token (bubble or server-rendered headless, per Step 2). A pure SPA that swaps `data-identity-token` at runtime *without* a reload does not auto-relink yet — render the token at page load to get the merge, or (non-widget clients) send the raw prior anon token in `X-Relink-Token` on your own `/session/init` call. Gated by the tenant's "Re-link anonymous sessions" setting (default on). ### Step 5 — Key rotation & revocation - **Rotate** the `og_...` key (admin Config → API Key): mints a new key; the previous stays valid for **24 hours** (zero-downtime). - **Revoke**: invalidates both slots immediately; `/embed/v1/token` returns `401` until a new key is generated. Use this for a leaked key. - The token-**signing** key rotates separately and transparently; in-flight tokens stay valid across rotation. - The 8 hex chars between `og_` and the next `_` are the non-secret **key ID** — safe to quote in logs/support tickets to identify which key is in use. --- ## REST API reference For driving the chat protocol directly (or building a non-JS client). All paths are under `/chatapiv1`. The widget uses exactly these. Machine-readable spec: `/openapi.json` on this host (OpenAPI 3.1) — fetch it directly if you are an agent or generating a client. Human reference (read-only, rendered from the same spec): `/` on this host. This file, the spec and the reference are published together on the docs origin (docs.ognate.com on prod) — all public, no login. **Header cheat-sheet** (exact names): - `X-Tenant-Key` — tenant channel key (`tk-...`). On `/session/init`, `/sessions`, `/session/{id}/messages`, `/session/{id}/hide`, `/escalate/poll-all`. - `X-Identity-Token` — the widget identity JWT (anon or user). Sent on the same routes as `X-Tenant-Key`, plus first `/session/init`. - `X-Relink-Token` — (optional) the prior **anon** identity JWT, presented on `/session/init` at the anon→identified transition. The server verifies its signature (must be `kind=anon`, your tenant) before merging prior history. The widget sends it automatically; see [Step 4](#step-4--anonymous--identified-re-linking). - `X-Session-Token` — the 1-hour **session JWT** from `/session/init`. Required on `/chat`, `/escalate`, `/escalate/poll`, `/escalate/message`, `/feedback`. ⚠ Not `Authorization: Bearer` — session auth uses the `X-Session-Token` header. - `Authorization: Bearer og_...` — tenant API key, **only** on `/embed/v1/token`. ### Session lifecycle **`GET /widget/config`** — tenant branding, before any identity exists. - Header: `X-Tenant-Key` (required). No identity, no session — nothing is minted, and an `X-Identity-Token` sent here is ignored rather than validated. - Response: `{ "widget_config": { }, "chat_enabled": true }` - Cached `private, max-age=60`, `Vary: X-Tenant-Key`. - Errors: `401 {error_code: tenant_key_invalid, detail}`; `429` when the per-tenant read budget is exhausted. Call this to paint your UI on load. `/session/init` returns the same `widget_config`, but only once the visitor sends their first message — so relying on it alone means showing unbranded defaults for the whole visit up to that point. Failures here should be non-fatal: fall back to your own defaults. **`POST /session/init`** — mint or resume a session. - Headers: `X-Tenant-Key` (required), `X-Identity-Token` (optional), `X-Relink-Token` (optional — the verified prior anon token to merge on login). - Body (optional): `{ "session_id": "" }` to resume. A non-v4 / unknown / foreign / terminal / deleted id silently mints a new session. - Response (`null` fields omitted): ```json { "session_id": "...", "token": "", "widget_config": { }, "escalation_enabled": true, "auto_escalate": false, "identity_token": "", "status": "ACTIVE|ESCALATED|REOPENED|RESOLVED" } ``` - Errors: `401 {error_code: tenant_key_invalid|identity_token_expired|identity_token_invalid, detail}`. ### Chat **`POST /chat`** — send a message, stream the reply. - Header: `X-Session-Token`. - Body: `{ "message": "..." }` (3–4000 chars; <3 → canned "please elaborate", >4000 → `413`). - Response: `200 text/plain` **NDJSON stream**, one JSON object per line: - `{"tool_call": {...}}` — 0+ tool lifecycle frames (started/cached/failed). - `{"chunk": "..."}` — partial assistant text. - final `{"reply": {"message_id": "...", "text": "...", "escalate": false}}`. - Errors: `401`, `403 chat_suspended` (tenant master switch — see Configure), `413`, `429` (per-user rate limit: identified 30 rpm, anon 10 rpm; body `{error_code:"rate_limit_exceeded", retry_after_s}`). ### Escalation (customer/widget side) **`POST /escalate`** — flip session to escalated, notify reps. Idempotent. - Header: `X-Session-Token`. Body: none. - Response: `{ "ok": true, "session_id": "..." }`. - Errors: `403 chat_suspended` (tenant master switch — see Configure), `409 escalation_not_configured` (no enabled rep email and no enabled webhook). **`GET /escalate/poll?session_id=&after=`** — poll for rep/assistant messages. - Header: `X-Session-Token` (widget may only poll its own `sid`). - Response: `{ "messages": [{id, role, content}], "closed": bool, "status": "..." }`. **`GET /escalate/poll-all?since=`** — multiplexed rep-reply poll across all the caller's sessions. - Headers: `X-Tenant-Key` + `X-Identity-Token`. `since` required (strict `created_at > since`). - Response: `{ "messages": [{session_id, message_id, role:"rep", content, created_at}], "newest_at": "..." }`. **`POST /escalate/message`** — user message during an escalated session (also reopens a RESOLVED session). - Header: `X-Session-Token`. Body: `{ "session_id": "", "content": "..." }` (1–4000). - Response: `{ "ok": true, "message_id": "..." }`. ### History & management **`GET /sessions?cursor=&limit=`** (headers `X-Tenant-Key` + `X-Identity-Token`; `limit` default 20, max 50) → `{ "sessions": [{session_id, status, created_at, updated_at, first_message, label, title}], "next_cursor": string|null }`. **`GET /session/{id}/messages?cursor=&since=&limit=`** (same headers; `limit` default 50, max 100; ownership mismatch → `404`) → `{ "messages": [{message_id, role, content, created_at}], "next_cursor": string|null }`. **`POST /session/{id}/hide`** (same headers; idempotent; mismatch → `404`) → `204`. Soft-deletes (tombstone, hard-purged after ~24h); flips an active escalation to RESOLVED. **`POST /feedback`** (header `X-Session-Token`) — body `{ "message_id", "session_id", "rating": "good"|"bad", "note"? }` → `{ "ok": true }`. ### Server-to-server - **`POST /embed/v1/token`** — see [Identity Step 1](#step-1--mint-a-user-token-server-to-server). - **`POST /integrations/messages`** — relay a rep reply back into a session; see [Escalation, inbound](#inbound-you--ognate-reply-into-a-session). - **`POST /integrations/draft`** — fetch the AI-suggested reply for an escalated session (L3); see [AI assistance](#ai-assistance-l2l3). - **`GET /health`** → `{ "ok": true }` (anonymous). > Not available: there is no `/refresh` endpoint (refresh is your tenant-hosted > GET URL), no `/healthz`, and **no programmatic document-upload / KB endpoint** — > knowledge-base/sources management is admin-portal only. ### Error envelope summary `400 {"error"}` (bad value) · `401`/`403`/`404`/`409`/`413`/`503 {"error"}` · domain-coded errors `{ "error_code", "message" }` (e.g. escalation states) · identity errors `401 { "error_code", "detail" }` + `WWW-Authenticate: Bearer error=""` (also surfaced as `x-amzn-remapped-www-authenticate` through the CloudFront→API-Gateway stack — the widget reads both) · rate limit `429 { "error_code":"rate_limit_exceeded", "retry_after_s" }`. --- ## Escalation When the AI can't resolve an issue (or the user asks for a human), the session **escalates**: Ognate notifies your reps and relays the conversation. You receive escalations via **email** and/or an **outbound webhook**, and reply back either in the built-in rep portal (`/rep/index.html`) or by POSTing to `/integrations/messages`. ### Configure (admin portal) `tenant.chat_suspended` (bool, **General tab** "Suspend chat") — master kill switch. When true the widget goes dark: `/widget/config` and `/session/init` both return `chat_enabled:false` — the first on page load, so a headless client can disable its composer before the visitor types — and `/chat` + `/escalate` return `403 chat_suspended`. Default false. `tenant.escalation_config` (**Escalation tab**): - `auto_escalate` (bool) — let the AI escalate on detected intent. Forced on at the handoff rungs below (every session is handed off anyway). - `passthrough_level` — `l1_human | l2_assisted | l3_review | on` (default `on`). The onboarding ladder: at L1–L3 **the AI never answers** — every session goes to your reps from the first message; `on` = AI live. Full semantics in [Passthrough ladder](#passthrough-ladder-l1--l2--l3--on); wire details in [AI assistance](#ai-assistance-l2l3). - `rep_emails[]` — `{address, enabled}`, max 10. SES emails every enabled address at `on`; **suppressed at L1–L3** (see the ladder section). - `webhooks[]` — `{url, enabled, secret_arn}`, **cap 1** in v1. HTTPS only. A 32-byte HMAC secret is minted on first save and shown once. - `winrate_eval_enabled` (bool, default false) — enables the offline L2/L3 evaluations (win-rate, escalation calibration, knowledge gaps) behind the Quality page's "Run Evaluation"; they cost judge-model calls, so opt in per tenant. Set via `PATCH /admin/tenants/{id}` (no Escalation-tab toggle). To pause the whole widget, use `chat_suspended`. For "AI on, escalation off", configure no enabled rep targets — with no enabled rep email **and** no enabled webhook, `/escalate` returns `409 escalation_not_configured`. ### Passthrough ladder (L1 → L2 → L3 → on) `passthrough_level` is an **onboarding progression**, not just a routing switch. A new tenant usually starts with humans answering everything while the knowledge base is still thin, lets Ognate measure how often the AI would have matched the reps, lets reps review AI drafts, and only then turns the AI live. Each rung is a tenant-wide setting (all sessions), changed on the admin Escalation tab or by `PATCH /admin/tenants/{id}` `{"escalation_config": {"passthrough_level": "..."}}`. | Rung | Who answers | What it is for | What your integration sees | |---|---|---|---| | `l1_human` | Your reps only | Build the corpus: every customer turn + rep reply is recorded as training data | Plain thread: `escalation.created` on the first message, then `message.created` per turn | | `l2_assisted` | Your reps only | Measure readiness: reps answer with the KB passages the AI would have used; Ognate later regenerates the AI's answer offline and scores it against the rep's (win-rate, admin Quality page) | As L1 + `metadata.kb_context` on each customer `message.created` | | `l3_review` | Your reps, with an AI draft | Rehearse autonomy: the AI drafts, a human sends; edits/adoption are scored and the AI's own answer-vs-escalate decision is calibrated against what your reps did | As L2 + `POST /integrations/draft` on demand | | `on` | The AI, live | Production | Only genuine escalations (visitor asked, or the AI detected intent with `auto_escalate`) reach you | **Handoff semantics at L1–L3 (identical across the three rungs):** - The AI does not reply at all. The visitor's **first** message escalates the session (`escalation_reason = passthrough_`); a RESOLVED session that gets a new customer message **reopens** (`session.reopened` webhook) instead of the AI answering. - `/session/init` returns `auto_escalate: true` and the widget shows its escalation flow immediately; headless clients should expect the escalated state from turn 1. - **Rep emails are suppressed** — reps are expected to work the rep portal (`/rep/index.html`) or your relay. `escalation.created` / `session.reopened` / `message.created` webhooks still fire, so a webhook relay behaves the same at every rung. (At `on`, enabled rep emails are sent as usual.) - Having **no** enabled rep target is not an error at L1–L3 — sessions still appear in the admin Sessions tab and the rep portal (at `on` the same config yields `409 escalation_not_configured`). - The built-in rep portal shows the same KB-context card (L2/L3) and an editable AI-draft card with "Insert into reply" (L3) — mirror those if you build your own relay UI. **Data captured at L1–L3:** customer turns, the retrieved KB passages (frozen at answer time), the rep's reply, and at L3 whether the rep sent the draft as-is / edited / composed fresh (only when a rep was actually shown the draft — portal or `POST /integrations/draft`). Tenant admins can download it ("Export corpus", Escalation tab). Sessions at `on` are scored by the separate RAG-quality evaluation instead. **What Ognate learns from it (admin Quality page, all offline, admin-triggered via "Run Evaluation", gated on the tenant's `winrate_eval_enabled` flag — nothing runs on the request path):** - **Win-rate** (L2/L3): the AI's answer is regenerated from the frozen KB passages and judged against the rep's reply. - **Escalation calibration** (L3): the AI's draft either *answered* or *self-escalated*; what the rep then did (kept/edited the answer, composed fresh, flagged the KB as missing) labels each turn — the page shows a *missed-escalation rate* (AI answered, rep threw it away) and a *needless-escalation rate* (AI bailed, the KB could answer; an LLM judge decides this cell). This measures the prompt-based "escalate if necessary" behaviour before the AI goes live; there is no separate escalate model. - **Knowledge gaps** (L2/L3): every human-resolved turn is auto-judged for facts the rep used that the retrieved KB passages did not contain; found gaps land in a triage list (open → resolved) alongside gaps reps flagged by hand. Fix them by adding sources in the admin portal, then move up the ladder. You get all three for free from a webhook relay — the signal is your reps' replies via `/integrations/messages` (adoption needs a prior `/integrations/draft` fetch). To also flag gaps by hand from your relay, call `POST /integrations/feedback` (`kb_missing` + note, or a KB-relevance mark) — same shape the built-in rep portal uses; see [Feedback](#feedback-you--ognate-flag-a-turn). **Moving up:** promotion is a human decision — the admin Quality page shows the offline win-rate with an advisory "eligible" badge (win-rate ≥ 0.80 over ≥ 100 scored turns), the L3 calibration rates and open knowledge gaps next to it, and a "Promote → next rung" button (`POST /admin/tenants/{id}/promote-passthrough`); nothing promotes automatically, and you can also set any rung directly. Going down (e.g. `on` → `l3_review`) is the same PATCH. Sessions already in flight keep their state; new turns follow the new rung. ### Outbound (Ognate → you): receive escalation events `POST ` — 5s timeout, **no retries in v1** (lossy; the rep portal at `/rep/index.html` is the fallback). Headers: - `Content-Type: application/json` - `X-Ognate-Event` — `escalation.created` | `message.created` | `session.reopened` - `X-Ognate-Timestamp` — unix seconds (string) - `X-Ognate-Signature` — `hex(HMAC_SHA256(secret, f"{timestamp}.{rawbody}"))` - `X-Ognate-Delivery-Id` — hex UUID (idempotency key) Body is serialized compact (`separators=(",",":")`) — **verify HMAC against the raw received bytes; re-serializing breaks the signature.** `escalation.created` payload: ```json { "event": "escalation.created", "tenant_id": "acme", "session_id": "sess_abc", "message_id": "msg_123", "role": "user", "text": "...", "timestamp": "2026-04-30T12:00:00Z", "metadata": { "session_url": "https:///admin/index.html#sessions//sess_abc", "escalation_reason": "passthrough_l2_assisted", "message_count": 12, "history_truncated": false }, "messages": [ {"role":"user","text":"...","timestamp":"...","message_id":"..."} ] } ``` `messages` = up to the last 50 messages from the last 24h, oldest first. If `history_truncated` is true, open the full thread via `metadata.session_url` (admin portal transcript — requires an admin login, not a rep link). `escalation_reason` is `passthrough_l1_human | passthrough_l2_assisted | passthrough_l3_review` when the session was handed off by a passthrough rung, and **absent** for a genuine escalation (visitor asked, or the AI detected intent). Both fields also ride `session.reopened`. `message.created` payload (every user/AI message after escalation; rep replies you post back are **not** echoed): ```json { "event": "message.created", "tenant_id": "acme", "session_id": "sess_abc", "message_id": "msg_124", "role": "user|assistant", "text": "...", "timestamp": "2026-04-30T12:01:00Z", "metadata": {} } ``` Receiver responsibilities: verify the signature against raw bytes with a constant-time compare; reject when `abs(now - timestamp) > 300`s; treat a repeated `X-Ognate-Delivery-Id` as an idempotent no-op; respond 2xx within 5s. ```python # Flask receiver import hmac, hashlib, time @app.post("/ognate-webhook") def hook(): raw = request.get_data() # raw bytes — do not re-parse for HMAC ts = request.headers["X-Ognate-Timestamp"] sig = request.headers["X-Ognate-Signature"] signed = ts.encode() + b"." + raw # sign the RAW bytes, no decode round-trip expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, sig): return "", 401 if abs(time.time() - int(ts)) > 300: return "", 401 evt = request.get_json() # ... idempotent on X-Ognate-Delivery-Id, route to your support tool ... return "", 200 ``` ### Inbound (you → Ognate): reply into a session `POST https:///chatapiv1/integrations/messages`. Use this to push a rep's reply (from Slack/Zendesk/your tool) back into the escalated session. Headers: - `Content-Type: application/json` - `X-Ognate-Tenant-Id` - `X-Ognate-Timestamp` — unix seconds - `X-Ognate-Signature` — `hex(HMAC_SHA256(secret, f"{timestamp}.{rawbody}"))` (same secret as outbound) Body: ```json { "session_id": "sess_abc", "text": "Sure, let me check.", "rep_id": "U07XYZ123", "rep_name": "Alice", "source_label": "slack", "client_message_id": "slack_ts_1714492800.123" } ``` `session_id` + `text` required (text ≤ 4000); the rest optional. `client_message_id` is the idempotency key (re-POST returns the original `message_id`). The session must be `ESCALATED`/`REOPENED`. Responses: `200 {"message_id"}` · `200 {"status":"duplicate","message_id"}` · `400` (bad JSON / missing / too long) · `401` (headers/signature/skew/unknown tenant) · `404` (session not found) · `409` (session not escalated). 5-min skew window. ### Feedback (you → Ognate): flag a turn `POST https:///chatapiv1/integrations/feedback` — same headers/HMAC as `/integrations/messages`. Lets your rep flag a turn independently of replying — passthrough L2/L3 only (feedback is meaningless at `off`/`l1_human`, where there's no AI draft or KB context to react to; `on` has no per-turn training row to attach it to). Body: ```json { "session_id": "sess_abc", "message_id": "msg_124", "feedback": { "kb_missing": true, "kb_missing_note": "no doc covers plan downgrades" } } ``` `session_id` + `message_id` required; `message_id` is the customer `message.created` id the feedback is about — the same id `/integrations/draft` returns, or the `message.created` id you showed the rep. `feedback` (object, same shape as the built-in rep portal): `kb_missing` (bool, "the answer was not in the KB"; feeds the Knowledge Gaps triage directly), `kb_missing_note` (≤ 500 chars, kept only with `kb_missing`), `kb_marks` (`{"": "relevant"|"irrelevant"}`, ≤ 50 entries), `draft_used` (bool), `rationale` (≤ 500 chars). `adoption` is server-derived and not settable. At least one field must be truthy. Because feedback is addressed to `message_id` rather than riding along with a reply, there's no compose/send race to worry about: it lands on the turn you name even if the customer wrote again while your rep was composing. A repeat POST for the same turn **replaces** the client-truth fields as a set — any field you omit is cleared, not merged — while the server-derived `adoption` field is never touched by this endpoint. Feedback also works after the session is resolved (no session-status requirement), unlike `/integrations/messages`. Responses: `200 {"record_id"}` · `400 invalid_feedback_body` (malformed shape, or no truthy field) · `401` (headers/signature/skew/unknown tenant) · `404 session_not_found` · `404 feedback_target_not_found` (no training row for that turn — e.g. rung `on`, or an unknown `message_id`). ### AI assistance (L2/L3) With `passthrough_level = l2_assisted` or `l3_review`, each customer `message.created` carries `metadata.kb_context` — the knowledge-base passages the AI would have used: `[{ "source_uri", "label", "text", "score" }]` (omitted when nothing was found; `label` is a short display form of `source_uri` — host+path for pages, "file · part" for uploads). On `l3_review` you can also fetch an AI draft for the latest pending customer turn: `POST https:///chatapiv1/integrations/draft` — same headers/HMAC as `/integrations/messages`, body `{ "session_id": "sess_abc" }`. Responses (all 200): `{"draft": {"text", "model_id"}, "message_id", "reason": null}` · `{"draft": null, "message_id": null, "reason": "not_ready"}` (retry in a few seconds) · `{"draft": null, "message_id": null, "reason": "no_pending_l3_turn"}` (not L3, or no pending turn — wait for the next customer message). Errors: `400 invalid_draft_body` (missing `session_id`); `401` / `404` / `409` as for `/integrations/messages`. The draft is generated once and cached; `message_id` matches the `message.created` it answers. Show it to your rep, then send the final text via `/integrations/messages`; replies sent after a fetch are scored for adoption (sent as-is / edited / composed fresh) on the admin Quality page. The same reply also feeds escalation calibration and knowledge-gap detection (see the ladder section) — no extra fields, no extra calls. > Signature format note: the escalation webhook (both directions) uses **separate** > `X-Ognate-Timestamp` + `X-Ognate-Signature` (hex digest). The customer-data tool > below uses a **combined** `X-Ognate-Signature: t=,v1=` header. Don't mix them. --- ## Customer-data tool (`query_user_data`) Lets the assistant fetch live per-user data (balance, order status, tier, ...) from **your** endpoint mid-conversation, when the question needs it. Only fires for sessions with a resolvable identity (anon or identified). Configured per tenant (admin), not via the widget. ### Configure (tenant fields) - `tool_enabled: true` - `tool_endpoint_url`: your HTTPS endpoint. - `tool_field_schema`: `{ "": "", ... }` — each field becomes a selectable option; the model picks fields by description. - Outbound auth (stored as an SSM SecureString): `{"mode":"hmac","secret":"..."}` (default) or `{"mode":"bearer","key":"..."}`. ### Request Ognate makes to your endpoint `POST ` — HTTPS only, IP-pinned (DNS-rebind defense), 3s per attempt, 1 retry on 5xx/transport, no redirects. Headers: - `Content-Type: application/json` - `X-Ognate-Tenant-Id: ` - `X-Ognate-Request-Id: ` - HMAC mode: `X-Ognate-Signature: t=,v1=` where `v1 = HMAC_SHA256(secret, f"{ts}.{rawbody}")` (combined Stripe-style header). - Bearer mode: `Authorization: Bearer ` (no signature header). Body: ```json { "user_id": "acct_12345", "fields": ["balance", "tier"], "request_id": "...", "ts": 1714492800 } ``` `user_id` is your bare id for identified users (`uid:` prefix stripped); anonymous ids pass through as `anon:`. `fields` = the subset the model asked for. ### Response your endpoint must return `200` JSON object mapping requested fields → **string** values: ```json { "balance": "$240.18", "tier": "gold" } ``` Constraints (atomic — any violation fails the whole call, nothing cached): keys ⊆ requested fields; values are strings; per-field ≤ 8192 bytes; total ≤ 65536 bytes. Non-JSON / 4xx / 3xx / oversize → tool failure. A per-tenant circuit breaker opens for 60s (doubling) after 3 consecutive failures. On failure the assistant is instructed not to fabricate and to offer human escalation. Values are wrapped and treated as opaque data (prompt-injection containment) — they are never executed as instructions. Surfaced contract constants: `per_attempt_timeout_s=3`, `max_attempts=2`, `breaker_trip_failures=3`, `breaker_open_base_s=60`, `cache_ttl_s=300`, `max_field_bytes=8192`, `max_response_bytes=65536`. ### Troubleshooting - **Tool call fails and your endpoint returns `403` with an HTML body containing `Blocked hosts:` / `Action Controller: Exception caught`** — your framework's host allowlist is rejecting the request `Host` header in middleware, *before* your auth or route runs (Rails `ActionDispatch::HostAuthorization` / `config.hosts`). Common when the endpoint is exposed through a tunnel (ngrok, Cloudflared, …) for testing: the public tunnel hostname isn't allowlisted. Tell-tale — a signed and an unsigned request get the **identical** page (your signature/bearer check never executes). Fix: allowlist the host (`config.hosts << ""`, or a `/\.ngrok\.app$/` regex), or run the tunnel with host-header rewrite (`ngrok http --host-header=rewrite`) so the app sees `localhost`. (Django's `ALLOWED_HOSTS` equivalent returns `400`, not `403`.) - **Repeated failures then a quiet stretch** — the per-tenant circuit breaker opened (3 consecutive failures → 60s, doubling). Calls short-circuit to failure until it closes; fix the endpoint and it re-converges automatically. --- ## Tenant settings Configured in the admin portal; the integration-relevant ones: - **Widget** (`widget_config`): `title`, `subtitle`, `welcome_text` (≤100 chars each), `lang` (`en`/`he`), `accent_color` (default `#2563eb`). These drive branding — there are no widget-branding script attributes. Also `relink_anon` (on login, merge a verified, widget-presented prior anon token into the user; default on). - **General**: `name`, `tenant_id` (read-only), `channel` (read-only — your `data-channel`), `retention_days` (empty = global default), plus the model overrides `model_id`, `rerank_model_id`, `custom_instructions` (appended to the system prompt) and recency settings `recency_enabled`, `recency_alpha` (default 0.7), `recency_half_life_days` (default 60). - **Trusted Domains** (`trusted_domains[]`): hostnames the tenant may crawl/fetch for the KB, each verified via a **DNS TXT** challenge (`_ognate-verify.` = ``) before any fetch. This is an SSRF/abuse/legal control, **not** a CORS allowlist. **CORS**: the chat API allows all origins by design (the widget is embeddable everywhere) — `allow_origins:*`, methods `GET/POST/OPTIONS`. The only CORS you configure is on your own token-refresh URL (if cross-origin). `/embed/v1/token` is the exception: it rejects browser origins entirely (server-to-server only). ## Theming & i18n - **Theme**: `data-theme` (`light`/`dark`/`auto`) is applied as a `data-theme` attribute on the widget root only — it never changes the host page. Built on the Ognate design system tokens (`public/design-system/`). - **i18n**: locales bundled = `en`, `he`. Selection order: `data-lang` → `` → `navigator.language` → `en`, with per-string English fallback. RTL auto-applied for `he`/`ar`/`fa`/etc. The `` is observed live, so changing page language re-loads the widget strings. --- ## Recipes **1. Anonymous bubble on a marketing site** — one tag (see [Embed: bubble UI](#embed-bubble-ui)). Done. **2. Headless chat in a React component** — assumes the widget `