The vocabulary the rest of the docs assume. Read once; refer back as needed.
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:
| Column | Meaning |
|---|
question | The prompt text shown on the chip. |
pageType | Where it should appear: general, category, product, or cart. |
assignedFlow | The 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:
| Constraint | Limit |
|---|
| Shape | Must be a JSON object (not a list). |
| Key count | At most 50 keys. |
| Key format | snake_case, 1–64 chars, [a-z0-9_], must start with a letter. |
| Value type | Scalar, null, or a nested array. Objects and resources are rejected. |
| Nesting depth | Arrays nested at most 4 levels deep. |
| Total size | The 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:
type | What it represents |
|---|
message | An agent message — plain text or markdown (payload.format). |
form | A structured input the user fills in. Triggers status: waiting_input. |
choice | One-of-many selection (buttons or radio). Also triggers waiting_input. |
link | A clickable URL (CTA or reference). |
image | An image attachment. |
card | A 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:
status | Meaning | Widget action |
|---|
completed | Flow finished. | Render terminal blocks, no follow-up call. |
waiting_input | Flow paused on a form/choice. | Collect input → resume with waitToken + executionId + values. |
waiting_time | Flow paused on a timer (server resumes itself). | Show “thinking…” / poll later. Rare. |
failed | Flow errored out. | Render any user-facing message in blocks, else show generic error. |
aborted | Cancelled 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:
| Field | Meaning |
|---|
prompt_tokens | Tokens billed for the prompt across the turn. |
completion_tokens | Tokens billed for the completion across the turn. |
total_tokens | Sum of prompt and completion tokens. |
model | The model id when the whole turn used a single model; null otherwise. |
cost_micros | Estimated cost in micro-USD (1e-6 USD). |
cost_usd | The 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