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

# Troubleshooting

> Diagnose widget, server SDK, identity, and self-hosting issues by reasoning from where FirstFlow evaluates each decision.

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.

```mermaid theme={null}
flowchart TD
  A[Browser: FirstflowProvider mounts] -->|publishableKey + agentId + user| B{Socket connects?}
  B -- no --> B1[Check keys / apiUrl / console warning]
  B -- yes --> C[Server: LLM call observed via @firstflow/sdk]
  C -->|firstflowAgentId + sessionId + userId| D{All three tags present?}
  D -- no --> D1[Call passes through unobserved]
  D -- yes --> E[ConversationRouterService evaluates eligibility]
  E --> F{Active experience + trigger + audience match?}
  F -- no --> F1[Nothing is pushed]
  F -- yes --> G[WidgetCompositionService renders WidgetTree]
  G -->|widget.show over Socket.IO| H[Browser renders widget]
  classDef bad fill:#fde2e2,stroke:#c0392b,color:#7b1f1f;
  classDef good fill:#e6f4ea,stroke:#1e8e3e,color:#14532d;
  classDef neutral fill:#f3f0ff,stroke:#6b4fbb,color:#3b2a6b;
  class B1,D1,F1 bad;
  class H good;
  class A,C,E,G neutral;
```

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

<Steps>
  <Step title="Confirm the provider is mounted and keyed">
    [`FirstflowWidget`](/react/widget) must render inside [`FirstflowProvider`](/react/provider), 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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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](/self-hosting/configuration).

<ParamField path="agentId" type="string" required>
  The agent the widget belongs to. The provider throws if this is empty after trimming whitespace. Copy it from **Settings → SDK integration**.
</ParamField>

<ParamField path="publishableKey" type="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.
</ParamField>

<ParamField path="apiUrl" type="string" default="https://api.firstflow.app">
  The base URL the Socket.IO client connects to. Override only when [self-hosting](/self-hosting/overview); a stale value makes the socket connect fail with `[Firstflow] realtime connect failed:`.
</ParamField>

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

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

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

The warning, emitted once per process from `@firstflow/sdk`, reads:

```text theme={null}
[@firstflow/sdk] LLM call tagged with some but not all of `firstflowAgentId`, `sessionId`, `userId` the call was NOT observed. Provide all three to track it.
```

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.

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

// FIRSTFLOW_API_KEY is read from the environment never hard-code it.
const client = new OpenAI();

const res = await client.chat.completions.create({
  firstflowAgentId: "agt_123",        // primary routing key
  sessionId: "ses_abc",               // groups messages 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?" }],
});
```

<ParamField path="firstflowAgentId" type="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.
</ParamField>

<ParamField path="sessionId" type="string" required>
  Groups messages into one conversation. Must equal the browser SDK's [`conversationId`](/react/provider) for the widget, transcript, and any flow run to line up. Non-empty after trimming.
</ParamField>

<ParamField path="userId" type="string" required>
  The end-user, stable across sessions. Drives audience targeting and per-user frequency. Non-empty after trimming.
</ParamField>

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.

<AccordionGroup>
  <Accordion title="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](/server/configuration).
  </Accordion>

  <Accordion title="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](/server/wrap-llm-client).
  </Accordion>

  <Accordion title="You can't use a wrapped client at all">
    For gateways like the Vercel AI SDK or a custom proxy, call [`observe()`](/server/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.
  </Accordion>
</AccordionGroup>

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

```tsx theme={null}
// Browser pass it as conversationId
<FirstflowProvider
  agentId="agt_123"
  publishableKey="pk_live_…"
  user={{ id: "user_42" }}
  conversationId={conversationId}   // e.g. "ses_abc"
>
  {children}
</FirstflowProvider>
```

```ts theme={null}
// Server pass the identical value as sessionId
await client.chat.completions.create({
  firstflowAgentId: "agt_123",
  sessionId: conversationId,        // SAME string as the browser conversationId
  userId: "user_42",
  model: "gpt-4o",
  messages,
});
```

`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](/concepts/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`](/react/identity) prop; the provider identifies the user immediately from this value without waiting for the socket. From the server, attach them with [`identify()`](/server/analytics).

```tsx theme={null}
// Browser traits travel with identity
<FirstflowProvider
  agentId="agt_123"
  publishableKey="pk_live_…"
  user={{ id: "user_42", email: "ada@example.com", traits: { plan: "pro" } }}
>
  {children}
</FirstflowProvider>
```

```ts theme={null}
// Server attach or update traits out of band
import { identify } from "@firstflow/sdk";

identify({ firstflowAgentId: "agt_123", userId: "user_42", traits: { plan: "pro" } });
```

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](/concepts/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](/mcp/quickstart). API keys remain valid for other surfaces; they are simply not accepted on MCP. See [MCP API keys](/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](/integrations/ai-providers) 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](/self-hosting/configuration).

## Still stuck?

Check the [FAQ](/faq) for product-level questions, or open an issue on [GitHub](https://github.com/firstflowdev/firstflow-oss).
