# HTTP API reference (v1)

Base URL: `https://<host>/api/v1`. JSON request and response bodies (`content-type:
application/json`) unless noted. Times are ISO 8601. Response shapes are the TypeScript types in
`packages/sdk/src/types.ts` (named in **bold** below).

## Authentication

| Caller | Header | Token |
| --- | --- | --- |
| Visitor (anonymous) | `Authorization: Bearer sbv_…` | From `POST /visitors`. Long-lived; keep in `localStorage`. |
| Visitor (identified) | `Authorization: Bearer <jwt>` | From `POST /server/visitor-tokens` (your backend). ≤ 24 h. |
| Operator (embedded) | `Authorization: Bearer <jwt>` | From `POST /server/operator-tokens` (your backend). ≤ 24 h, default 1 h. |
| Operator (hosted panel) | `Authorization: Bearer <supabase access token>` + `X-Snakkbar-Tenant: <id>` | Supabase Auth session. Operators your backend provisions can have one too: see [Panel sign-in for provisioned operators](#panel-sign-in-for-provisioned-operators). |
| Your backend | `Authorization: Bearer sk_snakkbar_…` | Secret API key from the admin. Never in a browser. |

**Origins.** Browser calls (visitor endpoints, and operator endpoints with a JWT) are rejected with
`403 origin_not_allowed` unless the `Origin` is in the tenant's allowed origins (or localhost outside
production). CORS preflight is answered for any origin; the check happens on the real request.

## Errors

```json
{ "error": { "code": "rate_limited", "message": "For mange meldinger. Vent litt og prøv igjen." } }
```

| Status | Codes |
| --- | --- |
| 400 | `bad_request` |
| 401 | `unauthorized` (missing/expired/invalid token) |
| 403 | `forbidden` (role), `origin_not_allowed` |
| 404 | `not_found` |
| 409 | `conflict` (e.g. someone else owns the conversation) |
| 413 | `payload_too_large` |
| 429 | `rate_limited` (with `Retry-After` when known) |
| 500/503 | `internal`, `unavailable` |

## Public

### `GET /tenants/:tenantId/config` → **PublicConfig**
No auth, any origin, cached 15 s. Assistant name and portrait, brand colour, launcher label,
welcome text (`{ no, en }`), suggestions, nearby/online team (`team[]` with `handle`, `name`,
`avatarUrl`, `online`, `nearby`, `greeting`), `humanAvailable`, `openNow`, `openingHours`,
`callbackEnabled`, `languages`, `nearbyMinutes`, `liveVisitors` (the tenant's live-visitor switch;
when `false`, send no activity).

### `POST /auth/sign-in-code` → `{ sent: true }`
No auth. Body `{ email }`. Password-free staff sign-in (the mobile app; `/panel/login` does the same):
mails a one-time link to the hosted panel and a code, if the address belongs to an active operator
or an existing Snakkbar login. The answer is the same either way. Sign in with the code through
Supabase Auth, `verifyOtp({ email, token, type: "email" })`. `400` for an invalid address, `429`
after 5 per hour per address or 30 per hour per IP.

## Visitor

### `POST /visitors`
Body `{ tenantId, language? }` → `{ visitorId, token }`. Creates an anonymous visitor. No auth;
origin checked; 120 per IP per 10 minutes (generous because, with live visitors on, every new
browser that opens a page gets one, and offices share an IP).

### `GET /visitor/me` → **VisitorProfile**
`{ id, externalId, verified, name, email, phone, company, attributes, language }`. `verified` is
`true` for identified visitors (token from `POST /server/visitor-tokens`).

### `PATCH /visitor/me` → **VisitorProfile**
The site tells Snakkbar who the visitor is, from the browser. Body **VisitorProfileInput**, all
fields optional:

| Field | Type | |
| --- | --- | --- |
| `name` | string ≤ 120 | |
| `email` | string ≤ 200 | Must be a valid address. |
| `phone` | string, 3–40 | Digits, spaces and `+ ( ) . / -` only. |
| `company` | string ≤ 120 | |
| `attributes` | object | Your own fields, merged into the stored ones. Keys `[A-Za-z0-9_.-]{1,40}`; values string ≤ 200, number or boolean; `null` removes the key (anonymous visitors only, see below). At most 30 keys in total. |

An empty string or `null` clears a field; a field left out is unchanged. `400 bad_request` for an
invalid email or phone, or more than 30 attributes. 20 updates per visitor per minute.

The browser is not trusted. For an **anonymous** visitor the values are stored as sent. For an
**identified** visitor, `name`, `email`, `phone` and `company` are only written while the stored
field is still empty, so what your backend sent with the token request wins (and the browser
cannot clear it). The same goes for `attributes`: the browser may add keys that don't exist yet,
but not change or remove existing ones. Staff see the profile on the
visitor and in its conversations (`visitor` and `conversation` doorbells ring), and the assistant
uses it so it doesn't ask for details it already has; it is told that browser-sent details of an
anonymous visitor are unconfirmed.

### `POST /visitor/activity` → **ActivityResult**
Live visitors: one heartbeat or live sample batch from a tab. The SDK's `startTracking()` (and so
the stock widget) sends these for you; implement it yourself only if you can't use the SDK.

| Field | Type | |
| --- | --- | --- |
| `tabId` | string, 4–40 | Random id per tab (page load), so several tabs are told apart. |
| `url` | string ≤ 4000 | `location.href`. http(s) only (`400` otherwise). Cleaned before storage, see [security.md](security.md#live-visitors). |
| `title` | string ≤ 300 | `document.title`. |
| `pageview` | boolean | `true` on the first report of a page: a load, or a URL change in a single-page app. Adds a row to the navigation history. |
| `referrer` | string ≤ 4000 | With `pageview`: `document.referrer` (or the previous URL in a single-page app). Stored as origin + path. |
| `visible` | boolean, required | `document.visibilityState === "visible"`. The visible tab owns the visitor's current page. |
| `viewport` | `{ width, height }` | CSS px. |
| `scroll` | `{ x, y }` | `scrollX`, `scrollY`. |
| `documentHeight` | integer | `document.documentElement.scrollHeight`. |
| `samples` | **PointerSample**[] ≤ 120 | Only while watched: pointer positions since the previous batch. |
| `clicks` | **PointerSample**[] ≤ 30 | Only while watched: clicks since the previous batch. |

A **PointerSample** is `[t, x, y]`: milliseconds since the batch's first sample (0–60000), and
viewport (client) coordinates in CSS px.

Response `{ watch, interval }`. `watch: true`: an operator is watching; send a batch about every
500 ms until it turns `false`. Otherwise send the next heartbeat after `interval` ms (10000).
`{ watch: false, interval: 0 }` (status 200): the tenant has live visitors off; stop sending.
600 requests per visitor per minute, shared by all its tabs.

A heartbeat after more than 30 minutes of silence starts a new session. Snakkbar then records the
user agent (device type, browser, OS), the session's referrer (only when it is another site) and,
when the hosting provides geo headers, country and city.

### `GET /visitor/conversations` → `{ conversations: VisitorConversation[] }`
This visitor's conversations, newest first (max 30).

### `POST /visitor/conversations` → **VisitorConversationDetail**
Body `{ pageUrl?, pageTitle? }`. `pageUrl` (here and on messages) is cleaned like live-visitor URLs
before it is stored ([security.md](security.md#live-visitors)).

### `GET /visitor/conversations/:id?language=no` → **VisitorConversationDetail**
Latest 100 messages, with `translation` into `language` (default: the visitor's language).

### `POST /visitor/conversations/:id/messages` → `text/event-stream`
Body:

| Field | Type | |
| --- | --- | --- |
| `body` | string ≤ 1500 | Required unless attachments are sent. |
| `clientId` | string ≤ 64 | Optional idempotency key; echoed on the stored message. Retrying with the same id never duplicates. |
| `attachmentIds` | string[] ≤ 4 | From the attachments endpoint. |
| `mentions` | string[] ≤ 3 | Operator handles the visitor tagged or tapped. |
| `pageUrl`, `pageTitle` | string | Where the visitor is now. |
| `language` | string | Visitor's language. |

The response streams **TurnEvent**s as Server-Sent Events:

```
event: message
data: {"type":"message","message":{…the stored visitor message…}}

event: assistant.start
data: {"type":"assistant.start"}

event: assistant.status
data: {"type":"assistant.status","label":"Søker etter produkter"}

event: assistant.delta
data: {"type":"assistant.delta","text":"Vi har tre "}

event: assistant.done
data: {"type":"assistant.done","messages":[{…text…},{…"kind":"products"…}]}
```

Or, when the assistant stays quiet: `silent` with `reason: "human"` (an operator owns the
conversation) or `"requested"` (the visitor asked for a person). An `error` event ends a failed
turn. The stream closes after the final event.

### `POST /visitor/conversations/:id/attachments` → **Attachment**
`multipart/form-data` with `file`: JPEG, PNG, WebP or GIF (checked by content, not extension).
Snakkbar accepts up to 5 MB, but the hosting caps request bodies at 4.5 MB, so keep uploads under
4 MB. The SDK's `upload()` re-encodes large photos to a 1600 px JPEG for you. Send the returned
`id` in `attachmentIds` within 24 hours.

### `POST /visitor/conversations/:id/read`
Body `{ through }` (time of the newest message seen) → `{ visitorReadAt }`.

### `POST /visitor/conversations/:id/typing`
Body `{ typing: boolean }` → `204`. Send at most every 2.5 s.

### `POST /visitor/conversations/:id/request-human`
Body `{ handles? }` → `{ requested: OperatorRef[], humanAvailable }`. When `humanAvailable` is
false, offer a callback.

### `POST /visitor/conversations/:id/callback`
Body `{ name, phone?, email?, message? }` (phone or email required) → `{ ok: true }`.

### `GET /visitor/stream?conversationId=` → `text/event-stream`
**RealtimeEvent**s for this visitor's conversations, plus `presence` and `watch` (an operator
started or stopped watching this visitor). See [realtime.md](realtime.md).

## Operator

### `GET /operator/me`
→ `{ operator: Operator, tenant: TenantSettings, tenants: { id, name }[] }`.

### `PATCH /operator/me` → **Operator**
Body (all optional): `{ displayName, greeting: { no, en }, language, available }`.
`available: true` = checked in.

### `POST /operator/me/avatar` → **Operator**
`multipart/form-data` `file` (JPEG/PNG/WebP ≤ 2 MB, square recommended).

### `POST /operator/heartbeat` → `204`
Keeps the operator online for 60 s. Send every 25 s while your UI is open.

### `GET /operator/team` → `{ operators: Operator[] }`

### `GET /operator/conversations?filter=all|open|mine&limit=30&cursor=`
→ `{ conversations: OperatorConversation[], nextCursor }`, newest activity first. `open` = not
closed; `mine` = you participate or were asked for.

### `GET /operator/conversations/:id?before=<messageId>` → **OperatorConversationDetail**
50 messages per page (older pages with `before`), translated into your language.

### `POST /operator/conversations/:id/actions` → **OperatorConversation**
Body `{ action }`:

| Action | Effect |
| --- | --- |
| `join` | Become a participant (and owner if there is none). Status → `human`. |
| `takeover` | Become the owner. |
| `release` | Give ownership to the next participant. If nobody else is in the conversation, it goes back to the assistant (`ai`) and you leave it. |
| `leave` | Stop participating (releases first if you own it). |
| `close` / `reopen` | Close or reopen. |

### `POST /operator/conversations/:id/messages`
Body `{ body ≤ 4000, clientId?, attachmentIds? }` → `{ message: Message, assistant: Message[] }`.
Joins automatically if nobody owns the conversation (a message that only asks the assistant does not join). Translated for the visitor before it is shown
to them. Writing `@<assistant handle>` (e.g. `@amanda`) makes the assistant answer in the
conversation; its messages are in `assistant`. Creates a pending knowledge suggestion.

### `POST /operator/conversations/:id/attachments` → **Attachment**
As for visitors.

### `POST /operator/conversations/:id/navigate` → **Message**
Body `{ url }`. Same site only (a host in `siteHosts` or an allowed origin). The visitor's widget
moves the browser there.

### `POST /operator/conversations/:id/read` → `204`
Body `{ through }`.

### `POST /operator/conversations/:id/typing` → `204`
Body `{ typing }`.

### `DELETE /operator/conversations/:id` → `204`
Admins only. Deletes messages and images.

### `GET /operator/requests`
→ `{ requests: { conversationId, at, preview }[] }`: conversations where someone asked for you in
the last 30 minutes.

### `GET /operator/stream` → `text/event-stream`
**RealtimeEvent**s for the whole tenant, including live visitors' `visitor` and `pointer` events.

The `visitor` on **OperatorConversation** is **VisitorInfo**: `id`, `externalId`, `name`, `email`,
`phone`, `company`, `attributes`, `language`.

### `GET /operator/dashboard?days=7` → **Dashboard**
Overview numbers for the last `days` days (1–90, default 7): whole days in the tenant's time zone
(`openingHours.tz`, default `Europe/Oslo`), today included. Any operator.

| Field | Meaning |
| --- | --- |
| `days`, `since`, `timeZone` | The period. |
| `onlineVisitors` | Now: visitors with a heartbeat in the last 35 s. |
| `activeConversations` | Now: not closed, a message in the last 15 minutes. |
| `waitingNow` | Now: not closed, someone asked for a person. |
| `openCallbacks` | Now: callback requests not yet handled. |
| `visitors`, `pageviews` | In the period, from the navigation history (only browsers that report live-visitor activity). |
| `conversations` | Started in the period, with at least one message. |
| `withHuman` | Of those: someone from the team wrote. The rest were handled by the assistant alone. |
| `requested` | Of those: someone asked for a person. |
| `callbacks` | Callback requests created in the period. |
| `medianResponseSeconds` | Median seconds from the first request for a person to the team's first reply after it; `null` without data. |
| `unansweredRequests` | Requests in the period without a reply from the team. |
| `perDay` | `{ date, conversations, withHuman, visitors }` per day, oldest first. |
| `byHour` | 24 numbers: conversations started per hour of day. |
| `topChatPages` | Up to 8 `{ url, title, count }`: pages conversations were started from. |
| `topPages` | Up to 8 `{ url, title, count }`: most viewed pages. |

### Live visitors
Any operator.

| Method and path | Body / result |
| --- | --- |
| `GET /operator/visitors?minutes=30` | → `{ visitors: LiveVisitor[] }`: visitors seen in the last `minutes` (1–1440, default 30) that have reported a page; online first, then most recently seen. Max 200. |
| `GET /operator/visitors/:id` | → **LiveVisitorDetail**: a **LiveVisitor** plus `pageviews` (`{ url, title, tabId, at }`, newest first, up to 200) and `conversations` (`{ id, status, createdAt, lastMessageAt, preview }`, newest first, up to 30). |
| `POST /operator/visitors/:id/watch` | Starts or renews the live feed → `{ watchedUntil }` (now + 25 s). Renew every ~10 s while your live view is open. |
| `DELETE /operator/visitors/:id/watch` | Stops the live feed → `204`. If another operator is still watching, their next renewal starts it again. |

**LiveVisitor** is **VisitorInfo** plus `verified`, `online` (heartbeat within 35 s), `visible`
(their tab is in the foreground), `firstSeenAt`, `lastSeenAt`, `sessionStartedAt`, `currentPage`
(`{ url, title }`), `sessionPageCount` (pages opened this session), `referrer` (where the session
came from), `device` (`{ type: "mobile" | "tablet" | "desktop", browser, os }`), `location`
(`{ country, city }` or `null`), `watched` (some operator is watching now) and `conversation` (the
latest one: `{ id, status, lastMessageAt, preview, requested }`, or `null`).

While a visitor is watched, their browser sends sample batches instead of heartbeats, and each
batch arrives on `GET /operator/stream` as a `pointer` event (a **LiveFrame**). See
[realtime.md](realtime.md#live-visitors) and, for showing it over the visitor's page,
[admin-embed.md](admin-embed.md#build-your-own-live-view).

### Knowledge
| Method and path | Body / result |
| --- | --- |
| `GET /operator/knowledge/documents` | → `{ documents: KnowledgeDocument[] }` |
| `POST /operator/knowledge/documents` | Three forms. **Large files (recommended):** JSON `{ filename, mimeType?, size }` → `{ document, upload: { url, method: "PUT", contentType } }`; `PUT` the raw file to `upload.url` with that `Content-Type`, then call `/ingest`. **Small files (< 4 MB):** multipart `file`. **Web page:** JSON `{ url }` (a page on the tenant's site). Types: PDF, DOCX, TXT, MD, CSV, ≤ 50 MB. Multipart and URL forms are indexed before the response. |
| `POST /operator/knowledge/documents/:id/ingest` | After a direct upload: extracts, indexes → **KnowledgeDocument** (`status` `ready` or `error`) |
| `DELETE /operator/knowledge/documents/:id` | admin → `204` |
| `GET /operator/knowledge/suggestions?status=pending` | → `{ suggestions: KnowledgeSuggestion[] }` |
| `POST /operator/knowledge/suggestions` | `{ question, answer, conversationId? }` → **KnowledgeSuggestion** |
| `PATCH /operator/knowledge/suggestions/:id` | `{ status?, question?, answer? }` → **KnowledgeSuggestion**. `approved` makes it available to the assistant. |
| `DELETE /operator/knowledge/suggestions/:id` | → `204` |

### Callbacks
`GET /operator/callbacks` → `{ callbacks: CallbackRequest[] }`;
`PATCH /operator/callbacks/:id` `{ handled }` → **CallbackRequest**.

### Notifications
The signed-in operator's own notifications ([notifications.md](notifications.md#api)).

| Method and path | Body / result |
| --- | --- |
| `GET /operator/notifications` | → **NotificationSettings** (`preferences`, `email`, `emailConfigured`, `replyByEmail`, `vapidPublicKey`, `pushSubscriptions`, `mobileDevices`) |
| `PATCH /operator/notifications` | `{ preferences: { mine?: { email, push }, … } }` → **NotificationSettings** |
| `POST /operator/notifications/push` | a browser's `PushSubscription.toJSON()` → **NotificationSettings** (`201`) |
| `DELETE /operator/notifications/push` | `{ endpoint }` → `204` |
| `PUT /operator/notifications/devices` | Mobile app: `{ installationId, pushToken, platform: "ios" \| "android" }` → **NotificationSettings**. `installationId` 8–100 chars `[A-Za-z0-9_-]`; `pushToken` `ExponentPushToken[…]`. With a Supabase login the phone is registered for every tenant the person is an active operator in. `403` while impersonating; rate-limited (20/h). |
| `DELETE /operator/notifications/devices/:installationId` | → `204`; removes the installation for the person's (or the token's) operators |
| `POST /operator/notifications/test` | `{ channel: "push" \| "email" }` → `{ sent }` |

### Tenant settings (admin)
| Method and path | |
| --- | --- |
| `GET /operator/tenant` | → **TenantSettings** |
| `PATCH /operator/tenant` | any **TenantSettings** fields except `id`, `assistantAvatarUrl` → **TenantSettings**. Includes `liveVisitors` (default `true`); off, browsers send no activity and `POST /visitor/activity` answers `interval: 0`. |
| `POST /operator/tenant/assistant-avatar` | multipart `file` → **TenantSettings** |
| `POST /operator/tenant/assistant-avatar/generate` | AI-drawn mascot inspired by the business (never a human; max 5 drawings per day), applied at once → **TenantSettings** |
| `POST /operator/tenant/assistant-avatar/draft` | draws a candidate mascot without applying it (counts towards the 5 per day) → `{ url }` |
| `POST /operator/tenant/assistant-avatar/accept` | `{ url }` of a candidate → puts it on the chat at once, removes the old avatar → **TenantSettings** |
| `POST /operator/tenant/assistant-avatar/discard` | `{ url }` of a candidate → deletes it, the current avatar stays → `204` |
| `GET /operator/api-keys` | → `{ apiKeys: ApiKey[] }` |
| `POST /operator/api-keys` | `{ name }` → `{ apiKey, key }` — `key` is shown only here |
| `DELETE /operator/api-keys/:id` | revoke → `204` |

## Server-to-server (secret API key)

### `POST /server/operator-tokens`
Body `{ externalId, name, email?, avatarUrl?, role?: "agent" | "admin", ttlSeconds? }` →
`{ token, expiresAt, operator: Operator }`. Creates the operator on first use, updates name, email,
avatar and role afterwards. `403 forbidden` for a deactivated operator (only `PUT` reactivates).

### `GET /server/operators` → `{ operators: ServerOperator[] }`
Every operator your backend created (those with an `externalId`), deactivated ones included.
`ServerOperator` is **Operator** plus `deactivatedAt: string | null`, `system: boolean` and
`panelLogin: boolean` (linked to a sign-in to the hosted panel). Use it to reconcile.

### `GET /server/operators/:externalId` → **ServerOperator**
`404` if unknown.

### `PUT /server/operators/:externalId` → **ServerOperator**
Body `{ name, email?, avatarUrl?, role?: "agent" | "admin", system?: boolean, invite?: boolean }`
(`email`/`avatarUrl` may be `null` to clear). Creates (`201`) or updates (`200`) the operator without
issuing a token, and reactivates a deactivated one. Idempotent.

- `role` is yours: it cannot be changed in Snakkbar's panels for an operator with an `externalId`.
- `system: true` marks an integration account (see [Departments](#departments)); only `PUT` sets it.
- `invite: true` also e-mails the operator a sign-in link to the hosted panel, unless they already
  have a panel login. A new row, a reactivation and a changed `email` never have one, so sending
  `invite: true` on those is enough. The response then carries `invited: boolean`; `false` (no
  e-mail, rate limit, mail not configured) leaves the operator saved, retry with `POST …/invite`.
- A changed `email` (trimmed, case-insensitive) unlinks the panel login, and so does a
  reactivation: the address has to sign in again. An old work address never keeps access.

### `POST /server/operators/:externalId/invite` → `{ sent: true }`
E-mails the operator a one-time sign-in link to the hosted panel, opened on your tenant
(`/panel?tenant=<id>`). No password is involved. `404` unknown operator, `403` deactivated,
`409 conflict` no e-mail, `429` after 5 invites per operator per hour, `503` if mail could not be
sent.

### Panel sign-in for provisioned operators
An operator your backend provisions can answer chats in the hosted panel (`/panel`) and the mobile
app as well as in your own embedded admin. Both are the **same operator row**: same `id`, history,
departments and notification settings; tokens from `POST /server/operator-tokens` keep working.

1. Your backend provisions the operator with their work e-mail, and `invite: true` (or calls
   `POST …/invite`). They can also go to `/panel/login` and ask for a link or a code themselves.
2. The first sign-in with that **verified** e-mail links the operator to the person's Snakkbar
   login. From then on the link counts, not the address. One login can be linked in several
   tenants (the panel's tenant switcher), at most one operator per tenant; a conflict is logged
   and skipped.
3. Your backend stays in charge. `DELETE` ends panel access at once (the next request answers
   `401`/`403` and the tenant leaves the switcher). Changing `email` or reactivating unlinks the
   login; only a sign-in after the change links it again.

### `DELETE /server/operators/:externalId` → `204`
Deactivates: tokens and panel sessions stop working at once, the operator disappears from the
team, @-mentions and hand-off, leaves every open conversation (ownership passes on, or back to the
assistant), their e-mail is cleared and their panel login unlinked. Name and avatar remain on past messages. `204` also for unknown or already
deactivated ids.

### `POST /server/visitor-tokens`
Body `{ externalId, name?, email?, phone?, company?, attributes?, ttlSeconds? }` →
`{ token, expiresAt, visitorId }`. Creates the visitor for `externalId` on first use. The profile
fields follow the rules of `PATCH /visitor/me`, but come from your backend and are trusted: they
always overwrite what is stored, including values the browser filled in. A field left out is
unchanged; `null` clears it; `attributes` are merged (`null` removes a key). Minting a token does
not make the visitor appear online.

### Departments
The same departments admins manage in Settings → Avdelinger ([operator-tools.md](operator-tools.md#routing-departments)),
for keeping them in sync from your backend. **ServerDepartment** is **Department** (`id`, `name`,
`description`, `memberIds`, `urlRules`, `roundRobin`) plus `memberExternalIds`: the `externalId`s
of the members that have one.

| Method and path | Body / result |
| --- | --- |
| `GET /server/departments` | → `{ departments: ServerDepartment[] }`, in display order |
| `GET /server/departments/:id` | → **ServerDepartment**; `404` if unknown |
| `POST /server/departments` | `{ name, description?, memberIds?, memberExternalIds?, urlRules?, roundRobin? }` → **ServerDepartment** (`201`). At most 50 departments; `409` if the name is taken. |
| `PATCH /server/departments/:id` | any of the same fields → **ServerDepartment**. Giving `memberIds` and/or `memberExternalIds` replaces the members; members who stay keep their place in the rotation. |
| `DELETE /server/departments/:id` | → `204`; its conversations stay, without a department. Idempotent. |

Fields: `name` 1–60 chars, `description` up to 300, `urlRules` up to 30 rules of up to 200 chars,
`memberIds` (operator ids) and `memberExternalIds` up to 200 each. When both member lists are given
the members are their union. An unknown, deactivated or system operator in either list is `400`.

System operators (`system: true`, integration accounts) are not part of the team: they are not
shown in the team list, @-mentions, round-robin and requests for a person, alerts or the
dashboard, and cannot be department members. They still sign in and act, and their name stays on
what they wrote.

## Rate limits

| Limit | Value |
| --- | --- |
| Visitor messages per conversation | 8 per 10 min |
| Visitor messages per IP | 30 per 10 min, 200 per day |
| Assistant turns per tenant | 2000 per day (then the turn ends with a `rate_limited` error event) |
| New visitors per IP | 120 per 10 min |
| Visitor profile updates | 20 per minute per visitor |
| Visitor activity (heartbeats, live batches) | 600 per minute per visitor (all tabs together) |
| New conversations per visitor | 30 per hour |
| Visitor uploads per IP | 12 per 10 min |
| Requests for a person per conversation | 10 per 10 min |
| Panel sign-in invites (`POST /server/operators/:externalId/invite`, `invite: true`) | 5 per hour per operator |
| Sign-in links and codes (`/panel/login`, `POST /auth/sign-in-code`) | 5 per hour per address, 30 per hour per IP |
| Callback requests per visitor | 5 per hour |
| Operator messages | 60 per minute per operator |
| Operator uploads | 30 per 10 min per operator |
| AI mascot drawing | 5 per tenant per day |
