Hopp til innholdet

DokumentasjonProaktiv chatMarkdown

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 to be on (see 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):

FieldMeaning
conditions.urlcontains (anywhere in the full URL) or starts_with (a path when it starts with /, else the full URL); case-insensitive
conditions.secondsOnPageat least this many seconds with the page in the foreground
conditions.pagesInSessionat least this many pages in this visit (30 minutes without a page ends a visit)
conditions.devicesmobile / tablet / desktop; empty = all (same user-agent rules as the server)
conditions.teamany, online (someone from the team is online), offline (nobody is)
fromassistant (name and portrait) or team (the company name, with the faces of who is around)
message, repliestext and up to three quick replies per language (no, en, …)
frequencyDaysat 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.