Krispy AI
reference

edge routes / API

every HTTP route on the edge Worker โ€” method, path, shape, auth.

edge routes

one Cloudflare Worker hosts all of these plus the SessionDO. CORS allow-origin is ALLOWED_ORIGIN (default *); OPTIONS is answered for every route.

public routes

| method | path | body / query โ†’ response | | ------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- | | POST | /api/chat | { sessionId, message, tenantId?, history? } โ†’ { reply, handoff, handoffState, handedOff, degraded?, formId?, form? } | | POST | /api/call | Visitor call control with registered visitorSecret: status, invite, accept, decline, cancel, end, grant. Status includes availableToRequest; invite returns a private nonce. | | POST | /api/call/presence | { tenantId, sessionId, siteId?, visitorSecret } โ†’ { ok: true }. Registers the loaded page for operator invitations before chat opens. A conflicting capability or site returns 403. | | POST | /api/contact | { sessionId, tenantId?, name?, contact?, message? } โ†’ { ok: true } โ€” legacy contact-capture shim (maps onto the lead fan-out) | | POST | /api/lead | Configured forms send { tenantId?, sessionId, siteId?, formId, values, history, submissionId, visitorSecret }. A matching visitor capability records labeled fields in the session before { ok: true, recorded: true, emailStatus: "sent" | "none" | "delayed" }; email delay does not discard the operator record. Older clients without both new fields retain legacy email delivery. | | GET | /api/session/:id/ws?t=<tenant> | WebSocket upgrade โ†’ SessionDO. 426 if not an upgrade. the visitor's live channel | | GET | /api/widget/config?t=<tenant> | โ†’ { capabilities: { attachments, media, maxImageBytes, maxVideoBytes }, theme, ctas, forms, script, popups } โ€” the public, secret-free widget projection (whitelist only; no persona or form delivery routing) | | GET | /api/usage?t=<tenant> | โ†’ { tenantId, usage: { ai, handoff, tokens }, plan, entitled, status, limits, withinLimits } | | GET | /health | โ†’ { status: "ok", service: "edge" } |

Optional visitor name

When TenantConfig.visitorIdentity.enabled is true, the widget asks for a name after the configured number of visitor messages (1โ€“5). The prompt is optional, may be skipped, and has separate English/Hebrew wording. POST /api/visitor/identity accepts { tenantId, siteId?, sessionId, visitorSecret, name }; the Durable Object checks the session-bound visitor capability before saving a trimmed 1โ€“80 character name. Neither a session ID alone nor a different site's capability can rename a conversation. Operator handoffs and thread reads return visitorName and an ISO alpha-2 countryCode when Cloudflare supplied one. Country is edge-derived, never accepted from the visitor. Unknown countries have no flag.

Private image and video attachments

GET /api/media/capabilities reports whether this Worker has a private R2 bucket and the 10 MiB image / 30 MiB video limits. Hosted Cloud widgets use POST /api/media/visitor with multipart tenantId, sessionId, the session-bound visitorSecret, a retry-stable UUID uploadId, optional siteId and caption, and file. Buttr operators use POST /api/operator/media with their bearer (or the server-only tenant sync secret) and the same tenant/session/id/caption/file fields. The operator route requires an existing conversation. Both routes verify the file signature rather than trusting its MIME label, persist one transcript message per upload ID, and broadcast it to the other side. Visitor media enters the human inquiry path. R2 is optional for self-hosts; without it these routes return 503.

GET or HEAD /api/media/:mediaId?t=<tenant>&s=<session> streams private bytes or metadata. The visitor sends x-visitor-secret; the operator sends a bearer or the server-only tenant sync secret. Access is checked against that conversation before reading storage. GET accepts a single byte Range for videos and sends 206. These are authenticated reads, not public R2 links. Hosted Krispy attachment objects under media/ expire after 180 days; an expired file yields 404. Incomplete multipart uploads are aborted after seven days by the bucket's default rule. In the guest chat, tapping a loaded image opens a full-screen viewer. Save image uses the already authenticated local blob: iOS shows its share sheet (including Save Image), and other browsers download the file. An expired image cannot be reopened after reload.

POST /api/chat gate

before answering, the Worker checks entitlement + plan limits. self-host ("self") is always entitled and unmetered. a non-self tenant with no valid snapshot gets 402 { error: "subscription_required" }; over its cap it gets 429 { error: "usage_limit_reached" }. missing sessionId/message โ†’ 400.

handoffState is explicit: ai answers; pending means a human was notified but has not replied; operator starts with the first human reply. Pending and operator-owned visitor messages still reach the topic and server ring, while the AI stays silent. handedOff remains as a compatibility boolean and is true only for operator.

Audio calls

Audio calls require RTC configuration and tenant callSettings.enabled. Operator invites require a connected visitor; visitor requests require visitorRequestsEnabled and, by default, a session already in pending or operator handoff state. Set visitorRequestTrigger: "always" to allow a visitor request before handoff. A visitor request sends one push to registered operator devices unless notifyOnVisitorRequest is false; push delivery runs after the invite response, and repeating the same ringing request does not send another. availableToRequest in visitor status says only that a request is allowed, not that an operator is online.

The widget calls /api/call/presence when it loads, including while chat is closed. The route checks tenant entitlement and binds the first private capability to the tenant, session, and site. It returns 400 for invalid input, 402 for an unentitled tenant, or 403 for a conflicting registration or site. A hidden tab keeps its call socket, but browsers may suspend background pages; the widget refreshes the socket when the page becomes visible again.

Both /api/call and /api/operator/call use POST with { tenantId, sessionId, action, id?, nonce? }; the visitor also sends its registered visitorSecret, while the operator uses its bearer or tenant sync secret. call.requestedBy identifies the requester. The recipient can accept or decline; the requester can cancel; either can end once accepted. The visitor nonce never appears in the operator response. grant returns room credentials only after acceptance. Accepting sets the session to operator handoff so the AI stays silent during the call. A new visitor request is limited to one per minute. The embedded visitor widget starts connecting on acceptance. Coordinator-owned visitor responses, including grant, carry the configured CORS headers so a cross-origin course page can read the token. A failed connection offers a retry; diagnostics show only the stage and bounded HTTP status, never the token.

The staged runtime 0.5 coordinator records a typed call receipt only when a call reaches a terminal state. /api/operator/thread returns callReceipts separately from its 20-message ring; the guest and operator sockets receive { type: "call_receipt", receipt } live and on reconnect. Each receipt includes server timestamps, duration measured from verified two-party media connection, an ended/missed/declined/canceled outcome, and whether the connection and end times came from signed events or later room observations. It contains no recording or conversation summary. Runtime 0.4 call routes do not create these receipts.

For the exact CALL_PILOT_TENANT_ID with NATIVE_CALLS_ENABLED=1, new guest and native calls share a tenant Durable Object. Existing 0.4 calls remain under their SessionDO owner, including after a flag change. Cloud resolves operator and installation identity before calling the internal coordinator routes; the guest still uses its registered visitor capability and call nonce. New invitations require site callSettings.enabled and a connected visitor. Turning the pilot flag off blocks new invitations and acceptance while allowing owned calls to finish, report status, issue accepted-room grants, and clean up.

LiveKit sends POST /api/livekit/webhook with the raw event body and signed Authorization token. The Worker verifies the HMAC signature and body hash, uses the room ID to locate a call in the configured pilot tenant, then confirms that the persisted owner is the coordinator. Legacy 0.4 rooms are ignored by this route. Two signed participant joins require an authenticated room query proving both peers active simultaneously before connection time is recorded. A signed room_finished ends the call and advances the device signal revision. Duplicate or older events cannot reopen a terminal call. Unverified late joins retain an approximate duration rather than claiming exact simultaneous media.

Cloud-only POST /api/internal/call-coordinator/{availability,start,action,status,grant,revoke-device,device-cleanup-status,offer-validity} requires x-tenant-sync-secret. Cloud supplies server-resolved tenantId, operatorId, and deviceInstanceId; the Worker never trusts a browser bearer for this surface. availability returns {canInvite,reason}. start and action return the canonical call decision. status is read-only; grant returns the accepted winner's room token. revoke-device returns 202 {cleanupConfirmed:false} until old offers and media are closed, then 200 {cleanupConfirmed:true} for the same rebind:<jobId> action ID. device-cleanup-status is read-only and returns per-device offer and active-call counts plus pending offer-outbox counts, without exposing call or device IDs. offer-validity checks the exact per-device offer, not a global revision.

Operator bearer authorization

The operator app sends its Better Auth bearer in Authorization: Bearer <session token> (or as ?auth=<token> for an operator WebSocket). The Worker verifies the token against the cloud API's GET /me and compares the server-resolved tenantId with the route's tenantId. This permits verified teammates to access the owner's tenant while rejecting another tenant with 403. During rollout, a /me response without tenantId falls back to its nonempty id; malformed identity fields fail closed with 401. Verified tokens are cached per Worker isolate for 60 seconds.

POST /api/operator/actions takes { tenantId, sessionId, siteId? } and returns { forms: [{ id, title }], connectors: [{ id, type: "instagram", label, caption?, url }] }. POST /api/operator/send-action adds kind: "form" | "instagram" and id; it returns { ok: true, delivered, action }. Both use the operator bearer or tenant sync secret. delivered counts live visitor sockets; the operator's own live echo is excluded. POST /api/operator/reply-drafts takes { tenantId, sessionId, siteId? } and returns { drafts: string[], sourceRevision, sourceLastMessageTs, stale? }. It reads the session transcript on the server, uses the tenant's configured knowledge and AI provider for the session's recorded site, and returns up to three editable suggestions. Suggestions request different useful approaches, such as an answer, a relevant clarifying question or a supported next step. If only one is useful, the model is instructed to return one rather than several paraphrases. Operators review them before sending. Links must appear in the tenant's configured sources or validated gateway context; sentence punctuation after a source link is accepted, while altered links are hidden. An optional siteId only asserts that recorded site; a mismatch returns 403. It never sends a reply. When the transcript ends in an AI message, the latest visitor request is restated for drafting. The Delulus pilot requests structured JSON for this route only. The app requests them on demand and discards a result when the transcript changes. Uncertain or failed generation returns an empty array; repeated requests are capped at 20 per session per hour (429; concurrent bursts can exceed the coarse KV counter). Direct Gemini remains limited to the approved pilot tenant and site. The server reads the session's recorded site and resolves the ID from that site's configured forms/connectors. An optional siteId must match. Missing session or ID returns 404, a site mismatch returns 403, and malformed fields return 400. The typed card appears in /api/operator/thread history and the visitor WebSocket (type: "action"); reconnect ready.messages restores it. The form submits through the existing /api/lead path. The inbox row also includes the recorded siteId.

Telegram is optional for the Buttr inbox. Before moving a session to pending, the Worker writes handoff:<tenant>:<session> to KV. With includeActive: true, the operator inbox also lists unresolved AI-only conversations indexed under conversation:<tenant>:<session>. Topic links keep their separate legacy session: keys, so enabling Telegram later cannot overwrite discovery state. The inbox unions and de-duplicates both prefixes so conversations created before this index remain visible. includeResolved: true includes archived rows. Bot-only conversations archive after 24 hours without a visitor turn by default (AUTO_ARCHIVE_HOURS); an inbox read also archives eligible older bot-only conversations that predate the timer. The next live visitor turn reopens them. Known handoffs, operator messages, and call requests stay available for manual archive even after the AI resumes; a legacy conversation without enough evidence stays active rather than being archived speculatively.

Known business questions stay with the AI. An explicit human request enters pending immediately without requiring a name, email, phone, or order reference. Only a business-configured form can collect details. Screenshot paste/drop is advertised only when capabilities.attachments is true; Cloud installations use the private media route so images and videos appear in Buttr and the website chat. Self-hosts without an R2 binding retain the Telegram photo route.

history is client-supplied context and may include the current visitor message as its final user entry (the browser widget sends it that way). When an empty session is handed off, the Worker seeds only prior history and appends the live visitor turn once. It removes one trailing exact match only; earlier repeated messages remain separate turns.

If the Worker has the optional private knowledge gateway configured for the exact tenant/site, it sends the latest question to that server-only gateway immediately before the model call. The gateway response may contain bounded cited evidence plus one bounded professional-method reference. Evidence is business reference data; guidance is explicitly method reference only, not a business claim or an instruction. Both are placed before the existing system instructions with the existing security, scope, and handoff rules retaining priority. The request is capped at 64 KiB; guidance is capped at one item and 20,000 characters total. The Worker adds trusted current UTC time when composing usable retrieved material so expired deadlines are not manufactured. Missing configuration, operator-owned sessions, timeout, scope mismatch, malformed data, or gateway errors leave the original model messages unchanged. No gateway credential or provider setting is exposed through widget config.

secret-guarded routes

these carry a bot token or mutate config, so they require a shared-secret header. a missing or wrong secret returns 401 (config routes) / 403 (webhook, billing) and leaks nothing.

methodpathauth headerpurpose
POST/api/telegram/webhookx-telegram-bot-api-secret-token: <TELEGRAM_WEBHOOK_SECRET>owner's Telegram reply โ†’ push to the visitor via the DO, move pending โ†’ operator
GET/api/tenant/config?t=<tenant>x-tenant-sync-secret: <TENANT_SYNC_SECRET>read a tenant's full config { botToken, chatId, systemPrompt?, model?, ... } (404 if none)
POST/api/tenant/configx-tenant-sync-secret: <TENANT_SYNC_SECRET>{ tenantId, config } โ†’ merge into the tenant's KV config. the CLI writes here. out-of-bounds configs are hard-rejected: oversized avatar/kbSources โ†’ 413, bad avatar scheme or non-https CTA url โ†’ 400 (see tenant config โ†’ write caps)
POST/api/billing/entitlementx-billing-sync-secret: <BILLING_SYNC_SECRET>mirror an entitlement snapshot into KV (for a hosted/multi-tenant deploy; unused in self-host)
GET/api/tenant/liveness?t=<tenant>x-tenant-sync-secret: <TENANT_SYNC_SECRET>"is my widget live?" โ€” returns { seen: { at, origin?, url? } | null }, the last time the widget booted on a customer page (stamped, throttled per site and embedding origin, off every GET /api/widget/config) + the embedding origin/url. null until it phones home
GET/api/tenant/kb-suggestions?t=<tenant>&s=<site>x-tenant-sync-secret: <TENANT_SYNC_SECRET>list pending relearning suggestions โ†’ { suggestions: KbSuggestion[] } (machine-proposed Qโ†’A from operator-touched sessions; own KV key suggestions:<ns(t,s)>, never public)
POST/api/tenant/kb-approvex-tenant-sync-secret: <TENANT_SYNC_SECRET>{ tenantId, siteId?, id } โ†’ move the suggestion into kbSources (+ bump kbVersion) and drop it from pending โ†’ { ok: true, source }. 404 if the id isn't pending
POST/api/tenant/kb-dismissx-tenant-sync-secret: <TENANT_SYNC_SECRET>{ tenantId, siteId?, id } โ†’ drop the suggestion, no KB change โ†’ { ok: true }

GET /api/tenant/config returns the full config (bot token included) and is secret-guarded. GET /api/widget/config is the public read the browser uses โ€” it returns only the whitelist (capabilities, theme, ctas, forms, script, popups) and never the token/prompt/persona or form connector IDs. don't confuse them. see security.

Multi-site. An account can run multiple sites, each with its own config/kbase/branding. Pass an optional ?s=<siteId> (query, on config/liveness) or siteId (body, on chat/lead/tenant/config) to scope to a site. Absent โ†’ the account's default site (the bare tenant:<t> key), so existing embeds are unchanged. siteId is [a-z0-9_-]{1,40}; anything else โ†’ 400. Conversations and billing stay account-scoped.

the SessionDO (internal)

the Worker forwards to a SessionDO per (tenant, session); the browser never calls it directly. its surface: the WebSocket upgrade (visitor connects, receives { type: "ready", handoffState, handedOff }), GET /state ({ handoffState, handedOff }), POST /context { tenantId, siteId? } (the chat flow's combined { handoffState, handedOff, messages } read โ€” the body persists the tenant's identity into DO storage write-once so the relearning handback, which fires from an alarm with no request in flight, can build the per-site suggestions: key + meter the AI call), POST /operator {text} (move pending โ†’ operator and broadcast the reply), and POST /handoff (move ai โ†’ pending and broadcast the waiting state). POST /resolve and silence handback move either human state back to ai. server โ†’ browser events: ready, operator, handoff, resume. On reconnect, pending renders a truthful waiting message; only operator says a team member joined.

Relearning. On a real handback (operator had the session, then it resolves or the silence alarm fires) the DO makes one Workers-AI call to extract a Qโ†’A the human answered that the bot couldn't, and appends it (normalized-question dedup, FIFO cap 20) under suggestions:<ns(t,s)> for a human to approve via /api/tenant/kb-approve. Off by default: it runs only for tenants using the knowledge base (โ‰ฅ1 kbSource) โ€” a tenant that never configured a KB incurs zero AI cost on handback. Also gated on โ‰ฅ1 operator message; failures are swallowed (never break resolve). The bot never self-modifies its KB โ€” approval is a human click (a prompt-injection valve).

keeping this in sync

the repo mirrors routes into a Bruno collection (api-collection/). when you add or change a route, update the matching .bru request in the same change (per CONTRIBUTING.md).

Launcher appearance in public config

GET /api/widget/config includes theme.launcherStyle (circle or pill) and theme.launcherLabel when configured. The widget displays up to 24 label characters; unset fields retain the circular launcher. These fields contain public display text, not credentials. Configure them through the authenticated tenant-config endpoint.

Hosted owner notifications

The hosted edge needs PUSH_TOKENS_URL set to its matching cloud API /internal/push/tokens endpoint and PUSH_TOKENS_SECRET set to the shared server credential. The preview deployment config supplies the preview endpoint. Without the URL, chat and inbox persistence work but mobile push is skipped. The first live visitor message claims one Buttr inquiry alert per session in the Durable Object. Later turns stay quiet; a first-turn handoff sends its existing single handoff alert instead of a second push. The claim records an alert attempt, not confirmed delivery. A failed attempt is not retried on later turns, so conversation traffic cannot spam operators. A signed device build, notification permission, registered device token, and valid platform push credentials are also required; simulator chat tests do not prove notification delivery. Self-hosted installations may leave these unset.

On this page