Skip to main content
FirstFlow shows onboarding UI tours, surveys, announcements, and AI guides inside your chat product without you building that UI. The mechanism that makes this work, and the source of most “why didn’t my widget show?” confusion, is a single decision: the backend evaluates eligibility and composes the widget; the browser only renders what arrives. Concretely, FirstFlow is three parts. A dashboard where you author experiences, a backend that owns every runtime decision, and two SDKs that connect your product: a server SDK that feeds conversation turns in, and a browser SDK that renders widgets back out. Nothing is decided in the browser.
The browser SDK never evaluates triggers, audience, or schedules. You cannot force a widget from client code you report activity, and the server decides. This is the most important fact on this page; the rest explains why it is built that way and how to work with it.

How it works

A turn flows in one direction and a finished widget flows back. When your app calls its LLM, the wrapped server SDK forwards the turn (plus a recent-message window) to the backend over a fire-and-forget HTTP call. The backend’s ConversationRouterService then derives the trigger from the message role, finds eligible experiences (trigger match, then audience), resolves any AI classifier or branch decision, walks the flow graph to a result, and composes a WidgetTree with Anthropic (claude-sonnet-4-6). That finished tree is pushed over Socket.IO to the user’s room, where the browser SDK renders it as blocks in your chat UI. When the user interacts, the action travels back over the same socket and the backend advances the flow server-side and pushes the next step. That ordering is the North Star: eligibility is server-side, composition is server-side, navigation is server-side. The browser is a passive consumer that renders trees and reports activity. When a widget does not appear, the cause is almost always upstream of the browser the turn was not tagged, the trigger did not match, or audience excluded the user not a rendering bug.

The four components

The dashboard is a Next.js app where you build agents, experiences, and flows; the browser never reaches the backend except through its own same-origin proxy. The backend is a NestJS service backed by PostgreSQL (domain state), ClickHouse (analytics), and Socket.IO (realtime), and it is the canonical owner of trigger, flow, and event semantics. The two SDKs are the only surface your product touches.

The server SDK feeds turns in

The server SDK is a transparent proxy around your existing OpenAI or Anthropic client. It does not replace your LLM calls it observes them. You import a pre-wrapped client from a peer-isolated subpath, then tag each call you want observed with three flat fields: firstflowAgentId, sessionId, and userId. The proxy strips those three fields from the request body before the call reaches OpenAI or Anthropic, fires a fire-and-forget observe hook with the turn and recent context, and threads OTel baggage so any auto-instrumented spans route to the right agent.
All three fields are required, and the proxy enforces this strictly. If you provide some but not all of them, the call is stripped of the partial tags and passed through unobserved, with a one-time warning logged so a typo in userId silently means no widget ever fires. This is the most common integration mistake.
Partial tagging means the request runs but is never observed. If you tag firstflowAgentId and sessionId but forget userId, the turn passes through to OpenAI normally, no error is thrown, and no widget can ever fire for that turn. Tag all three, every time.
Only four method paths are instrumented: chat.completions.create, messages.create, responses.create, and embeddings.create. Everything else on the client proxies straight through untouched. For OpenAI and Anthropic chat calls, the proxy observes the user turn (when the request ends with a user message) and attaches an assistant hook that fires once the response streamed or not is fully consumed, capturing tokens, latency, and finish reason. That is why a trigger like after_user_and_agent_message can fire from the wrapped client alone: both halves of the turn are observed automatically. If you cannot use a pre-wrapped client a custom gateway, the Vercel AI SDK, or an OpenAI-compatible local model use the standalone helpers instead. observe(), outcome(), track(), identify(), and trace() are all agent-scoped, read FIRSTFLOW_API_KEY from the environment, and are fire-and-forget. For OpenAI-compatible endpoints (Ollama, vLLM, LM Studio) import from @firstflow/sdk/aiclient; for LangChain, attach FirstflowCallbackHandler or wrap your chain with withFirstflow from @firstflow/sdk/langchain. See Wrap your LLM client and LangChain.

The browser SDK renders trees back out

The browser SDK is the inverse: it never sends LLM turns, it only opens a socket and renders what the backend pushes. You mount FirstflowProvider once near the root of your app and drop FirstflowWidget where the widget should appear. The provider opens the Socket.IO connection, identifies the user immediately from the user prop (no waiting for the socket), and binds the conversation.
The publishableKey (pk_live_…) is browser-safe and is not a secret it is distinct from the server’s FIRSTFLOW_API_KEY, which never leaves your backend. The provider authenticates the socket with a short-lived client JWT minted on its behalf, then joins the user’s room. When the server emits its agent.config event, the provider applies the theme and command list automatically; you do not fetch or refresh config yourself. The provider exposes no way to trigger a widget. Through useFirstflow() you can call notifyActivity() to tell the server the user is active, track()/identify() for analytics, triggerCommand() to fire a command, and getConversationId() to read the bound id but whether a widget shows is the server’s decision. That is the design: the browser reports, the server decides. The conversationId you pass to FirstflowProvider and the sessionId you pass on the server SDK must be the same string. That shared id is the join key for everything: the widget, the realtime room, the stored transcript, the classifier’s context window, and any in-progress flow run. You own this id FirstFlow does not mint it for you. It is optional and lazy by design. Omit it until the conversation actually begins (the first message). With no conversation yet, the socket connects on user identity alone, so chat_opens experiences run in user scope. On the first message, mint a stable id (crypto.randomUUID()), persist it (for example in localStorage), reuse it across reloads, and pass that same value as the server SDK’s sessionId. Use a fresh id to start a new conversation. See Conversations & sessions.
If the browser conversationId and the server sessionId diverge, the transcript, classifier context, and flow run split across two conversations and the widget cannot reattach to the turn that produced it. Keep them identical.

Authoring graph vs. runtime graph

Each experience stores two representations of its flow, and you only ever edit one of them. flow is the full React Flow editor graph { nodes, edges, viewport } including editor-only chrome that you build in the visual builder. flowRuntime is a normalized runtime graph derived from flow when you save: chrome stripped, edges validated. The backend walks flowRuntime, never flow. You never touch flowRuntime directly; it is regenerated automatically on every save. This split is why the visual editor can carry layout and UI metadata that the runtime ignores, and why what runs is always a clean, validated subset of what you drew. The flow vocabulary trigger, classify, result, action, and content nodes is covered in Flows & nodes.

What runs where

Because eligibility, composition, and navigation are all server-side, the boundary between the two SDKs is sharp. The table below maps each runtime concern to its owner so you can reason about failures.

One object model, two surfaces

The experiences you build in the dashboard emit the same widget primitives and analytics events the SDK consumes there is one shared model underneath. The WidgetTree and Block schema lives in @firstflow/widget-kit, shared by the composer and the renderer. Because of this, the concepts you learn here apply whether you author in the UI or read data back through the SDK; you are never reconciling two different models.

Next

Install both SDKs and ship a live experience in the quickstart, or read the core concepts agents, experiences, flows, triggers, sessions, and widgets to build the full mental model.