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

# Intent & sentiment

> Classify a conversation's intent with an LLM (with hysteresis), or score sentiment lexically with no LLM call.

The server SDK ships two classifiers: an LLM-backed **intent** classifier with hysteresis, and a free **lexical sentiment** scorer. Both are functions you call from your own routes you decide the branches, when to run them, and what to do with the result.

## Intent classification

```ts theme={null}
const result = await ff.classifyIntent({
  conversationId,                       // required
  llm: ai,                              // a wrapped OpenAI/Anthropic client
  branches: [                           // required, ≥ 2
    { id: "billing", label: "Billing or pricing questions" },
    { id: "support", label: "Bug report or technical help" },
    { id: "sales",   label: "Pre-sales / feature interest" },
  ],
  confidenceFloor: 0.6,                 // optional skip writing below this
  alsoSetIntentColumn: true,            // optional also write the .intent column
  stickiness: { minDwellMs: 30_000 },   // optional hysteresis overrides
  model: "claude-haiku-4-5",            // optional
  windowSize: 10,                       // optional recent messages to feed in
});

// { effectiveIntent, effectiveConfidence, switched, raw }
```

It reads recent messages from `firstflow_conversation_messages`, builds a forced-choice prompt over your branches, parses the chosen label into a confidence, reconciles against the conversation's sticky state, and writes the result to `firstflow_conversations.state` (and the `intent` column if `alsoSetIntentColumn`). Pass `recentMessages` directly to skip the DB read.

### Hysteresis

A raw per-message classification would flip-flop. The reconciler holds the active intent unless a new one is clearly better:

| Setting               | Default     | Effect                                                       |
| --------------------- | ----------- | ------------------------------------------------------------ |
| `switchMargin`        | `0.15`      | New confidence must beat the active by this much to switch   |
| `minSwitchConfidence` | `0.75`      | …or clear this absolute bar alone                            |
| `minDwellMs`          | `0`         | Don't switch until the active intent has been held this long |
| `intentTtlMs`         | `1_800_000` | Drop the active intent if idle this long (30 min)            |

### Using intent in a flow

Step branches read `traits`, `user`, `session`, and `answers` not the classifier directly. Bridge it as a trait:

```ts theme={null}
const intent = await ff.classifyIntent({ conversationId, llm: ai, branches });
return Response.json({ reply, currentIntent: intent.effectiveIntent });
```

```tsx theme={null}
<FirstflowProvider user={{ id, traits: { intent: currentIntent ?? "unknown" } }} … />
```

### Cost

Roughly **\$0.0003 per call** with `claude-haiku-4-5` over a 10-message window. Run it every turn, every Nth turn, or on demand your choice. Set `confidenceFloor` to skip writes when uncertain.

<Note>
  Intent and `state` persistence requires migration
  `0007_conversation_signals.sql`. If `classifyIntent` returns a value but the
  conversation row never updates, apply it. See [Set up Supabase](/self-hosting/supabase-cloud).
</Note>

## Sentiment (no LLM call)

`classifySentiment` is a pure lexical scorer over the user's messages free and synchronous:

```ts theme={null}
const result = ff.classifySentiment(userMessages); // { sentiment, score, ... }
await ff.recordSentiment({ conversationId, ...result }); // persist to the conversation
```

`sentiment` is `positive | neutral | negative`; `score` ranges roughly -1..1. It needs no model, so it runs anywhere with no cost.

## Building "classify → trigger an experience"

Compose the classifier with the client's `emit`: classify on the server, return an event name, and `emit` it on the client to fire a matching `custom_event` experience.

```ts theme={null}
const intent = await ff.classifyIntent({ conversationId, llm: ai, branches, confidenceFloor: 0.7 });
const fireEvent = intent.effectiveIntent === "billing" ? "show_billing_help" : null;
return Response.json({ reply, fireEvent });
```

```tsx theme={null}
const { emit } = useFirstflow();
if (data.fireEvent) emit(data.fireEvent);
```

## Next

Attach manual [outcomes & signals](/server/outcomes), or review [what gets recorded](/server/observe).
