DokumentasjonVerktøy for teametMarkdown
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 |
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.
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", withbyandnote) until they have read the conversation, which the stock admin shows as a card and an alert; - emit a
note.mentionedevent ({ 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.
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:
kompressorfinds «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:
- URL rules on the page the chat starts on (or the first message's page, if the chat was
created without one).
/servicematches 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. - The assistant's handoff. When the tenant has departments, the assistant sees them (name and
description) in its instructions, and
overfor_til_mennesketakes an optionalavdeling(department name). It does not override an operator's choice. - An operator moves it from the chip under the tabs (
PATCH /operator/conversations/:idwith{ departmentId },nullfor 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).
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.