> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstflow.app/llms.txt
> Use this file to discover all available pages before exploring further.

# How it works

> The FirstFlow architecture: why the backend decides everything, how a finished widget reaches the browser over Socket.IO, and what the two SDKs actually do.

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.

<Note>
  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.
</Note>

## 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](/server/overview) 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](/react/overview) 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.

```mermaid theme={null}
flowchart TD
  A["Your app LLM chat"] -->|"user / assistant message"| B["@firstflow/sdk<br/>wraps your OpenAI / Anthropic / aiclient / LangChain client"]
  B -->|"POST /v1/conversations/:id/messages<br/>(202, fire-and-forget)"| C["Backend ConversationRouterService"]
  C --> D["1. Derive trigger from message role<br/>2. Find eligible experiences (trigger + audience)<br/>3. Resolve classifier / branch decision<br/>4. Walk the flow graph to a Result<br/>5. Compose a WidgetTree (claude-sonnet-4-6)"]
  D -->|"widget.show over Socket.IO<br/>→ member:{workspaceId}:{userId}"| E["@firstflow/react<br/>renders the WidgetTree as blocks"]
  E -->|"user interacts: CTA / answer / dismiss"| F["widget.action emitted back<br/>(flow.next / flow.back / widget.dismiss)"]
  F -->|"backend advances the flow,<br/>pushes the next tree"| E

  style A fill:#0F172A,stroke:#0F172A,color:#ffffff
  style B fill:#DB2777,stroke:#BE185D,color:#ffffff
  style C fill:#16A34A,stroke:#15803D,color:#ffffff
  style D fill:#7C3AED,stroke:#6D28D9,color:#ffffff
  style E fill:#0D9488,stroke:#0F766E,color:#ffffff
  style F fill:#EA580C,stroke:#C2410C,color:#ffffff
```

## 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.

| Component   | Stack                                      | Role                                                                                                                   |
| ----------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Dashboard   | Next.js (App Router)                       | Author [agents](/concepts/agents), [experiences](/concepts/experiences), and [flows](/concepts/flows-and-nodes).       |
| Backend     | NestJS + Postgres + ClickHouse + Socket.IO | Canonical logic: trigger/audience evaluation, flow walking, AI widget composition, realtime push, analytics ingestion. |
| Server SDK  | [`@firstflow/sdk`](/server/overview)       | Wraps your LLM client to feed conversation turns, traces, and cost to the backend.                                     |
| Browser SDK | [`@firstflow/react`](/react/overview)      | Opens a realtime socket and renders the widgets the backend pushes. Passive consumer.                                  |

## 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.

```ts theme={null}
import { OpenAI } from "@firstflow/sdk/openai"; // reads FIRSTFLOW_API_KEY

const client = new OpenAI(); // your normal OpenAI client, wrapped

const completion = await client.chat.completions.create({
  firstflowAgentId: "agt_123",       // primary routing key
  sessionId: "ses_abc",              // groups turns into one conversation
  userId: "user_42",                 // the end-user, stable across sessions
  model: "gpt-4o",
  messages: [{ role: "user", content: "How do I export my data?" }],
});
// completion is the unmodified OpenAI response the three fields never left your server.
```

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.

<Warning>
  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.
</Warning>

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](/server/wrap-llm-client) and [LangChain](/server/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`](/react/provider) once near the root of your app and drop [`FirstflowWidget`](/react/widget) 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.

```tsx theme={null}
import { FirstflowProvider, FirstflowWidget } from "@firstflow/react";

function App({ user, conversationId, children }) {
  return (
    <FirstflowProvider
      agentId="agt_123"
      publishableKey="pk_live_…"
      user={{ id: user.id, email: user.email, traits: { plan: user.plan } }}
      conversationId={conversationId}
    >
      {children}
      <FirstflowWidget />
    </FirstflowProvider>
  );
}
```

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](/react/theming) and [command](/react/commands) list automatically; you do not fetch or refresh config yourself.

The provider exposes no way to trigger a widget. Through [`useFirstflow()`](/react/use-firstflow) 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.

## One conversation id links both sides

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](/concepts/conversations-and-sessions).

```tsx theme={null}
// Mint once on the first message, then reuse everywhere.
let conversationId = localStorage.getItem("ff_convo");
if (!conversationId) {
  conversationId = crypto.randomUUID();
  localStorage.setItem("ff_convo", conversationId);
}
// Pass `conversationId` to FirstflowProvider AND the same value to the server SDK `sessionId`.
```

<Warning>
  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.
</Warning>

## 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](/concepts/flows-and-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.

| Concern                                                | Where it runs | Owner                                                                                   |
| ------------------------------------------------------ | ------------- | --------------------------------------------------------------------------------------- |
| Trigger / audience / schedule / frequency / classifier | Server        | Backend (`ConversationRouterService`)                                                   |
| Widget composition (`WidgetTree`)                      | Server        | Backend AI layer (Anthropic `claude-sonnet-4-6`)                                        |
| Flow navigation (`flow.next` / `flow.back`)            | Server        | Backend (flow navigation listener)                                                      |
| Observing LLM turns + cost                             | Your server   | [`@firstflow/sdk`](/server/overview)                                                    |
| Rendering the pushed `WidgetTree`                      | Browser       | [`@firstflow/react`](/react/overview) + [`@firstflow/widget-kit`](/widget-kit/overview) |
| Reporting activity / analytics                         | Browser       | [`useFirstflow()`](/react/use-firstflow)                                                |

## 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`](/widget-kit/overview), shared by the composer and the renderer. Because of this, the [concepts](/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](/quickstart), or read the [core concepts](/concepts) agents, experiences, flows, triggers, sessions, and widgets to build the full mental model.
