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 awidget.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.apiUrl on the provider to your backend origin; the default https://api.firstflow.app will not reach your deployment. See self-hosting configuration.
The agent the widget belongs to. The provider throws if this is empty after trimming whitespace. Copy it from Settings → SDK integration.
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.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. The warning, emitted once per process from@firstflow/sdk, reads:
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.
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.The end-user, stable across sessions. Drives audience targeting and per-user frequency. Non-empty after trimming.
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.
FIRSTFLOW_API_KEY is unset
FIRSTFLOW_API_KEY is unset
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.You imported the raw vendor SDK instead of the wrapped one
You imported the raw vendor SDK instead of the wrapped one
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.You can't use a wrapped client at all
You can't use a wrapped client at all
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 browserconversationId 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 theuser prop; the provider identifies the user immediately from this value without waiting for the socket. From the server, attach them with identify().
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.