Skip to main content
A complete customer journey, narrated. One concrete flow, every API call, every state transition, every retry. If the rest of the docs feel abstract, read this one first.

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:
  1. Builds the order_status flow:
    • Trigger node: trigger.chat with intent_name: order_status, description “Look up the status of an order”
    • message node: “What’s your order number?”
    • form node: one field order_number (required text)
    • enqueue.task node: dispatches to the inventory.lookup queue with order_number → returns waiting_time
    • await.task_result node: waits for the worker’s response
    • message node: “Order # ships . Tracking:
    • End
  2. Publishes version 1 of the flow.
  3. Issues a widget key — bundled with the webhook URL Acme’s backend will receive events at:
    ddev console comerix:widget-key:create \
        --tenant=019e2667-...                    \
        --label='acme.com chat'                  \
        --origin='https://acme.com'              \
        --origin='https://*.acme.com'            \
        --intent=order_status                    \
        --intent=returns                         \
        --webhook='https://acme.com/webhooks/comerix-flow'
    # → pk_live_5940830723da7ddae5699200f465e8d2
    #   whsec_…  (the HMAC secret — shown once)
    
The integrator gets the 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.
POST /api/public/v1/chat/sessions
Origin: https://acme.com
Content-Type: application/json

{
  "publicKey": "pk_live_5940830723da7ddae5699200f465e8d2",
  "customerId": "u-42",
  "locale": "en",
  "variables": { "plan_tier": "gold", "cart_value": "129.00" }
}
The optional top-level 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:
  1. WidgetCorsSubscriber sees Origin: https://acme.com, looks up the key, checks against allowedOrigins. Match.
  2. Controller resolves the tenant from the key, sets the tenant context for RLS.
  3. ConversationStore::ensure() creates an fs_conversations row keyed to customerId='u-42'.
  4. WidgetSessionToken::issue() signs {tid, cid, wk, iat, exp} with HMAC-SHA256.
  5. 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).
  6. AuditLogger records widget.session.start.
Response (200):
{
  "sessionToken": "eyJ0aWQiOi…",
  "conversationId": "0193f8a1-7c2d-7e8f-9a1b-...",
  "expiresAt": "2026-05-15T13:00:00Z",
  "widget": { "label": "acme.com chat" },
  "intents": [
    {
      "name": "order_status",
      "description": "Look up the status of an order",
      "examples": ["Where is my order?", "Track my package"],
      "required_entities": []
    }
  ],
  "quickQuestions": [
    {
      "question": "Where's my order?",
      "pageType": "general",
      "flowId": "0193f8...",
      "intentName": "order_status"
    }
  ]
}
The widget caches the 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.)
POST /api/public/v1/chat/messages
Authorization: Bearer eyJ0aWQiOi…
Origin: https://acme.com
Content-Type: application/json

{
  "text": "Where's my order from yesterday?",
  "intentName": "order_status",
  "context": { "page_url": "https://acme.com/orders/recent" }
}
What the server does:
  1. CORS subscriber validates Origin against the key tied to the token.
  2. Controller verifies the token, sets tenant context.
  3. Body has text (no waitToken) → trigger path.
  4. widgetKey.allowsIntent('order_status') → true.
  5. TriggerChatService::trigger():
    • Looks up the published order_status flow.
    • Creates a new fs_executions row + ExecutionStarted event in one DB transaction.
    • Records the user message in fs_chat_messages.
    • Runs the step loop: message block → form block → pauses on expectedInput.
  6. AuditLogger records widget.message.send (mode=trigger).
  7. 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).
  8. Widget gets a Reply with status: waiting_input, blocks to render, waitToken, executionId.
Response:
{
  "reply": {
    "executionId": "01935-aaaa-...",
    "conversationId": "0193f8a1-...",
    "status": "waiting_input",
    "blocks": [
      {"id": "b_msg_1", "type": "message",
       "payload": {"role": "agent", "text": "What's your order number?", "format": "plain"}},
      {"id": "b_form_1", "type": "form",
       "payload": {"fields": [{"name": "order_number", "type": "text", "label": "Order #", "required": true}],
                   "submit_label": "Check"}}
    ],
    "expectedInput": {
      "type": "form_submission",
      "schema": {"type": "object", "required": ["order_number"], "properties": {"order_number": {"type": "string"}}}
    },
    "waitToken": "wait_e08e9aa8232d29d957f5a788b9c3d315",
    "waitExpiresAt": null
  }
}
The widget renders the agent line as a chat bubble, then the form below the bubble. It stores executionId and waitToken in memory.

Step 4 — Mariya types the order number

She enters 12345 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:
POST /api/public/v1/chat/messages
Authorization: Bearer eyJ0aWQiOi…
Origin: https://acme.com
Content-Type: application/json

{
  "waitToken": "wait_e08e9aa8232d29d957f5a788b9c3d315",
  "executionId": "01935-aaaa-...",
  "values": { "order_number": "12345" }
}
What the server does:
  1. Auth + CORS as before.
  2. Body has waitToken + executionIdresume path.
  3. ResumeExecutionService::resume():
    • Loads the execution snapshot.
    • Verifies status is waiting_input and the token matches via hash_equals (replay-safe).
    • Validates values against the schema. Passes.
    • Records {order_number: "12345"} as a user message.
    • Advances to the next node: enqueue.task.
    • The node dispatches an InventoryLookupMessage to the inventory.lookup Symfony Messenger queue.
    • The flow transitions to status waiting_time. State machine stores waitToken cleared (single-use done), waits for the worker.
  4. Engine emits a chat.execution.updated outbox event with {status: waiting_time, ...}.
  5. Widget gets a Reply with status: waiting_time and an empty blocks array.
Response:
{
  "reply": {
    "executionId": "01935-aaaa-...",
    "conversationId": "0193f8a1-...",
    "status": "waiting_time",
    "blocks": [],
    "expectedInput": null,
    "waitToken": null,
    "waitExpiresAt": "2026-05-15T12:30:00Z"
  }
}
The widget shows a subtle “Looking that up…” indicator. Now it has to wait until the worker finishes. Two ways to learn when it’s done:
  • Webhook → Acme backend → widget (preferred) — the server POSTs chat.execution.updated to 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.
The Acme widget uses the webhook path.

Step 5 — The async work completes

Meanwhile the inventory.lookup worker in another process:
  1. Picks up the queued message: {execution_id: '01935-...', order_number: '12345'}.
  2. Calls Acme’s internal inventory API (takes 4.7 s).
  3. Gets back {ship_date: '2026-05-16', tracking: '1Z…'}.
  4. POSTs the result to /api/v1/engine/events with event_name: inventory.lookup.completed, data containing the result.
  5. Engine matches the event to the waiting subscription, resumes the execution past await.task_result, evaluates the final message node, transitions to completed. It enqueues a chat.execution.updated webhook row.
A few seconds later (or immediately, depending on cron cadence) the webhook worker POSTs to Acme:
POST https://acme.com/webhooks/comerix-flow
X-Comerix-Event: chat.execution.updated
X-Comerix-Event-Id: 01935-...
X-Comerix-Signature: sha256=df4e526e...

{
  "event": "chat.execution.updated",
  "widget_key_id": "01935-...",
  "occurred_at": "2026-05-15T12:30:00+00:00",
  "tenant_id": "019e2667-...",
  "conversation_id": "0193f8a1-...",
  "execution_id": "01935-...",
  "mode": "resume",
  "status": "completed",
  "blocks": [
    {"id":"b_msg_final","type":"message",
     "payload":{"role":"agent","text":"Order #12345 ships May 16. Tracking: 1Z…","format":"markdown"}}
  ],
  "wait_token": null
}
Acme’s webhook receiver:
  1. Verifies the X-Comerix-Signature against the saved whsec_….
  2. Dedupes by X-Comerix-Event-Id.
  3. Pushes {status: completed, blocks: [...]} to Mariya’s browser tab over Acme’s existing WebSocket channel.
  4. Acks with 204.
The widget receives the push from Acme’s WebSocket, renders the final bubble. Mariya is happy. See Webhooks for the full event catalog, signature verification snippets, and retry behaviour.

Step 6 — Optional telemetry

Throughout, the widget fires telemetry:
POST /api/public/v1/chat/events
{"name": "widget_open"}

POST /api/public/v1/chat/events
{"name": "message_view", "props": {"block_id": "b_msg_final"}}

POST /api/public/v1/chat/events
{"name": "widget_close", "props": {"duration_ms": 14200}}
These land in the audit log and are aggregated by analytics (avg session length, completion rate per intent, drop-off points, etc.).

What Acme’s backend received along the way

The same webhookUrl that delivers the final completed event also received the earlier lifecycle events:
WhenEventWhat Acme does with it
Step 2chat.session.openedIncrement “chat opened” metric in their dashboard.
Step 3chat.execution.updated (status waiting_input)(Optional) push the conversation into their CRM.
Step 4chat.execution.updated (status waiting_time)Show a “thinking” indicator on Mariya’s screen via their WebSocket channel.
Step 5chat.execution.updated (status completed)Relay the final blocks to the widget; trigger a follow-up “Track your shipment” email.
The widget and the webhook are independent surfaces — the widget owns the customer-facing UI; the webhook lets Acme automate around every state change.

Things that can go wrong, and what the widget does

WhatWhat the widget seesWhat it does
User closes the tab during waiting_timen/aThe 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+ secondsWidget 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 failsEngine transitions to status: failed with a message block. Webhook fires with that payloadAcme relays it; widget renders the message and offers “Try again”.
Acme’s webhook endpoint returns 5xxWorker 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 rejectionAcme fixes their verification, replays manually.
sessionToken expires while openNext /messages returns 401Widget silently reopens /sessions, retries. User sees no glitch.
Mariya from evil.examplen/a — never reaches the controllerWidgetCorsSubscriber returns 403 origin_not_allowed with no CORS headers; browser blocks the body.