Skip to main content
The vocabulary the rest of the docs assume. Read once; refer back as needed.

Widget key (pk_live_…)

A public credential that identifies a widget configuration. Issued by the tenant admin via comerix:widget-key:create. Carries:
  • A reference to the tenant whose flows can be triggered.
  • An intent permission policy — either an explicit allowedIntents list or the allowsAllIntents flag. Exactly one is set; an empty list does not implicitly mean “any” (that mode is opt-in via allowsAllIntents).
  • An allowedOrigins list — which sites may embed it (exact match https://acme.com or wildcard https://*.acme.com).
  • An enabled flag — the admin can disable a key without rotating it.
The key is not a secret — it’s safe to embed in client JS. Its protection is allowedOrigins + browser CORS. Compromise = an attacker on an unrelated origin still can’t talk to your flow because their Origin won’t match.

Intent

The handle a published flow registers under. A flow whose trigger is trigger.chat with intent_name: order_status becomes the order_status intent for its tenant. The flow itself can internally branch into sub-scenarios (route / condition nodes), so one intent often covers many user goals. One widget key can grant access to many intents — each /messages trigger call specifies which one to run via intentName. The session response returns the precomputed effective list (key allow-list ∩ tenant’s published intents) so the widget can render a menu or feed the names into a client-side intent classifier without an extra round-trip.

Quick questions

Up to six one-tap prompts the widget surfaces when a visitor opens the chat — a low-friction way to launch a flow without typing. They’re authored at tenant scope under Settings → Widget → Quick questions (a rows config field). Each row binds:
ColumnMeaning
questionThe prompt text shown on the chip.
pageTypeWhere it should appear: general, category, product, or cart.
assignedFlowThe flow that handles the prompt when tapped.
The session response returns them on quickQuestions, each resolved to the intentName the widget should post to /messages to start it. A prompt whose flow is unset, unpublished, or not permitted by the widget key comes back with intentName: null so the client can still render it (disabled) rather than silently drop it. Page filtering is left to the client — only the widget knows which page the visitor is on.

Conversation

A persistent thread of messages between one customer and the flow. Created the first time a widget opens a session for a customerId (or anonymously if omitted). Carries channel, customer_id, customer_locale, and a variables jsonb bag. Conversations outlive sessions — the same conversationId can be reused across browser tabs and visits if your widget threads customerId through. Internal admin tools read history per conversation via /api/v1/conversations.

Conversation variables

A client-supplied key/value bag the widget can attach to a conversation. Send an optional top-level variables object on /sessions (where it seeds the conversation) and on every /messages call (trigger or resume — the values merge into the conversation). They are persisted on the conversation and readable from inside flows, so a flow author can branch on context the page already knows (cart total, plan tier, the SKU the visitor is viewing). Because the values come from an unauthenticated public endpoint and surface to operators, the server validates them before persisting:
ConstraintLimit
ShapeMust be a JSON object (not a list).
Key countAt most 50 keys.
Key formatsnake_case, 1–64 chars, [a-z0-9_], must start with a letter.
Value typeScalar, null, or a nested array. Objects and resources are rejected.
Nesting depthArrays nested at most 4 levels deep.
Total sizeThe serialized map must be ≤ 4096 bytes.
A violation returns 422 validation_failed with the offending key in details. Treat the values as untrusted input in your flows — they are never authenticated.

Session token

The HMAC-signed bearer the widget uses on every subsequent call. Payload: {tenantId, conversationId, widgetKeyId, iat, exp}. Default lifetime 1 hour. Signed with WIDGET_TOKEN_SECRET (a 32-byte hex held server-side). Short-lived on purpose: revoking a key means new sessions can’t be opened; in-flight sessions expire within the hour. There’s no logout endpoint — sessions self-expire. When /messages or /events returns 401 invalid_session_token, the widget silently re-opens a session (calls /sessions again) and retries the original request once. Don’t surface that to the user.

Execution

One concrete run of a flow. Each call to /messages with text + intentName creates a new execution (the trigger path). A subsequent call with {waitToken, executionId, values} advances the same execution past its current waiting_input node (the resume path). The widget gets executionId in every Reply and is expected to pass it back when resuming. Don’t try to reverse-engineer the engine’s lifecycle — just track executionId + waitToken from the latest reply and pass them back when the user submits the next thing. When a flow dispatches async work (a node enqueues a job via Symfony Messenger and returns), the execution paused in status waiting_time and the widget can no longer make forward progress with the user’s input — it has to wait for the worker. The poll endpoint exists for exactly this case (see below).

Block

The unit of UI the flow emits. Each Reply carries a blocks array; the widget renders them in order. Common types:
typeWhat it represents
messageAn agent message — plain text or markdown (payload.format).
formA structured input the user fills in. Triggers status: waiting_input.
choiceOne-of-many selection (buttons or radio). Also triggers waiting_input.
linkA clickable URL (CTA or reference).
imageAn image attachment.
cardA composite (image + text + actions).
Unknown type values must render as a no-op or a debug stub. See Blocks for the payload structure of each type.

Wait token

A single-use opaque string the engine emits when a flow pauses on user input. The widget echoes it back on the next /messages call to prove the resume belongs to that pause. Used + a hash_equals check on the server — replay is not possible. Expires per waitExpiresAt. If a user lets a form sit beyond waitExpiresAt and then submits, the backend returns 409 invalid_wait_token. The widget then either starts a new turn (drop the wait info, send a fresh text) or shows a “session expired, please retry” hint.

Status

The single field that tells the widget what to do next:
statusMeaningWidget action
completedFlow finished.Render terminal blocks, no follow-up call.
waiting_inputFlow paused on a form/choice.Collect input → resume with waitToken + executionId + values.
waiting_timeFlow paused on a timer (server resumes itself).Show “thinking…” / poll later. Rare.
failedFlow errored out.Render any user-facing message in blocks, else show generic error.
abortedCancelled by admin or timeout.Start a fresh turn if the user wants to retry.
Treat completed | failed | aborted as terminal — no more calls for that execution. A new text turn starts a new execution.

Polling (for async waits)

When a flow node hands off to a queue worker, the reply comes back with status: waiting_time. The execution will only progress when the worker finishes and notifies the engine. The widget bridges the gap by polling GET /chat/executions/{executionId} every 1–2 s. Each poll returns a Reply snapshot — the current status + any blocks the worker has emitted. Polling is non-destructive: blocks stay in the snapshot across reads, so the widget must dedupe by block.id. Stop polling as soon as status is completed, failed, aborted, or waiting_input (the flow now wants user action again). A session can only poll or resume executions from its own conversation — the executionId must be one the same session triggered. Any other id (another conversation, or one that doesn’t exist) returns 404 execution_not_found, so a leaked executionId/waitToken pair is useless from an unrelated session. This is also useful if the widget loses connection mid-conversation — reconnect, fetch the session’s most recent executionId from your own state, and resume from a poll.

Goals, conversions & telemetry

Beyond running flows, the platform measures which chats lead to a sale.
  • Telemetry event — a client-side signal the widget reports via POST /events (or /events/batch): lifecycle (widget_open, conversation_started), messaging (message_sent, link_click), and commerce (add_to_cart, purchase). Each event is stored against the conversation, feeds the analytics dashboards, and is replayed inline in the operator’s conversation history transcript — folded between the chat messages in chronological order.
  • Goal — a tenant-defined conversion or engagement target: a trigger event name, an optional match_filter (props that must match), and a value model (none / fixed / read from a prop like value). The currency can be fixed or read from a prop too, so a purchase event should carry both the value and the currency — reported in the workspace’s single base currency, the way e-commerce platforms compute a base order total — letting one goal aggregate a multi-currency store. Goals are authored under Insights → Goals.
  • Conversion — recorded when a telemetry event matches a goal. The platform attributes it to the conversation, its latest execution/flow, and the customer (customerId), so dashboards can show conversion rate, revenue, and which flows convert.
  • Deferred conversion — a sale that completes after the chat ends. Your backend reports it server-side via the Engine API POST /api/v1/insights/conversions; the platform attributes it back to the conversation directly (by conversation_id) or to the customer’s most recent conversation within the goal’s attribution window.
A telemetry event carries no special auth beyond the session token — keep raw PII out of props (the server redacts common patterns as a safety net, and the goal value is read from a numeric prop).

Token usage & cost

Every chat reply can carry a per-turn token accounting of the LLM work behind it, exposed on the Reply.tokenUsage field (and mirrored on the chat.execution.updated webhook as token_usage). It’s null when the turn consumed no tokens. Fields:
FieldMeaning
prompt_tokensTokens billed for the prompt across the turn.
completion_tokensTokens billed for the completion across the turn.
total_tokensSum of prompt and completion tokens.
modelThe model id when the whole turn used a single model; null otherwise.
cost_microsEstimated cost in micro-USD (1e-6 USD).
cost_usdThe same cost expressed in whole US dollars.
Cost is carried in micro-USD (cost_micros) so per-turn amounts can be summed across thousands of executions without floating-point drift; cost_usd is the convenience conversion (cost_micros ÷ 1,000,000).
These counts roll up into the Insights Token spend dashboard widget and the ins_token_usage_daily analytics view; estimated cost is derived from the per-model pricing configured under Insights.

Rate limits

The public chat surface is capped per-IP, not per-key (per-key throttling lands later). The shared budget across /sessions, /messages, GET /executions/{id}, and /events is 60 requests / minute on a sliding window — generous for one chat session, tight against runaway retries. When you blow the budget the response is 429 rate_limited with a Retry-After header carrying the integer seconds the client should wait. Drive your backoff from that header (the reverse proxy in front of the service may tighten the cap further, and the header reflects whichever limit fired). Webhook deliveries to your backend are a separate channel and aren’t bounded by this cap. See Errors → Rate-limit responses for the exact body shape and retry guidance.

How they fit together