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

# Conversations & sessions

> The host-owned id that joins the widget, the transcript, traces, cost, and any flow run into one thread.

A **conversation** (used interchangeably with **session**) is one chat thread between an end-user and your AI agent product. It is the unit FirstFlow uses to group messages, render experiences in context, evaluate triggers, and attribute traces and cost.

You own the conversation's identifier. The same value travels two paths `conversationId` on [`FirstflowProvider`](/react/provider) in the browser and `sessionId` on every observed [LLM call](/server/wrap-llm-client) on the server and FirstFlow joins them into a single record. Get that one value right and everything lines up; get it wrong and the widget, the transcript, and the traces drift apart.

## How it works

There is no FirstFlow-issued conversation token. The id is a string you mint, persist, and pass through both SDKs. The server reconciles browser and server activity by the pair `(agentId, conversationId)` stored as `(agentId, externalId)` in the conversations table, where `externalId` is your `conversationId`/`sessionId` verbatim.

That shared id is the join key for everything attributed to the thread:

* the **widget** rendered in the browser and the **realtime socket** scope it arrives on,
* the stored **transcript** of user/assistant turns,
* the **classifier context** that triggers and decision branches read,
* the **LLM traces** model, tokens, latency, cost, errors and any **outcome** or **intent** signal, and
* any **flow run** (the server-side step state behind a multi-step experience).

The diagram below shows the two ingest paths converging on one conversation record.

```mermaid theme={null}
flowchart TD
    subgraph browser["Browser @firstflow/react"]
        P["FirstflowProvider<br/>conversationId='ses_42'"]
        W["FirstflowWidget"]
    end
    subgraph server["Your server @firstflow/sdk"]
        L["Wrapped LLM call<br/>sessionId='ses_42'"]
    end
    subgraph ff["FirstFlow backend"]
        S(("Conversation<br/>agentId + externalId='ses_42'"))
        R["ConversationRouterService<br/>triggers · audience · classifier"]
        C["WidgetCompositionService<br/>Anthropic claude-sonnet-4-6"]
    end

    P -- "Socket.IO handshake<br/>binds socket to ses_42" --> S
    L -- "POST /v1/conversations/ses_42/messages (202)" --> S
    S --> R --> C
    C -- "widget.show pushed over Socket.IO" --> W

    classDef edge fill:#fde68a,stroke:#b45309,color:#7c2d12;
    classDef core fill:#bbf7d0,stroke:#15803d,color:#14532d;
    classDef hub fill:#e9d5ff,stroke:#7e22ce,color:#581c87;
    class P,W,L edge;
    class R,C core;
    class S hub;
```

### Why the server owns reconciliation

The browser SDK is a passive consumer. It does not decide what to show it opens the socket, reports activity, and renders whatever arrives. Eligibility (triggers, audience, schedule, frequency, and any classifier) is evaluated server-side in `ConversationRouterService`, and the finished `WidgetTree` is composed by Anthropic (`claude-sonnet-4-6`) and pushed back over Socket.IO to the room `member:{workspaceId}:{userId}`. Flow navigation (`flow.next` / `flow.back`) is likewise server-side.

That is precisely why the conversation id must match across the two SDKs: the server is the only place where a browser socket and a server-side LLM call can be recognized as the same thread, and it recognizes them only by `(agentId, conversationId)`.

### What ingest does

Each observed LLM call sends one turn to `POST /v1/conversations/:conversationId/messages`. The endpoint returns `202 Accepted` and processes the message fire-and-forget observation never blocks your model call. On the way in, the request carries the most recent turns as a bounded window:

```ts theme={null}
// Shape the server SDK forwards per observed turn (IngestMessageDto)
{
  conversationId: "ses_42",            // = browser conversationId
  userId: "u_8c1f",                     // stable end-user id
  role: "assistant",                    // "user" | "assistant" | "system"
  content: "Here's how to connect Slack…",
  recentMessages: [                     // bounded sliding window, not the full thread
    { role: "user", content: "How do I wire up Slack?" }
  ],
  model: "gpt-4o", provider: "openai",  // LLM metadata, auto-captured
  inputTokens: 312, outputTokens: 88, totalCostUsd: 0.0021
}
```

The server is the durable memory of the conversation, independent of whatever window you happen to send on any single call. `ConversationStoreService` upserts the conversation on `(agentId, externalId)`, locates the last stored turn inside the incoming history, and appends only what comes after it. Appending-after-last makes ingest idempotent and tolerant of your app trimming its own context window resending overlapping turns never duplicates rows.

Two things separate stored memory from decision context. The **full transcript** is persisted (user, assistant, and enriching `system`/`tool` rows). The **classifier context** is deliberately narrower: trigger and branch decisions read at most the last 100 user/assistant turns, so stored system/tool rows never leak into the model that gates a trigger. The `recentMessages` you send is the window the classifier sees for the turn being ingested keep it focused, not the entire history.

## Owning and minting the id

`conversationId` is optional and lazy by design. Leave it empty until the conversation actually begins the first user message. Until then, the socket connects on the user identity alone, so `chat_opens` experiences (tours, surveys, announcements that fire when the chat opens) still run in **user scope** without a conversation.

When the first message arrives, mint a stable id, persist it, and reuse it across reloads so the same thread keeps accumulating:

```ts theme={null}
let conversationId = localStorage.getItem("ff_conversation");
if (!conversationId) {
  conversationId = crypto.randomUUID();
  localStorage.setItem("ff_conversation", conversationId);
}
// Pass `conversationId` to FirstflowProvider AND as `sessionId` on every server LLM call.
```

To start a brand-new thread (a "New chat" button), generate a fresh id and reset the stored value. The previous conversation stays intact under its old id; the new one accumulates under the new id.

If you already have a conversation primitive in your product, reuse its id you do not need a separate FirstFlow id. The only contract is that the browser `conversationId` and the server `sessionId` are byte-for-byte identical for the same thread.

<Warning>
  If the browser `conversationId` and the server `sessionId` don't match, FirstFlow treats them as two different conversations: the widget renders against one record while the transcript and traces accumulate on another. Always pass the same value, and read it back in the browser with `useFirstflow().getConversationId()` when you need to confirm what the socket is bound to.
</Warning>

## End-to-end: one thread, both SDKs

A minimal correct integration threads the same id through the provider and the wrapped client. The browser code:

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

<FirstflowProvider
  agentId="agt_9f2c"
  publishableKey="pk_live_…"
  user={{ id: "u_8c1f", email: "ada@acme.com", traits: { plan: "pro" } }}
  conversationId={conversationId}     // lazy; omit until the first message
>
  {children}
</FirstflowProvider>
```

The server code reads `FIRSTFLOW_API_KEY` from the environment and tags the call with all three identifiers. The pre-wrapped client strips the FirstFlow fields before the request reaches OpenAI:

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

const openai = new OpenAI(); // reads FIRSTFLOW_API_KEY + OPENAI_API_KEY from env

const completion = await openai.chat.completions.create({
  firstflowAgentId: "agt_9f2c",
  sessionId: "ses_42",        // MUST equal the browser conversationId
  userId: "u_8c1f",           // MUST equal the provider user.id
  model: "gpt-4o",
  messages: [{ role: "user", content: "How do I wire up Slack?" }],
});
```

Tagging is all-or-nothing. Every observed call needs `firstflowAgentId` **and** `sessionId` **and** `userId`; identity is never inferred. If a call is tagged with some but not all three, the FirstFlow fields are still stripped (they never reach the provider) but the call is passed through **unobserved**, with a one-time console warning. A call with none of the three fields is simply a normal, untracked LLM call. See [Wrap your LLM client](/server/wrap-llm-client) for the OpenAI, Anthropic, AI-client, and LangChain variants.

## Signals on a conversation

Beyond the transcript, two kinds of signal attach to the same id.

**Traces** are created automatically. Every observed LLM call bridges into a trace plus a generation span model, provider, tokens, latency, cost, finish reason, errors keyed by `sessionId`, so the [Traces](/server/traces) view has data without any explicit instrumentation. Call [`trace()`](/server/traces) yourself only when you want richer nested spans (retrieval, tool calls) under the same session.

**Outcomes and intent** are explicit. When a thread ends, send what happened with [`outcome()`](/server/outcomes):

```ts theme={null}
import { outcome } from "@firstflow/sdk";

outcome({
  firstflowAgentId: "agt_9f2c",
  sessionId: "ses_42",                 // same conversation id
  userId: "u_8c1f",
  outcome: "completed",                 // "completed" | "abandoned" | "escalated" | custom
  intent: "billing-question",           // free-form, grouped in analytics
});
```

`outcome()` is fire-and-forget and writes to the conversation via `PATCH /v1/conversations/:conversationId/signal`. It is a no-op for an unknown conversation, so it never throws if the row doesn't exist yet. Outcome and intent power the session KPIs in [Analytics](/features/analytics).

## Specific cases and limitations

<AccordionGroup>
  <Accordion title="Activity, idle, and chat_open experiences">
    The socket binds to the conversation in its handshake, but user-scoped triggers don't need a conversation. Call [`notifyActivity()`](/react/use-firstflow) on real interaction (keystroke, click) to reset the idle timer; after 30 seconds without activity the SDK emits `user.idle` once, which can drive an `after_idle` trigger. Because eligibility is server-side, you cannot force a widget from the browser you report activity and the server decides.
  </Accordion>

  <Accordion title="Commands and the active conversation">
    Slash commands and programmatic triggers run against a conversation too. `triggerCommand(commandId, conversationId?)` defaults to the conversation the provider is bound to; pass an explicit `conversationId` to target a different thread. Read the bound id at any time with `getConversationId()`. See [Commands](/react/commands).
  </Accordion>

  <Accordion title="Persistence and the userTrackingEnabled flag">
    Passive transcript persistence is gated on the agent's tracking policy, but LLM metadata from an observed call is always stored it is explicit developer intent, not passive behavior. So traces and cost land regardless, while the stored message transcript follows the agent's setting.
  </Accordion>

  <Accordion title="Resending overlapping windows is safe">
    The store appends only the turns after the last one it already has, matched by role and content. Resending a window that overlaps previous calls common when your app keeps a rolling context does not create duplicate rows. Disjoint windows (no overlap found) are appended whole.
  </Accordion>
</AccordionGroup>

## Next

Mount the [provider](/react/provider) with your `conversationId`, then [wrap your LLM client](/server/wrap-llm-client) and pass the same value as `sessionId`. For who the conversation belongs to, see [Identity](/react/identity); for what the thread can trigger, see [Triggers & audience](/concepts/triggers-and-audience).
