# Proactive chat

Snakkbar can start the conversation instead of waiting for the visitor:

- **"Si hei"**: an operator writes first to someone who is on the site right now (Visitors view).
- **Automatic triggers**: tenant admins set up rules ("after 20 seconds on the pricing page,
  show …"); the widget shows a teaser by itself when a visitor matches.

Both appear as a calm **teaser** next to the chat button: who writes, what they say, a × to dismiss,
and (for triggers) up to three quick replies. Tapping it opens the chat. Both need
[live visitors](./sdk.md) to be on (see [Live visitors off](#live-visitors-off)).

## The operator writes first

`POST /api/v1/operator/visitors/:id/conversations` with `{ body, clientId? }` (SDK:
`operator.visitors.startConversation(visitorId, { body })`):

- If the visitor has an open conversation that was active in the last 24 hours, the message goes
  there and the operator joins it (the usual "joined" notice).
- Otherwise a new conversation starts: status `human`, owned by the operator, the operator as its
  only participant (no "joined" notice above the first message), and the visitor's current page as
  its page. `conversation.created` is emitted with `by: "operator"`.
- The message is translated into the visitor's language before it is stored, as in the inbox.
  `clientId` makes retries safe.

Returns `{ conversation: OperatorConversation, message, created }` (201 when created). The stock
admin opens the conversation in the inbox right after sending.

### How the visitor's browser finds out

The visitor's realtime stream is only open while the chat is open (or a conversation is active),
so the signal rides on the live-visitor heartbeat instead. Every heartbeat response
(`POST /visitor/activity`, every ~10 s) carries:

```json
{ "watch": false, "interval": 10000,
  "unseen": { "conversationId": "…", "count": 1, "message": { "role": "operator", "body": "Hei! …", "author": { "name": "Kari", "avatarUrl": "…" }, … } } }
```

`unseen` is the newest staff (operator) message in the visitor's open conversation from the last
24 hours that came after the visitor's read marker (`visitor_read_at`), with its stored translation
into the visitor's language. `null` = nothing unread. While an operator watches the live view the
tracker sends sample batches every 500 ms; those only check `unseen` with the presence write
(~every 8 s), so `unseen` may be absent (= "not checked") on a batch.

Read state stays the single source of truth: the teaser disappears once the visitor opens the
conversation, because the widget then moves the read marker (`POST /visitor/conversations/:id/read`)
and the next heartbeat says `null`. The widget now sends the read marker whenever the conversation
has staff messages, not only while a person is a participant, so a teaser can never get stuck after
the operator left. The "Sett"/"Seen" receipt staff see therefore still means the visitor opened the
chat; dismissing a teaser does not mark anything read.

## Automatic triggers

Admins manage rules under **Innstillinger → Proaktiv chat** (stored on the tenant as
`TenantSettings.triggers`, `PATCH /operator/tenant { triggers }`, max 20). A rule
(`ProactiveTrigger` in `packages/sdk/src/types.ts`):

| Field | Meaning |
| --- | --- |
| `conditions.url` | `contains` (anywhere in the full URL) or `starts_with` (a path when it starts with `/`, else the full URL); case-insensitive |
| `conditions.secondsOnPage` | at least this many seconds with the page in the foreground |
| `conditions.pagesInSession` | at least this many pages in this visit (30 minutes without a page ends a visit) |
| `conditions.devices` | `mobile` / `tablet` / `desktop`; empty = all (same user-agent rules as the server) |
| `conditions.team` | `any`, `online` (someone from the team is online), `offline` (nobody is) |
| `from` | `assistant` (name and portrait) or `team` (the company name, with the faces of who is around) |
| `message`, `replies` | text and up to three quick replies per language (`no`, `en`, …) |
| `frequencyDays` | at most once per visitor per this many days; `0` = once per visit |

Every condition that is set must hold. Rules are tried in order and the first match wins. On top of
the cap: at most one trigger teaser per page view, none while the chat is open or the visitor is in
a conversation (an active conversation from the last hour), none in a background tab, and after the
visitor dismisses one, no more triggers for the rest of that visit.

### Where they are evaluated, and why

In the browser, by the SDK (`packages/sdk/src/proactive.ts`, `VisitorClient.startProactive`), not on
the server and not in the stock widget only:

- The conditions are about this tab (foreground time, page changes in a single-page app, device);
  the browser knows them without extra requests, the server would need a heartbeat per second.
- Sites with their own chat UI (headless) get the exact same behaviour: `startProactive` hands them
  `Teaser` objects to render however they like.
- Team presence comes from the public config (cached 15 s) and is only fetched once a rule is
  otherwise ready to show.

The public config (`PublicConfig.triggers`) carries enabled rules only, without the staff-facing
name.

The frequency cap and the "dismissed this visit" flag live in the client's storage (localStorage by
default) under `snakkbar:<tenant>:proactive`, together with the newest staff message the visitor
dismissed or opened (so a dismissed "Kari wrote …" does not come back after a reload; a newer
message does).

### Replying keeps the context

When the visitor taps a trigger teaser, the chat opens as a new conversation that starts with the
teaser text (and its quick replies as chips). Nothing is stored yet. On the visitor's first message
(or a tapped quick reply) the widget calls `createConversation({ triggerId })`; the server looks the
rule up and stores **its own** text (never text from the browser) as the first message: role
`assistant`, `meta.proactive = { triggerId, from }`, exposed as `Message.proactive`. A team teaser
is serialized with the company name and no portrait, and the stock widget shows it as a person.

The assistant sees it: eve never produced that message, so the history instruction
(`agent/instructions/menneske.ts`) includes proactive teasers as "automatic message shown to the
visitor before they wrote" and asks for a natural continuation instead of a fresh greeting.

### Counters

`proactive_trigger_stats` keeps totals per rule:

- `shown`: the widget showed the teaser (`POST /visitor/triggers/:id { event: "shown" }`),
- `opened`: the visitor tapped it, or opened the chat while it showed (`event: "opened"`),
- `replied`: a conversation was created from it (counted by `POST /visitor/conversations`).

The browser reports `shown`/`opened` once per teaser (rate limited per visitor and rule). Deleting a
rule deletes its counters. `GET /operator/triggers/stats` (admins) returns
`{ stats: { [triggerId]: { shown, opened, replied } } }`; the settings list shows them per rule.

## SDK (headless)

```ts
const client = createVisitorClient({ host, tenantId });
client.startTracking(); // brings `unseen` with the heartbeats
const proactive = client.startProactive({
  onTeaser(teaser) {
    if (!teaser) return hideTeaser();
    if (teaser.kind === "message") showTeaser(teaser.message.author?.name, teaser.message.translation?.body ?? teaser.message.body);
    else showTeaser(teaser.trigger.from, teaser.body, teaser.replies);
  },
  onUnseen(unseen) { setBadge(unseen?.count ?? 0); },
});
// The visitor tapped it:            proactive.open()    (then open the chat; for a trigger,
//                                                        createConversation({ triggerId }) on the first message)
// The visitor closed it:            proactive.dismiss()
// Your chat is open / mid-chat:     proactive.pause(true)
// You heard a doorbell for a staff message: proactive.refresh() (fetch `unseen` now)
```

The pure helpers are exported for custom rule handling: `triggerMatches`, `urlMatches`,
`capAllows`, `teaserFor`, `pickLanguage`, `deviceFromUserAgent`.

## Live visitors off

`liveVisitors: false` means the widget stays quiet until the visitor opens it. So:

- no heartbeats, hence no `unseen` and no "Kari wrote …" teaser (operators cannot see those
  visitors in the Visitors view anyway, so they cannot say hi);
- `PublicConfig.triggers` is always empty and the SDK ignores triggers, so no automatic teasers,
  no counter requests and no anonymous visitor is created for them.

Messages into a conversation the visitor already has still reach them the usual way (realtime and
the unread badge while that conversation is active).

## Operator live view (mirror)

Inside the live view's iframe (`name="snakkbar-mirror"`) the widget renders nothing and
`startProactive` is inert: no teasers, no counters.

## Data and retention

- `tenants.proactive_triggers` (jsonb array) and `proactive_trigger_stats` (one row per rule;
  removed with the tenant or the rule). Neither holds visitor data, so `snakkbar_cleanup` needs no
  change.
- Teaser messages are ordinary messages and follow the conversation's retention.

Migration: `supabase/migrations/20261017100000_proactive_chat.sql`. No new environment variables.
