conversationId on FirstflowProvider in the browser and sessionId on every observed LLM call 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).
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 inConversationRouterService, 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 toPOST /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:
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:
conversationId and the server sessionId are byte-for-byte identical for the same thread.
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: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:
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 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 bysessionId, so the Traces view has data without any explicit instrumentation. Call trace() 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():
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.
Specific cases and limitations
Activity, idle, and chat_open experiences
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() 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.Commands and the active conversation
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.Persistence and the userTrackingEnabled flag
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.
Resending overlapping windows is safe
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.
Next
Mount the provider with yourconversationId, then wrap your LLM client and pass the same value as sessionId. For who the conversation belongs to, see Identity; for what the thread can trigger, see Triggers & audience.