# Co-browsing: guide a visitor on their own page

While an operator chats with a visitor, they can see the visitor's screen (the live view) and ask
to **guide** them. Once the visitor says yes, the visitor sees the operator's cursor on their own
page — an arrow with the operator's name and picture, like in Teams — and the operator can
click links there for them. The visitor can stop it at any time.

It builds on [live visitors](concepts.md): the tenant's `liveVisitors` switch must be on and the
widget (or `startTracking()`) must run on the page.

## How it looks

**Operator (stock admin and hosted panel).** In the inbox, the eye button in a conversation's
tab bar (or «Se kundens skjerm» in the visitor panel) opens the visitor's screen beside the chat.
Under the screen:

1. **«Vis kunden rundt»** asks the visitor. The bar says «Venter på svar fra kunden …» with
   «Avbryt».
2. When the visitor says yes, the screen gets an accent frame and the bar says «Du viser kunden
   rundt». Moving the mouse over the screen moves your cursor on the visitor's page; clicking
   shows a ripple there, and a click on a link opens it for the visitor. The visitor's own cursor
   is tagged «Kunden», so both of you can tell the cursors apart.
3. «Avslutt», closing the screen, or the visitor's «Stopp» ends it. The live view on the Visitors
   page has the same bar.

**Visitor (stock widget).** A card at the top of the page: «Kari vil vise deg rundt — Du ser
markøren til Kari på skjermen din, og Kari kan klikke på lenker for deg. Du kan stoppe når som
helst.» with «Tillat» and «Nei takk». After a yes, a bar «Kari viser deg rundt · Stopp» stays at
the top, and Kari's cursor (accent colour, name tag with picture) moves over the page, a moment
behind the operator. It carries on to the next page when a link is followed. Nothing changes on the
page before the visitor says yes.

## What the operator can do

- **Point.** The cursor is drawn over the visitor's page. Positions are in the visitor's document
  (scroll included), so they land on the same spot even if the visitor has scrolled since.
- **Click links.** A click follows the `<a href>` under the point if it is `http(s)`, the same way
  as the visitor clicking it (the site's own router and handlers run). A link that would open a
  new tab opens in the same tab, so the guide comes along. Anything else (buttons, form fields,
  text) only gets the ripple: the operator cannot type, submit forms or press buttons for the
  visitor.
- **Not:** scroll the visitor's page, read what they type, or see anything beyond what the live
  view already shows ([security.md](security.md#live-visitors)).

## Lifecycle

| State | Means | Ends when |
| --- | --- | --- |
| `requested` | The operator asked; the visitor's widget shows the card. | The visitor answers, the operator cancels, 90 s pass, or the watch ends. |
| `active` | The visitor said yes. | Either side stops it, or the watch ends (the operator closed the live view or lost the connection: ≤ 25 s). |

One session per visitor: while a colleague is guiding, a request fails with `busy`. Requests
need the visitor to be online (`offline` otherwise). Any operator of the tenant can end a session.

## SDK

Operator side (`createOperatorClient`):

```ts
// Ask (also starts watching if nobody does). The answer arrives as a `cobrowse` event.
const session = await client.visitors.cobrowse.request(visitorId, { conversationId });

// While active: your cursor, in the visitor's document coordinates, every ~150 ms while it moves.
await client.visitors.cobrowse.pointer(visitorId, {
  url: frame.url,                         // the page you are looking at
  samples: [[0, 410, 1290], [40, 418, 1302]], // [ms into the batch, pageX, pageY]
  clicks: [[40, 418, 1302]],              // a click: ripple, and a link there is followed
});
await client.visitors.cobrowse.pointer(visitorId, { url, samples: [], clicks: [], away: true }); // left the screen

await client.visitors.cobrowse.end(visitorId);

client.subscribe((event) => {
  if (event.type === "cobrowse" && event.visitorId === visitorId) {
    // event.session: CobrowseSession | null; event.reason: "declined" | "ended" | "expired"; event.by
  }
});
```

`pointer` fails with `not_active` once the visitor has stopped it. `LiveVisitor.cobrowse` has the
current session when you load the visitor list.

Visitor side (`createVisitorClient`, with `startTracking()` running; the stock widget does all of
this):

```ts
const stop = client.cobrowse.subscribe({
  onSession(session, reason) {
    // null: over. requested: ask the visitor. active: show «X viser deg rundt · Stopp».
  },
  onPointer(batch) {
    // Ignore batches whose `url` is another page; draw samples at (pageX - scrollX, pageY - scrollY);
    // follow http(s) links under clicks.
  },
});
await client.cobrowse.answer(true);  // or false
await client.cobrowse.end();         // «Stopp»
```

Requests reach the visitor's browser with the next heartbeat (≤ 500 ms while watched, which a
request always is) or at once on an open visitor stream. While a session is on, the SDK keeps its
own visitor stream open for the cursor batches.

## HTTP API

| Method and path | Body / result |
| --- | --- |
| `POST /operator/visitors/:id/cobrowse` | `{ conversationId? }` → `{ cobrowse: CobrowseSession }`. `409 offline` / `409 busy`. |
| `DELETE /operator/visitors/:id/cobrowse` | Cancels or stops → `204`. |
| `POST /operator/visitors/:id/cobrowse/pointer` | **CobrowsePointerInput** `{ url, samples ≤ 60, clicks ≤ 10, away? }` → `204`. `409 not_active`. |
| `POST /visitor/cobrowse` | `{ accept: boolean }` → `{ cobrowse: CobrowseSession \| null }`. `409 expired` when the request is gone. |
| `DELETE /visitor/cobrowse` | The visitor stops (or turns down) → `204`. |

`POST /visitor/activity` answers `cobrowse: CobrowseSession | null` too.

**CobrowseSession**: `{ state: "requested" | "active", operator: OperatorRef, conversationId,
since }`.

Realtime ([realtime.md](realtime.md)): `cobrowse` (`visitorId`, `session`, `reason?`, `by?`) on
both streams; `cobrowse.pointer` (`visitorId`, `operatorId`, `url`, `samples`, `clicks`, `away?`)
on the visitor stream only.
