The setup
Tenant: Acme Inc., an e-commerce store at acme.com. Flow they want to ship:order_status — let the customer type a question, ask for the order number, look it up in an inventory service (async — takes 3–8 s), and reply with shipping ETA.
Widget integrator: Acme’s front-end team. They embed a chat bubble at the bottom-right of every order page. They already use vanilla JS — no React.
Step 0 — Provisioning (admin, one-time)
The Comerix Flow admin (Acme employee) opens the Studio and:-
Builds the
order_statusflow:- Trigger node:
trigger.chatwithintent_name: order_status, description “Look up the status of an order” messagenode: “What’s your order number?”formnode: one fieldorder_number(required text)enqueue.tasknode: dispatches to theinventory.lookupqueue withorder_number→ returnswaiting_timeawait.task_resultnode: waits for the worker’s responsemessagenode: “Order # ships . Tracking: ”- End
- Trigger node:
- Publishes version 1 of the flow.
-
Issues a widget key — bundled with the webhook URL Acme’s backend will receive events at:
pk_live_… value via 1Password / their secrets manager (safe in client JS) and the whsec_… for the backend webhook receiver (treat as a secret).
Step 1 — A visitor lands on a page
Mariya opens acme.com/orders/recent. The page loads, including a tiny launcher script that registers a click handler on the chat bubble. No API call is made yet. Opening sessions on page load wastes them on people who never engage. The launcher also stashes whatever the page knows about Mariya — her plan tier, the value of her cart, the page type — so it can hand that to the session as conversation variables. The flow can branch on these and they’re carried forward across every later message in the conversation.Step 2 — Mariya clicks the chat bubble
The widget calls/sessions. This is the only call where the publicKey appears — every later call carries the issued sessionToken instead.
variables map is where the page passes what it knows — plan_tier, cart_value, and the like. Keys must be snake_case ([a-z0-9_], leading letter, 1–64 chars); the server sanitizes and caps them, then attaches the map to the conversation so the flow can read it.
What the server does:
WidgetCorsSubscriberseesOrigin: https://acme.com, looks up the key, checks againstallowedOrigins. Match.- Controller resolves the tenant from the key, sets the tenant context for RLS.
ConversationStore::ensure()creates anfs_conversationsrow keyed tocustomerId='u-42'.WidgetSessionToken::issue()signs{tid, cid, wk, iat, exp}with HMAC-SHA256.IntentRegistry::listIntents()is filtered by the key’s allow-list, intersected with the tenant’s published flows. Result:[order_status](returns isn’t published yet).AuditLoggerrecordswidget.session.start.
sessionToken in sessionStorage. It uses intents[0].description to seed the chat placeholder: “Ask about your order…”. If the array had more entries, the widget would render a button-row above the input.
The response may also carry quickQuestions — tappable chips the tenant configured (in Settings → Widget → Quick questions) to nudge a visitor into a flow. Each chip carries its question text, the pageType it should surface on (the client filters by the current page), and — when its assigned flow is published and this key may trigger it — the intentName the widget posts to start it. A chip whose flow is unset, unpublished, or not permitted comes back with a null intentName so the client can still render it (disabled) rather than silently drop it.
Step 3 — Mariya types the first message
“Where’s my order from yesterday?”The widget knows there’s exactly one intent, so it doesn’t need a classifier — it uses
order_status. (For multi-intent keys, the widget would either show buttons or run a tiny client-side intent classifier on the text.)
- CORS subscriber validates Origin against the key tied to the token.
- Controller verifies the token, sets tenant context.
- Body has
text(nowaitToken) → trigger path. widgetKey.allowsIntent('order_status')→ true.TriggerChatService::trigger():- Looks up the published
order_statusflow. - Creates a new
fs_executionsrow +ExecutionStartedevent in one DB transaction. - Records the user message in
fs_chat_messages. - Runs the step loop:
messageblock →formblock → pauses onexpectedInput.
- Looks up the published
AuditLoggerrecordswidget.message.send(mode=trigger).- Any LLM call the reply made along the way is metered: the engine sums the prompt/completion tokens for the turn, prices them by the model that produced them, and records that token usage and estimated cost per reply (carried on the reply as
tokenUsage, and rolled up by analytics into the Token spend widget). - Widget gets a
Replywithstatus: waiting_input, blocks to render,waitToken,executionId.
executionId and waitToken in memory.
Step 4 — Mariya types the order number
She enters12345 and clicks Check.
The widget validates {order_number: "12345"} against the schema (the field is a non-empty string — passes). It POSTs a resume body — no text, no intentName:
- Auth + CORS as before.
- Body has
waitToken+executionId→ resume path. ResumeExecutionService::resume():- Loads the execution snapshot.
- Verifies status is
waiting_inputand the token matches viahash_equals(replay-safe). - Validates
valuesagainst the schema. Passes. - Records
{order_number: "12345"}as a user message. - Advances to the next node:
enqueue.task. - The node dispatches an
InventoryLookupMessageto theinventory.lookupSymfony Messenger queue. - The flow transitions to status
waiting_time. State machine storeswaitTokencleared (single-use done), waits for the worker.
- Engine emits a
chat.execution.updatedoutbox event with{status: waiting_time, ...}. - Widget gets a
Replywithstatus: waiting_timeand an emptyblocksarray.
- Webhook → Acme backend → widget (preferred) — the server POSTs
chat.execution.updatedto Acme’s backend, which relays the new state to Mariya’s browser over whatever push channel Acme already uses (WebSocket, Pusher, Server-Sent Events from Acme’s own server). One transport per integrator, owned by them. - Polling (fallback when there’s no integrator backend) — widget calls
GET /chat/executions/{id}every 2–3 s.
Step 5 — The async work completes
Meanwhile theinventory.lookup worker in another process:
- Picks up the queued message:
{execution_id: '01935-...', order_number: '12345'}. - Calls Acme’s internal inventory API (takes 4.7 s).
- Gets back
{ship_date: '2026-05-16', tracking: '1Z…'}. - POSTs the result to
/api/v1/engine/eventswithevent_name: inventory.lookup.completed, data containing the result. - Engine matches the event to the waiting subscription, resumes the execution past
await.task_result, evaluates the finalmessagenode, transitions tocompleted. It enqueues achat.execution.updatedwebhook row.
- Verifies the
X-Comerix-Signatureagainst the savedwhsec_…. - Dedupes by
X-Comerix-Event-Id. - Pushes
{status: completed, blocks: [...]}to Mariya’s browser tab over Acme’s existing WebSocket channel. - Acks with 204.
Step 6 — Optional telemetry
Throughout, the widget fires telemetry:What Acme’s backend received along the way
The samewebhookUrl that delivers the final completed event also received the earlier lifecycle events:
| When | Event | What Acme does with it |
|---|---|---|
| Step 2 | chat.session.opened | Increment “chat opened” metric in their dashboard. |
| Step 3 | chat.execution.updated (status waiting_input) | (Optional) push the conversation into their CRM. |
| Step 4 | chat.execution.updated (status waiting_time) | Show a “thinking” indicator on Mariya’s screen via their WebSocket channel. |
| Step 5 | chat.execution.updated (status completed) | Relay the final blocks to the widget; trigger a follow-up “Track your shipment” email. |
Things that can go wrong, and what the widget does
| What | What the widget sees | What it does |
|---|---|---|
| User closes the tab during waiting_time | n/a | The execution still completes. Webhook still fires for Acme — the backend can mark it “abandoned” or follow up by email. Next time Mariya opens the widget, the session starts fresh. |
| Worker takes 60+ seconds | Widget shows “Still looking…” then “This is taking longer than usual” | Acme’s WebSocket eventually delivers the final blocks. If Acme’s backend is down, the webhook keeps retrying (6 attempts over ~5 h). |
| Worker fails | Engine transitions to status: failed with a message block. Webhook fires with that payload | Acme relays it; widget renders the message and offers “Try again”. |
| Acme’s webhook endpoint returns 5xx | Worker retries with backoff (1 m, 5 m, 15 m, 1 h, 4 h) | After 6 attempts the row is dead; ops sees it in the fs_webhook_deliveries query. Manual replay is one SQL update. |
| Acme’s webhook endpoint returns 401 (bad signature) | Worker marks dead immediately — won’t retry a permanent rejection | Acme fixes their verification, replays manually. |
sessionToken expires while open | Next /messages returns 401 | Widget silently reopens /sessions, retries. User sees no glitch. |
Mariya from evil.example | n/a — never reaches the controller | WidgetCorsSubscriber returns 403 origin_not_allowed with no CORS headers; browser blocks the body. |