Skip to main content
Most FirstFlow integration problems trace back to one fact: decisions happen on the server, not in the browser. Eligibility (triggers, audience, schedule, frequency, the classifier) runs in the backend’s ConversationRouterService, and a finished widget is composed and pushed down a Socket.IO connection. The browser SDK is a passive consumer it renders what arrives and reports activity back. When something doesn’t work, the question is rarely “is my component broken?” and almost always “did the signal reach the server, and did the server decide to push anything back?” This page walks each failure from that angle. Use the diagnostic flow below to localize the break, then jump to the matching section.

The widget never appears

A widget appears only when the server pushes a widget.show event, which requires three things in sequence: the realtime socket is open, the server observed a qualifying signal, and an eligible experience matched. Walk them in order.
1

Confirm the provider is mounted and keyed

FirstflowWidget must render inside FirstflowProvider, and the provider needs both agentId and publishableKey. The provider throws synchronously if either is empty FirstflowProvider requires a non-empty agentId or ... non-empty publishableKey so an empty key surfaces as a crash, not a silent no-op. Both values come from Settings → SDK integration and are browser-safe. publishableKey is the pk_live_… value and is not a secret.
2

Verify the socket actually connected

The provider opens the connection in an effect. If it fails, it logs [Firstflow] realtime connect failed: to the browser console and renders nothing. A wrong apiUrl, a revoked publishableKey, or a network/CORS block all land here. With no console error, the socket is up and the problem is downstream.
3

Confirm an experience is eligible

Eligibility is evaluated server-side, so the browser cannot force a widget to show. The experience must be active (not draft or paused), its trigger must match the observed event, and its audience rule must match the current user. A correctly mounted widget that never renders almost always means no experience qualified not a client bug.
If you self-host, set apiUrl on the provider to your backend origin; the default https://api.firstflow.app will not reach your deployment. See self-hosting configuration.
agentId
string
required
The agent the widget belongs to. The provider throws if this is empty after trimming whitespace. Copy it from Settings → SDK integration.
publishableKey
string
required
The workspace publishable key (pk_live_…). Browser-safe, not a secret. The provider throws if empty. A revoked or wrong key causes the socket connect to fail rather than the provider to throw.
apiUrl
string
default:"https://api.firstflow.app"
The base URL the Socket.IO client connects to. Override only when self-hosting; a stale value makes the socket connect fail with [Firstflow] realtime connect failed:.
Tours, surveys, and announcements that fire on chat_opens run in user scope and do not require a conversation. They can appear from the user identity alone, before any message. If those work but message-driven guides don’t, the gap is in observed LLM calls see the next section.

My LLM calls aren’t observed

Guides fire because the server observed a conversation turn. Observation depends entirely on tagging: the server SDK reads three flat fields off the LLM request body, strips them before the request reaches OpenAI or Anthropic, and uses them to route the message. Identity is never inferred. If any one of the three is missing, the call is passed through unobserved.
Partial tagging silently drops the call from observation. If you set some but not all of firstflowAgentId, sessionId, and userId, the request still completes against the provider, but FirstFlow never sees it no trigger evaluates, no widget composes. The SDK emits a one-time console warning the first time this happens, then stays quiet. Confirm all three are present on every observed call.
The warning, emitted once per process from @firstflow/sdk, reads:
A correct call tags all three fields inline on the request body. They are removed before the provider sees them, so the provider never errors on unknown fields.
firstflowAgentId
string
required
The agent that owns the conversation the primary routing key on the server. Must be a non-empty string after trimming. Omitting it (or leaving the other two off) makes the call pass through unobserved.
sessionId
string
required
Groups messages into one conversation. Must equal the browser SDK’s conversationId for the widget, transcript, and any flow run to line up. Non-empty after trimming.
userId
string
required
The end-user, stable across sessions. Drives audience targeting and per-user frequency. Non-empty after trimming.
Only four method paths are instrumented: chat.completions.create, messages.create, responses.create, and embeddings.create. Tagging any other method does nothing the fields are not parsed there. Beyond tagging, two environment-level mistakes also stop observation cold.
Every server helper observe(), outcome(), track(), identify(), trace() and the wrapped clients read FIRSTFLOW_API_KEY from the environment. It is a server-only secret; never expose it to the browser. Without it the helpers no-op, so calls complete normally but nothing is recorded. Set it in your server environment, not in client code. See server configuration.
Observation comes from the proxy that @firstflow/sdk wraps around the client. Importing openai or @anthropic-ai/sdk directly bypasses it entirely. Import the pre-wrapped client from the matching subpath @firstflow/sdk/openai, /anthropic, or /aiclient (OpenAI-compatible runtimes like Ollama, vLLM, and LM Studio) or attach the LangChain handler from /langchain. See wrap an LLM client.
For gateways like the Vercel AI SDK or a custom proxy, call observe() directly with firstflowAgentId, sessionId, and userId. It records one conversation turn, reads FIRSTFLOW_API_KEY from the env, and is fire-and-forget it never throws and never blocks the request.

Widget shows but the transcript and traces don’t line up

The browser conversationId and the server sessionId are the same identifier, and a single shared value is what links the widget, the realtime socket, the stored transcript, the branch-decision classifier, and any flow run into one conversation. If they differ, each side records under a separate id, so the widget and the transcript never group. Mint the id once where the conversation begins, persist it, and feed the identical string to both SDKs.
conversationId is optional and lazy by design. Omit it until the first message: with no conversation yet, the socket connects on user identity alone and chat_opens experiences run in user scope. On the first message, mint a stable id (for example crypto.randomUUID()), persist it (for example in localStorage), and reuse it across reloads so the socket reconnects bound to the same conversation. Use a fresh id only to start a genuinely new conversation. See conversations and sessions.

Audience targeting isn’t matching

Audience rules are evaluated server-side against the traits FirstFlow holds for a user, so targeting can only match data you have actually sent. Traits arrive two ways, and both must reach the server before a rule referencing them can evaluate true. From the browser, pass traits on the user prop; the provider identifies the user immediately from this value without waiting for the socket. From the server, attach them with identify().
If traits are present but a segment still won’t match, the rule itself is the suspect. Conditions target traits.* or answers.* and use a fixed operator set equals, not_equals, contains, not_contains, in, not_in, gt, gte, lt, lte, exists, not_exists. A common mismatch is type or operator: gt on a string-typed trait, or in against a value that isn’t in the list. Review the segment rules in triggers and audience.

MCP client gets a 401

The /mcp endpoint accepts OAuth only static API keys are rejected there. A 401 means the OAuth consent flow has not completed. Open the dashboard, approve the consent screen, and reconnect your coding agent. API keys remain valid for other surfaces; they are simply not accepted on MCP. See MCP API keys for where keys do apply.

AI features do nothing (self-hosted)

Widget composition, classification, and prompt-to-flow generation all call Anthropic (claude-sonnet-4-6) through the backend. Without an AI provider key set ANTHROPIC_API_KEY on the backend these features degrade gracefully: experiences still trigger, but nothing AI-composed is produced. If guides trigger yet no widget renders on a self-hosted deployment, an unset provider key is the first thing to check. See self-hosting configuration.

Still stuck?

Check the FAQ for product-level questions, or open an issue on GitHub.