# Operator tools

Tools for the team in the inbox: **saved replies**, **internal notes**, **tags**, **search** and
**routing** to departments. All of them are in the stock admin, in the SDK's `OperatorClient` and
in the HTTP API (`/api/v1/operator/*`, operator token or hosted login).

## Saved replies (hurtigtekster)

Reusable text an operator inserts from the composer: type `/` (at the start or after a space)
and a few letters, or use the bolt button. ↑↓ choose, Enter or Tab inserts, Escape closes. The
picker matches the shortcut first, then the title, then the text.

- **Shared** (`scope: "team"`): everyone uses them, admins manage them.
- **Personal** (`scope: "personal"`): only the operator who made them.
- `shortcut` (optional): what to type after `/`, e.g. `pris`. Lowercase letters, digits, `-`, `_`.

Placeholders are filled in for the conversation when inserted. A placeholder without a value
disappears with the space before it (`Hei {visitor.firstName}!` → `Hei!`); unknown ones stay as
written.

| Placeholder | Value |
| --- | --- |
| `{visitor.name}`, `{visitor.firstName}` | The visitor's name (profile) |
| `{visitor.email}`, `{visitor.phone}`, `{visitor.company}` | From the visitor's profile |
| `{operator.name}`, `{operator.firstName}` | The operator inserting it |
| `{assistant.name}` | The assistant's name |
| `{company}` | The tenant's name |

```ts
await operator.savedReplies.create({ title: "Pris på service", body: "Hei {visitor.firstName}! …", shortcut: "service", scope: "team" });
const replies = await operator.savedReplies.list(); // shared + mine
```

| Method | Endpoint | Who |
| --- | --- | --- |
| `savedReplies.list()` | `GET /operator/saved-replies` | Everyone (shared + own) |
| `savedReplies.create(input)` | `POST /operator/saved-replies` | Everyone (`team` needs admin) |
| `savedReplies.update(id, input)` | `PATCH /operator/saved-replies/:id` | Owner; admins for shared |
| `savedReplies.remove(id)` | `DELETE /operator/saved-replies/:id` | Owner; admins for shared |

A shortcut is unique per owner (and among shared replies): a clash answers `409 conflict`.

## Internal notes

A note is a message only the team sees: `kind: "note"`, `role: "operator"`. In the admin, the
«Notat» switch in the composer turns it amber; notes show amber and dashed in the transcript. On a
conversation someone else owns, or a closed one, «Skriv notat» writes a note without joining.

```ts
await operator.send(conversationId, { body: "Avtalt pris: 38 900 kr. @ola sjekk montør torsdag", kind: "note" });
```

A note does **not** join the conversation, clear a waiting request, get translated, ask the
assistant, or become a knowledge suggestion, and the composer sends no typing signal while
writing one. `@handle` (or `@Name`) mentions of colleagues are stored in `mentionedOperators` and:

- show up for them in `GET /operator/requests` (`kind: "mention"`, with `by` and `note`) until they
  have read the conversation, which the stock admin shows as a card and an alert;
- emit a `note.mentioned` event (`{ conversationId, messageId, operatorIds, by }`).

**Never reaches the visitor.** Notes are left out of the visitor's conversation (`GET
/visitor/conversations/:id` and anything serialized for a visitor), the visitor stream (the
`message` doorbell carries `kind`, and the visitor stream drops notes), previews, unread counts and
message counts, translations for the visitor, and the assistant's context (the history
instructions skip them). They are staff-side data in webhooks (`message.created` with `kind:
"note"`). Anything new that sends transcripts or messages to visitors (e-mail, exports) must
filter `kind = 'note'` too.

## Tags

Tenant tags with a name and a colour from a small palette: `gray`, `red`, `orange`, `amber`,
`green`, `teal`, `blue`, `violet`, `pink`. Admins manage them in Settings; anyone assigns them
under the tabs of an open conversation, and filters the inbox by them.

```ts
const tags = await operator.tags.list();
await operator.updateConversation(conversationId, { tagIds: [tags[0].id] }); // replaces the set
const tagged = await operator.listConversations({ tag: tags[0].id });
```

`OperatorConversation.tags` lists a conversation's tags. Endpoints: `GET/POST /operator/tags`,
`PATCH/DELETE /operator/tags/:id` (writes need admin), `PATCH /operator/conversations/:id` with
`{ tagIds }`.

## Search

`GET /operator/search?q=…` (`operator.search(query, { tag, department, cursor, limit })`) finds
conversations by:

- message text and notes (Norwegian full text: `kompressor` finds «kompressoren», «kompressorer»),
- translations of messages (so a Norwegian word finds what a visitor wrote in English),
- the visitor's name, e-mail or phone (digits match however the number is formatted),
- tag names.

One result per conversation, its best hit first (visitor field, then message or note, then
translation, then tag), newest activity first, 20 per page (`limit` up to 50, `nextCursor` for
more). Each result has the conversation and `match: { field, messageId, snippet, highlights }`;
`highlights` are `[start, end)` ranges in `snippet`. The stock admin searches as you type, shows
the snippets with the matches marked, and opens a hit scrolled to its message (loading older
messages when needed). Notes are found by staff search; it is an operator endpoint.

## Routing: departments

Departments (teams) such as «Salg» and «Service», each with members, a description, URL rules and
an optional round-robin. Admins manage them in Settings → Avdelinger.

A conversation gets a department (`OperatorConversation.department`) in three ways:

1. **URL rules** on the page the chat starts on (or the first message's page, if the chat was
   created without one). `/service` matches that page and everything below it (not
   `/servicetorget`), `*` matches anything (`/produkter/*/deler`), and a pattern without a leading
   `/` includes the host (`shop.amatec.no`). The most specific (longest) matching rule across all
   departments wins. A rule never overrides a department the assistant or an operator chose.
2. **The assistant's handoff.** When the tenant has departments, the assistant sees them (name and
   description) in its instructions, and `overfor_til_menneske` takes an optional `avdeling`
   (department name). It does not override an operator's choice.
3. **An operator moves it** from the chip under the tabs (`PATCH /operator/conversations/:id` with
   `{ departmentId }`, `null` for none). If the conversation is still waiting for a person, the new
   department is asked.

Each change emits `conversation.routed` (`{ conversationId, departmentId, by, operatorId }`).

### Who is asked for a person

`requestPeople` (a visitor asks for a person, or the assistant hands over or @-mentions someone) picks, in order: the
named people; the conversation's owner; the last operator who wrote in it; **the department's
members** (round-robin when on, else those nearby, else all of them); nearby operators; the whole
team (max 5).

### Round-robin

With round-robin on, a request that names nobody makes the **next available member** the owner:
of the members online and checked in, the one assigned longest ago (never assigned first). The
turn is claimed with a compare-and-set, so two requests at once do not pick the same person. The
owner sees the conversation as theirs («Mine») right away; the visitor sees «Kari har fått beskjed.»
and Kari as being in the conversation once she joins. Nobody available: the usual request (nearby
members, else all members). Emits `conversation.assigned`.

### Inbox filters

The inbox filter menu has status (Alle, Åpne, Mine), department (or «Ingen avdeling») and tag.
«Mine» includes conversations round-robin made you the owner of. The same filters are query
parameters of `GET /operator/conversations`: `filter`, `department` (an id, or `none`), `tag`.

| Method | Endpoint | Who |
| --- | --- | --- |
| `departments.list()` | `GET /operator/departments` | Everyone |
| `departments.create(input)` | `POST /operator/departments` | Admins |
| `departments.update(id, input)` | `PATCH /operator/departments/:id` (`memberIds` replaces the members; members who stay keep their place in the rotation) | Admins |
| `departments.remove(id)` | `DELETE /operator/departments/:id` (its conversations stay, without a department) | Admins |

Your backend can manage the same departments with a secret API key, naming members by `externalId`: `/server/departments` ([api.md](api.md#departments)).

## Data model

Migration `supabase/migrations/20261017130000_operator_tools.sql`: `saved_replies`, `tags`,
`conversation_tags`, `departments`, `department_members`, `conversations.department_id` and
`department_by`, GIN indexes for full text on `messages.body` (Norwegian) and
`message_translations.body` (simple), the `snakkbar_search` function, a `kind` on the message
doorbell, and a doorbell for tag changes. Pure logic (URL rules, round-robin, snippets, the
composer's `/` and placeholders) is tested with `npm run test:tools`.
