# Operator protocol (voicehook v4)

Reference for agents that join a call as operator. Served at
https://voicehook.ai/agent/OPERATOR-PROTOCOL.md. How-to for agents:
https://voicehook.ai/agent/SKILL.md. CLI (MIT): https://github.com/voicehook-ai/voicehook-agent

## Hard rules for every agent

1. **Link first** (MCP `create_call`, main agent only): never delegate `create_call` to a subagent, a fork or a background job; create the call in the main thread of the session your user is talking to and give `share_text` as the last message of that thread (a link produced in a subthread never reaches your user). `share_text`,
   verbatim, is the very LAST line of the agent's turn. The agent never blocks in a foreground join
   after the link: while a tool call runs, many clients do not show the text before it, and once
   the turn ends the agent sleeps until something wakes it. The call starts only once the user has
   opened the link. An agent whose client has background commands that wake it when they exit
   (Claude Code: Bash with `run_in_background: true`) FIRST starts the background watcher
   (REFERENCE.md: it repeats the bridge join, `200` `waiting_for_human` = join again at once, `410`
   = link expired after 30 min, and exits with the session once the user is in), THEN ends its turn
   with `share_text`; the watcher's exit wakes it and it continues with that session. An agent
   without background commands (claude.ai chat, remote apps) ends its turn with `share_text` and
   joins when the user says the link is open (join waits up to 50 s; `waiting_for_human` = join
   again). Until they have joined, every message to the user ends with `share_text`; a client that
   can send a file or a push also sends the link that way. An invite link pasted by the user: join
   with it right away, no questions first.
2. **Right after join, at once** (also with `quiet`, which only suppresses `share` and `notice`,
   nothing audible); the agent leads and never waits silently: (1) greeting: join `greet: true`
   (MCP `join`: default) = the default greeting in the call language, a `greet` text only if it
   matches the call language, and the `say` in `context.first_actions` drops out; without greet,
   right after join `say` one short greeting in the call language: who you are and that you are
   listening. If you stay silent, the voicebot announces you after 3 seconds; (2) `status` with `doing`, `open`, `done`; (3) one `activity` line; (4) open questions
   for the human via `decide`; (5) `show` with `panel: "board"` (or `log`, `decisions`).
3. **Acknowledge within 3 s:** every request of the human gets a short `say` ("On it, one
   moment." / "Mach ich, Moment.") before the work; progress goes to `status` and `activity`.
4. **Solo call = ONE voice:** the main agent, through Delta. Other sessions of the same person
   are helpers (status board, `decide`, activity with the helper credential), never a second
   voice. A `helper_decision` goes, with question and answer, to exactly that session (`from`),
   never to all; a remote-controlled session on another machine may not get it: check that it
   reacted (board, activity), else, with the user's OK, type into its terminal.
5. **Feed Delta, speak little:** board (`doing`/`open`/`done`/`faq`), activity and context let
   Delta tell it in his own flow. `say` only to answer a direct question, or to correct Delta
   when he is wrong or forgot something important: a `say` overrides him and breaks his flow.
   Before every `say` read `agent_said` and add only what is missing; filters or wrappers
   around `next` never drop `agent_said`.
6. **Boards and decisions:** a board only while the agent really works, updated at least every
   3 min, cleared (empty `doing`/`open`/`done`) when idle or done. Every decision is a
   clickable `decide` question (yes/no first), one topic, short and self-contained, never only
   by voice; where supported it carries hidden background in `context` (what it is about, what
   happens on yes / no, why it matters), not shown on the card: Delta explains with it when asked.
7. **Credentials** never on disk (beyond the per-call session header), in argv, a chat, a call,
   a board or a card; scripts pass a bearer by process substitution (`-H @<(…)`). Agents never
   type passwords into login pages: the user gets the clipboard and one step at a time.
8. **Shipping and outages:** merge only on fully green CI; verify a deploy live (version
   endpoint), never by the deploy run status (a run can be a no-op "superseded").
   `voicebot_left`: tell the user in the chat at once; not back within about a minute or
   `ended`: offer a new call (`create_call` + background watcher).

## Join

`voicehook-agent join <invite-url> --name <Name> --model <model-id> --json`

`--name` and `--model` are mandatory since CLI 0.4.0 (exit 2 without them). They are
your self-report: the web Agent chip shows a spinner until you join, then
`[emoji ]label` (since 09.10.2026; the model is no longer shown). The label is the bridge/MCP
join field `label` (token query `label=`; `name` is its old alias), max 22 characters so the
chip with emoji and space stays within 24; longer is rejected with 422, never cut. Without a
label the chip shows the model name. `--name` also becomes the identity prefix and the name in
the auto-greet.

Optional `--username <Name>` (token query `username=`, bridge join field `username`): the
user's first name, lands as participant attribute `vh.user`. The voice then knows whom it
talks to ("Der Nutzer heißt …") and addresses the user directly instead of talking about them.

Optional `--voice <Chirp3-HD name>` (token query `voice=`, bridge join field `voice`): your
own TTS voice in pipeline mode, short (`Puck`) or full (`de-DE-Chirp3-HD-Puck`), lands as
`vh.voice`. Invalid names are dropped silently. Without it you still get your own voice,
see "Several operators". Use your real name: never `Claude` unless you are Claude. If
`model` is clearly not a Claude model (deepseek, gpt, gemini, llama, ...) and `name`
contains "Claude", the server renames you to the vendor name (e.g. `DeepSeek`).

Run the join in the background (`setsid nohup … &`) so it outlives the agent's shell;
the operator must stay connected until the user says goodbye.

## Work cycle

| CLI | listen | speak | end |
|---|---|---|---|
| 0.5.0 (`next`/`say`/`leave`) | `voicehook-agent next` blocks, prints one JSON line `{"type": "user"\|"revise"\|"timeout"\|"ended", "text"?}`; exit 3 when the call is over | `voicehook-agent say [--mode overwrite\|append] "<text>"` | `voicehook-agent leave [--say "<goodbye>"]` |
| 0.4.0 | follow the join output with `tail -f \| grep -E '"topic": "(_wake\|operator\.revise)"\|session ended'` (never sleep-poll; 0.4.0 logs each user turn twice, as `transcript` and `_wake`, so match only `_wake`) | stdin line `{"topic":"operator.say","text":"…"}` | stdin line `{"topic":"quit"}` |

0.5.0 control socket: `join` listens on `$VOICEHOOK_AGENT_HOME/sessions/<slug>/<identity>/`
(default home `~/.voicehook-agent`). `say`/`next`/`leave` pick the join automatically when
only one runs, otherwise `--session <slug>/<identity>`. An agent sets its own
`VOICEHOOK_AGENT_HOME` to stay isolated from other agents on the same machine. `next`
starts queueing turns with the first `next`/`say`. `--idle-timeout MIN` (default 10, 0 = off):
without `say`/`next` for that long the join announces it and leaves. The CLI prints no
`agent.heartbeat`; check for a peer of kind `agent` in the `room-state` lines or in
`voicehook-agent status`.

One `say` per user turn, in the user's language. Do not push `operator.persona` while
another operator is in the room: it replaces the agent's instructions for everyone
(CLI 0.5.0 skips that push unless `--force-persona`).
The skill (https://voicehook.ai/agent/SKILL.md) has a wrapper (`$D/vh`) that gives the curl bridge
(Quickstart A) and the CLI (Quickstart B) the same `next`/`say`/`leave` interface. CLI 0.4.0:
upgrade with `uv tool install --force git+https://github.com/voicehook-ai/voicehook-agent`.

## HTTPS bridge (no WebRTC, no install)

For agents that only get HTTPS out, usually through an HTTP CONNECT proxy (`HTTPS_PROXY`,
cloud sandboxes like claude.ai/code). libwebrtc ignores that proxy and the server offers no TURN relay,
so a WebRTC join times out (`wait_pc_connection timed out`). Through the bridge the server
joins the room for the agent as a normal participant, with the same token as the CLI
(`GET /api/token?invite=1&op_invite=<hmac>&name=&model=`: `vh.role=agent`, `vh.name`,
`vh.model`, same active-room check, voice-ai dispatch only for rooms with a payer). An operator
join needs the HMAC `?invite=` from the invite URL; it is verified (403 if invalid). The CLI
sends the URL's invite as `op_invite`.

The session key from `join` is a bearer secret (stored hashed on the server). It goes ONLY
in the header `Authorization: Bearer <session>`, never in a URL.

The session survives a restart of the HTTP service (deploy): the server keeps it hashed and
rejoins the room under the same identity. A waiting `next` returns `{"type":"timeout","reconnect":true}` when the
server shuts down; a dropped connection or empty answer means the same: call `next` again
with the same session (bei Verbindungsabbruch einfach erneut `next` aufrufen). Ended rooms
are never revived (`next` -> `ended`).

| endpoint | body / query | answer |
|---|---|---|
| `POST /api/bridge/join` | `{invite_url` or `room`+`invite?, label?, model?, identity?, greet?, persona?, force_persona?, idle_timeout?, quiet?}` (`idle_timeout` in minutes, default 10, 0 = off; `greet`: `true` = the default greeting in the call language, a text = spoken only if it matches the call language, else the default) | `{session, expires_in, room, identity, idle_timeout_s, peers, notes, lang, humans_present, instructions, context, share, notice}`; `humans_present` = people in the call (agents, voicebot and bots not counted); `0` adds `human_away: true` and `message`: the person is out right now, keep calling `next` and wait, never create a new room or call; `context` `{lang, mode: normal|live, instructions, functions, rules, skill_url}` is the source of truth for this call (`instructions` = its first sentences, also on top; `functions` = what you can call; `rules` = multi-agent rules); `lang` is the call language (`de` or `en`, room metadata `vh.lang`): speak it; `share` `{text, url}` is one line for your own user, never for the call; `notice` `{kind: "bridge_mode", text}` is advice for you, the agent (what the optional CLI adds), also once in the first `next`; `quiet: true` leaves both out (nothing audible) |
| `GET /api/bridge/next?timeout=50` | max 120 s | one object like CLI `next`: `{type: user\|revise\|status_request\|decision\|data\|timeout\|ended, text, ...}` (`data`: see "Data between agents"); plus `status_stale: true` when the board is older than 5 min and the user spoke since; `stale_error` (see below) on EVERY answer while board or activity feed is overdue; always `humans_present` (`0` = the person is out: wait with `next`, never create a new room; back = greet them briefly), `board_now: {doing, age_s}\|null` and `activity_now: {text, age_s}\|null` (newest published board `doing` / feed line without the time = what the voicebot knows about you); `agent_said: [..]` = what the voicebot said on its own since the last `next` (max 3 lines / 400 chars, never your own echoes); `operators_said: [{speaker, op, model?, text, addressed_to, addressed_to_you, to_human}]` = what OTHER operators said since the last `next` (spoken `transcript` lines `role:"operator"` with `op` != your identity, max 5 lines / 1000 chars, never your own lines; it never wakes `next`, it comes with the next answer), except a line that addresses YOU: it wakes `next` as `{type: "operator", op, speaker, model?, text, addressed_to, addressed_to_you: true}` (max 1 per 5 s per receiver, further ones stay in `operators_said`); `user` events carry `addressed_to` + `addressed_to_you`; `addressed_to` = labels/names addressed (sentence start, before `,`/`:`, or `, Name` at the end; word boundary, case-insensitive; `"Delta"` incl. STT spellings Data/Della/Delter only in that position; same or similar labels: empty); `to_human` = nobody addressed or the person; `{type: "context", context}` when the call language changes |
| `POST /api/bridge/say` | `{text, mode?, priority?, emotion?, shape?}` (`priority: "urgent"`; `emotion`/`shape` see vh.visual) | `{ok, seq}`; payload on the wire `{text, _seq, _ts, mode?, priority?, emotion?, shape?}` like the CLI; invalid shape/emotion 400 `{"error":"invalid"}`, more than one shape per 2 s 429 `{"error":"rate_limited"}` |
| `POST /api/bridge/visual` | `{shape?, emotion?}` (at least one) | `{ok, shape?, emotion?}` (validated, clamped); draws in the ring without speech, see vh.visual; invalid 400 `{"error":"invalid", detail}`, more than one shape per 2 s 429 `{"error":"rate_limited", detail}` |
| `POST /api/bridge/leave` | `{say?}` (optional) | `{ok, type: "leaving"}`; `say` is spoken with `mode:"append"` first |
| `GET /api/bridge/info` | | connection info `{connected, pending, idle_s, peers[], sse_clients, ...}`; `GET /api/bridge/status` is the same (older name); `POST /api/bridge/status` is the board |
| `POST /api/bridge/status` | `{doing, open?, done?, faq?, emoji?}` | `{ok}`; the status board, same as `send` with topic `operator.status`; `emoji` outside the list: 422 |
| `POST /api/bridge/ui` | `{cmd: window\|layout\|move\|focus, win?, state?, preset?, slot?, project?}` | `{ok, applied, humans}`; arranges the human's call page at once (`delta.ui` v2, UI control below); 422 invalid value. Same rate limit as `say` |
| `POST /api/bridge/show` | `{text?, title?, kind?: text\|code\|link\|image, panel?: board\|log\|cards\|decisions\|none, image?, large?}` | `{ok, id?, seq?, panel?, large?}`; puts a card on the user's call page, never spoken (`operator.card`); a card shows up as a preview top left, `panel: "cards"` opens the large view, `board`/`log`/`decisions` open those panels, `none` closes all (`delta.ui`). `large: true` opens the card view big in the middle with this card at full size (implies `panel: cards`; delta.ui `{panel: cards, open, large: true, id}`). Limits: text 4000 chars, title 80, SVG 300 KB (no script, foreignObject, on* attributes or external references), PNG/JPEG/WebP 1 MB, link only one http(s) URL (use `kind: link` for every link the user should open; http(s) URLs inside a text card are clickable too); else 422, never cut. Same rate limit as `say` |
| `POST /api/card/image` | `{image}` (Bearer = LiveKit token of a CLI operator joined over WebRTC, or its helper pump token) | `{ok, id, url, mime}`; stores an image for a card the CLI publishes itself as `operator.card` with this `url` (CLI 0.21.0 `show --card image`). Same image checks as `show`, max 10 per minute per operator (429), call ended 410 |
| `POST /api/bridge/activity` | `{text}` (3-8 words, cut to 120 chars, secrets scrubbed) | `{ok, type: "activity", line}`; appends `HH:MM:SS note: <text>` to the session's feed (newest 15 lines) and publishes `operator.activity {lines, ts}` on change, at most every 5 s (the newest state wins); own rate limit (20 per 10 s, 429), does not count as a sign of life for the idle guard |
| `POST /api/bridge/decide` | `{question, options?, recommend?, context?, recent?}` / `{id, question?, options?, recommend?, context?}` / `{id, delete: true}` | `{ok, id, number}`; Decision Board, see below (yes/no by default, 2-3 options of max 40 chars); 403 not yours, 404 unknown id, 409 already decided, 422 too long or limits, 503 voicebot not (yet) in the call |
| `GET /api/bridge/decisions` | | `{ok, open: [...], count}`; every open decision of the call, also those of other agents and helper sessions (Decision Board, "Open decisions" below); 503 voicebot not (yet) in the call |
| `GET /api/bridge/events` | | Server-Sent Events, see below |
| `POST /api/bridge/send` | `{topic, payload, force?}` | raw publish; topics: `operator.say`, `operator.persona`, `operator.mode`, `operator.interrupt`, `operator.inject`, `operator.backchannel`, `operator.status`, `operator.alive`, `operator.activity` (else 400) |
| `POST /api/bridge/data` | `{to: label\|identity\|"*", kind: json\|text\|file, payload? \| file_id?, name?, mime?, id?}` | `{ok, id, to: [labels], size, file_id?, url?}`; data for other agents, see "Data between agents" below; 404 unknown target (lists the labels), 409 ambiguous label, 413 packet over 15 KB, 422 bad kind or body |
| `POST /api/bridge/upload` | raw body (`--data-binary @file`), header `X-Filename` (or `?name=`), `Content-Type` (or `?mime=`) | `{ok, file_id, url, size, expires_in, name?, mime}`; a file for `data` with `kind: file`; max 10 MB per file, 25 MB and 50 files per call (413), 2 uploads at once per session (429) |
| `GET /api/bridge/file/<file_id>` | | the file (attachment), only with a bridge or MCP session in the same call, else 404 |

SSE events (`event: <type>` + `data: <json>`; comment `: ping` every 15 s): `hello`
`{room, identity, expires_in, peers}`, `room-state` `{peers}`, `data` `{topic, payload,
sender}` for EVERY data packet (transcript, operator.revise, operator.notice,
agent.heartbeat, ...), `peer-joined` / `peer-left` / `peer-updated`, `speakers`, `track`
`{identity, state: on|off|mute|unmute}`, `reconnecting` / `reconnected`, and `ended`
`{reason}` as the last event. A peer: `{identity, kind (LK int, 4 = agent), kind_label,
name, attributes, audio, speaking, operator}`.

Queue: user turns and `operator.revise` are queued from `join` on (bounded, 200), so turns
spoken while the agent works wait for the next `next`. Guards on the server: idle guard
(no `next`/`say`/`send` for `idle_timeout`; a running `next` counts as alive) announces and
leaves like the CLI; persona guard: `persona` at join and `send` of `operator.persona`/
`operator.mode` are refused (409) while another operator is in the room unless `force`;
SSE guard: once an SSE client was connected, the server leaves 60 s after the last one went
away; `expires_in` is `null` when the call has no maximum duration; no human in the room for
120 s (300 s while an operator is in the room and the
last human dropped unexpectedly; hang-up stays 120 s) -> call over, room deleted, every
operator leaves; room ended -> `ended` with `reason`, plus `detail` (e.g. `idle_no_human`) and
`message` (plain-language why, e.g. "Der Raum wurde beendet, weil seit 300 s kein Mensch mehr
verbunden war.") when the server ended the room; 410 details carry the same message.
Limits: 4 sessions per room, 4 per IP, 10 joins per IP per minute, 20 sends per
10 s per session (429). Errors at join: 400 no room, 403 invite invalid, 409 no human in room (the room never had a person or
is gone; a join while the person is only out is accepted with `humans_present` 0 and `human_away`;
on 409 tell your user, join again every 15 s for at most 2 min, then tell your user nobody is in the call and stop; never create a new room), 410 call ended (never rejoin),
429 limits, 502 LiveKit connect failed, 503 server without LiveKit credentials. Nothing of
what is said and no key is logged.

CLI 0.6.0 (`--transport auto|webrtc|bridge`, default auto) uses the bridge on its own when
`HTTPS_PROXY`/`ALL_PROXY` is set or the WebRTC connect fails (one retry via the bridge,
logged as a `_meta` line). Its output, `say`/`next`/`leave` and FIFO input stay identical.

## Data between agents (`operator.data`)

Agents in the same call can exchange JSON, text and files on a silent channel: never spoken,
never sent to the voicebot, never shown to the human. The human only sees one line in the
sender's activity feed, e.g. `Daten an Bert: result.json (1,2 KB)` (metadata, never the content).

- `to`: the receiver's label (as on its chip, case-insensitive), its identity, or `"*"` = all
  other agents. Unknown: 404 with the labels in the call; two agents with the same label: 409, use
  the identity. The voicebot and humans are never targets.
- `kind`: `json` (`payload` any JSON), `text` (`payload` a string), `file` (`payload` = base64
  plus `name`/`mime`, or `file_id` from an upload). The whole packet is max 15 KB (413 with a hint
  to upload), so a file goes inline only up to about 11 KB (base64). `id` is yours (1-64 of `[A-Za-z0-9._-]`) or made by the server.
- Bigger files: `POST /api/bridge/upload` (max 10 MB) gives a `file_id`; the file lives in
  memory, only for this call, and is gone after one hour or when the call ends. Download:
  `GET /api/bridge/file/<file_id>` with any bridge or MCP session in the same call (other calls: 404).
- Receiving: `next` returns `{type: "data", from, from_identity, kind, id, name?, mime?,
  encoding?: "base64", payload | url + file_id, size?}`; `url` is a path, GET `"$B$url"`. It never
  counts as a turn of the human: use the data, never read it out.
- Receiving and download work for bridge and MCP sessions; the CLI (`voicehook-agent`) follows.
- Same rate limit as `say` (20 per 10 s). MCP: tool `send_data` (inline only; bigger files via
  the REST upload above with the MCP `session` as Bearer).

```sh
H="Authorization: Bearer $SESSION"; B=https://voicehook.ai
curl -sS -H "$H" -H content-type:application/json "$B/api/bridge/data" \
  -d '{"to":"Bert","kind":"json","name":"result.json","payload":{"rows":3,"ok":true}}'
curl -sS -H "$H" -H 'X-Filename: report.pdf' -H content-type:application/pdf \
  "$B/api/bridge/upload" --data-binary @report.pdf          # -> {"file_id":"…","url":"/api/bridge/file/…"}
curl -sS -H "$H" -H content-type:application/json "$B/api/bridge/data" \
  -d '{"to":"*","kind":"file","file_id":"<file_id>"}'
curl -sS -H "$H" "$B/api/bridge/file/<file_id>" -o report.pdf   # receiver, after next type data
```

## Data-channel topics

All payloads are JSON on the LiveKit data channel. The CLI maps stdin lines
`{"topic":"operator.say","text":"..."}` to these packets.

| topic | direction | payload | effect |
|---|---|---|---|
| `operator.say` | operator to agent | `{text, mode?, priority?, emotion?, shape?}` | speak `text`, see modes below (default `append`); `emotion`/`shape` color the ring and draw when this say starts, see vh.visual |
| `operator.visual` | operator to agent | `{shape?, emotion?}` | draw a shape / tint the ring at once, without speech, see vh.visual |
| `vh.visual` | agent to everyone | `{v:1, turn, speaker, emotion, shape}` | emotion and shape of the current turn for the ring (browser, desktop bubble), see vh.visual |
| `operator.revise` | agent to owner | `{unspoken[], new, text, owner, reason?}` | what of YOUR says was NOT spoken yet, plus an instruction; sent only to you (`destination_identities`); `reason:"user_stop"` = the user stopped them, never resend |
| `operator.say_status` | agent to owner | `{seq, state, spoken_chars, owner, reason?}` | what happened to each of your `operator.say`, see below; sent reliable, only to you |
| `operator.persona` | operator to agent | `{text}` | knowledge of the agent, appended after Delta's fixed core, never replaces it; sanitized (max 1500 chars, override lines dropped, `operator.notice` `persona_sanitized`); first push triggers the auto-greet |
| `operator.mode` | operator to agent | `{mode:"strict"\|"auto"}` | strict: the agent never answers on its own (`--strict-relay`) |
| `operator.interrupt` | operator to agent | `{}` | stop YOUR says (running and queued) and an own answer of Delta; another operator who is speaking keeps going; your unspoken rest comes back as `operator.revise` |
| `operator.inject` | operator to agent | `{text, role?}` | synthetic chat-context entry, not spoken |
| `operator.status` | operator to agent | `{doing, open[], done[], faq?[{q,a}], emoji?, emoji_at?}` (or `{text}` = doing) | status board, see below; replaces the previous one, never spoken |
| `operator.activity` | operator to agent | `{lines[], ts}` | activity feed: last tool steps of the agent, see below; replaces the previous one, never spoken |
| `operator.status_request` | agent to operator | `{text}` | the user asked for your status; answer at once with `operator.status` |
| `operator.card` | agent to humans | `{id, seq, kind, title, text, at, url?, mime?}` | a card from `show`, never spoken; images come as `url` (signed, only for this room) instead of bytes; the voicebot keeps the last 20 cards of the call and sends them again to a browser that reloads (`op`, `age_s`, `replay: true`) |
| `delta.ui` | agent or server to humans | `{panel:"board"\|"log"\|"cards"\|"decisions", open:true\|false, via?}` or v2 `{v:2, cmd, ...}` (UI control, below) | opens or closes the status board / activity log panel in the user's browser, the same state as the user's own click; the voicebot sends it when the user says e.g. "show me the status board" (`via:"voice"`, one packet per panel). Browsers ignore unknown `panel` values and accept it only from the voicebot, an agent participant or the server, never from another browser |
| `operator.decision` | agent to owner | `{type:"decision", id, number, verdict, option?, answer?, by, changed?, text}` | a human answered YOUR decision (Decision Board, below); sent only to you; `text` is one summary line (the CLI prints it), `answer` the person's own answer; bridge and MCP turn it into a `next` event (`text` = the own answer) |
| `delta.decisions` | agent to humans | `{op:"reset"}` / `{op:"upsert", item}` / `{op:"delete", id}` | the Decision Board in the browser, one packet per item; replayed to a human on join or reload |
| `delta.decide` | human to agent | `{id, verdict: "yes"\|"no"\|"option", option?}` / `{get:true}` | a click on Yes/No or an option; only from humans |
| `operator.notice` | agent to everyone | `{kind, minutes_left, seconds_left, free_s, free_eur, balance_eur, topup_url, text}` | server notice, see below; sent reliable |
| `cost` | agent to everyone | `{eur, mode}` | running customer price of the call, see below; only sent when the sum changed |
| `transcript` | agent to everyone | `{role, text, speaker?, op?, id, t, final}` | see transcript roles. `id` is stable per segment (user: one turn in Live, one STT segment in normal mode; agent/operator: one reply) and never reused; `t` = start of speech (server epoch seconds, user: VAD start, not transcript arrival); `final:false` = interim user text, sent only to humans, operators get finals only. Live: Gemini closes the user text only after Delta's reply, so operators get the user turn as `final:true` as soon as Delta starts answering (user silent, text stable); Gemini's later final then goes to humans only, and only text added after that point reaches operators as one more user turn. Clients update the line with the same `id` in place and sort by `t`; payloads without `id`/`t` come from old workers |
| `transcript.live` | agent to everyone | `{phase:"start", role:"operator", id, text, op?, speaker?}` / `{phase:"end", role, id, interrupted, op?, speaker?}` | an `operator.say` output started / finished playing (pipeline mode only); for the browser's live reading. Not an echo: `transcript` alone means "spoken" |
| `operator.data` | operator to operators | `{id, from, from_identity, to, to_identities, kind: json\|text\|file, name?, mime?, payload \| (url, file_id, size), ts}` | data between agents, never spoken; sent only to the addressed operators (`destination_identities`), never to the voicebot or humans; receivers accept it only from an operator of the same call and only when their identity is in `to_identities` (resolved by the server; `to` is for display) |
| `agent.heartbeat` | agent to everyone | `{ts, room, probe, healthy}` | every 30 s; no tick for more than 60 s = worker dead |

## vh.visual (emotion and shapes in the ring)

The ring tints itself per turn with the emotion of whoever speaks, and can draw shapes from a
swarm of its particles. The agent publishes one packet per turn on `vh.visual`:

```json
{"v":1, "turn":"<id>", "speaker":"user|delta|<operator identity>",
 "emotion":{"label":"neutral|joy|calm|curious|excited|concerned|frustrated|sad",
            "valence":-1..1, "arousal":0..1},
 "shape": null | <shape>}
```

Sources: in pipeline mode Delta rates its own answer and the user turn before it (first a
packet for the user turn, then one for Delta's turn). Your `operator.say` / `operator.visual`
may carry `emotion` (a label string or `{label, valence?, arousal?}`) and `shape`; without
`emotion` the browser guesses it from your words. Live mode: the browser guesses from the words.
The tint is subtle (about 25 to 40 %, stronger with `arousal`), spreads from the speaker's side
and fades back after 5 s of silence. Only styles that support it tint; shapes work everywhere
except with reduced motion.

Shapes, coordinates normalized 0..1 inside the ring (x right, y down):

| shape | fields |
|---|---|
| preset | `{"type":"preset","name":"arrow_up\|arrow_right\|check\|cross\|question\|loop\|split3\|scale\|heart\|bolt\|one\|two\|three","hold_ms":800..8000}` |
| polygon | `{"type":"polygon","points":[[x,y],...],"closed":true\|false,"hold_ms":800..8000}` |
| path | `{"type":"path","d":"<SVG path, only absolute M L Q C Z>","hold_ms":800..8000}` |
| multi | `{"type":"multi","items":[<preset\|polygon\|path>, ...],"hold_ms":...}`, at most 12 items |

Any shape may add `"label":"<max 24 chars>"`, shown small under it. A string shape (`"check"`)
means that preset. Limits: at most 400 points per shape (multi: all items together), path
`d` at most 2 KB, values outside 0..1 are clamped, unknown fields dropped; anything invalid is
dropped silently (the call goes on), the bridge answers 400. At most one shape per 2 s per
operator; faster ones are dropped (bridge: 429), the emotion still applies.

When to draw: for short signals and state only: yes or no (`check`, `cross`), a question
(`question`), attention (`bolt`), a count (`one`, `two`, `three`). Explanations, sequences and
diagrams go into an image card (SVG, `show` with `kind: "image"`, `panel: "cards"` = shown
large), not into the ring. Not on every sentence, not as decoration.

Examples:

```json
{"topic":"operator.say","text":"First the build, then the deploy.",
 "emotion":"calm","shape":{"type":"preset","name":"arrow_right","hold_ms":2500}}
{"topic":"operator.visual","shape":{"type":"multi","label":"Today to tomorrow","hold_ms":4000,
 "items":[{"type":"polygon","points":[[0.06,0.32],[0.36,0.32],[0.36,0.68],[0.06,0.68]],"closed":true},
          {"type":"path","d":"M0.4 0.5 L0.6 0.5 M0.53 0.42 L0.6 0.5 L0.53 0.58"},
          {"type":"polygon","points":[[0.64,0.24],[0.94,0.24],[0.94,0.76],[0.64,0.76]],"closed":true}]}}
```

## operator.status (board)

`emoji` (optional): the emoji on your chip. Pick the emoji that best shows your current state, thought or mood; your own choice, change it whenever it changes. Only
emojis from the list in the enum of the MCP `status` tool (the bridge answers 422 otherwise);
`""` removes it, a board without the field keeps it. The bridge adds `emoji_at` (Unix time of
the last change); the status board in the browser shows it as "since HH:MM".

The voicebot never says "Operator" to the user; it names you by your `vh.name`
(`--name`, letters only, max 24 chars, else "dein Agent"): "Kurzen Moment, ich frag
Claude." Your status board sits at a fixed place in its instructions:
`{doing: "baut gerade den Fix", open: ["Tests"], done: ["Analyse"], faq: [{q: "Wann ist
das live?", a: "Nach dem Review, heute Abend."}]}`. Every send REPLACES the whole board
(context never grows). Budget 2000 chars in total (`doing` max
400, each item max 200, 10 per list): `faq` is cut first (last pair), then `done`
(oldest), then `open` (last). Empty or `doing:"fertig"` with no lists and no `faq` clears it.

Items stand alone: every `open`/`done` item says WHAT concretely (thing and place), readable
without context. Questions that wait for the user never go into `open`: they go to the
Decision Board (`decide`, below); status OR Decision Board, never both. One item per
thing, never bundles ("6 Entscheidungen", "diverse Fixes", "Rest"); more than 10: keep the
most important, the rest goes to `faq`. `doing` names the concrete step ("Prüfe PR #250 auf
Überlappung", not "arbeite an PRs").

`faq` (CLI `vh status --faq "Frage::Antwort"`, repeatable): up to 6 pairs, question and
answer max 200 chars each. Fill it on EVERY board update with the questions the user most
likely asks next, answered in advance; Delta answers them directly ("Wahrscheinliche Fragen
und Antworten von Claude"), in the third person. Delta also answers from what you already
said in this call (`[Claude]` lines in its context and summary), still never from nowhere. At most one update per 5 s per room is applied,
the last one wins. Nothing is spoken on its own (exception: the status takt below, if the
user chose it); asked for the status, the voicebot answers from the board ("Kurz Moment,
Claude baut gerade den Fix").

Triggers: push the board on every task change (started, finished, new task). When the
user asks for your status, the voicebot sends `operator.status_request`
(`next` -> `{type:"status_request"}`); answer with a fresh board at once. If it arrives
within 8 s, the voicebot speaks one sentence from `doing`. If the user set "Delta status
updates" to Off in the call panel, no `status_request` is sent; keep pushing the board
anyway. `next` also carries
`status_stale: true` when your board is older than 5 min and the user spoke since.

`stale_error` (HTTPS bridge): while you work (board has `doing` or `open`, you sent a `say`
in the last 5 min, or no board at all more than 60 s after the join) and the board or the
activity feed was not updated for 60 s (never set = counted from the join), EVERY `next`
answer carries `{"stale_error": {"status_age_s": 183, "activity_age_s": 95, "message":
"FEHLER: Statusboard seit 3 Min 3 s, Aktivitätslog seit 1 Min 35 s nicht aktualisiert. Jetzt:
POST /api/bridge/status {…} und POST /api/bridge/activity {\"text\": \"…\"}"}}`. An age
is `null` when that part is fresh; the message names only the overdue parts. Updates that
count: `POST /api/bridge/status` or `send` `operator.status`, `POST /api/bridge/activity` or
`send` `operator.activity`. Finished board (no `doing`/`open`) and no `say` for 5 min = no
message. The 60 s threshold is a server setting.

Empty feed (HTTPS bridge and MCP): on join the server adds the first activity line
`<label> joined` (German room: `<label> ist dazugekommen`), so the activity panel shows you
at once; it does not count as your activity. If you sent no activity of your own 30 s after
the join, the next `next` carries `stale_error` with `"activity_missing": true` and the
message `Send an activity line now (3 to 8 words): …`, at most once per 2 min, whether or
not you are working. After your first own line only the rule above applies.

Keep the main loop free: answer a `user` turn within ~3 s and hand slow work (shell, web,
edits, builds) to a background agent. CLI 0.7.0 measures the time from a `user` turn
leaving `next` to your next `say`; over 8 s the following `next` carries
`latency_warning: {seconds, hint}`. Nothing is spoken automatically.

## Silent mode (`human_state`)

The human can mute the mic or the sound on the call page. The page reports it (`delta.human`
`{mic_muted, speaker_muted, away}` to the voicebot), the voicebot sets it as attributes on the human
(`vh.mic_muted`, `vh.speaker_muted`, `vh.away`, "1"/"0"), and everyone sees a small pill at the
human's chip ("stumm" / "hört nicht zu"). With several humans a state counts only when it holds for
all of them.

- Every `next` carries `human_state: {mic_muted, speaker_muted, away}`; a change also arrives as an
  event `{type: "human_state", ...}`.
- Sound off or away: neither the voicebot nor any `say` starts speaking. Your says wait (visible on the
  page as "Nicht gesagt"); status takt, reminders and "still on it" stay silent; cards, board,
  decisions and typed answers keep working. When the human listens again the voicebot gives ONE
  summary instead of a replay, scaled to the absence (short: one sentence; long with much news: a
  spoken summary by project of what got done, what waits for them and open decisions, about 30 to 60
  s, interruptible). The waiting says then end as `dropped` with `reason: "muted"` (`operator.revise`
  says so); do not resend them.
- Mic off only: the voicebot and your says speak as usual, but expect answers by click or text, no
  spoken questions.

## UI control (`delta.ui` v2)

The whole call page can be arranged by the voicebot (the human says "mach die Entscheidungen groß",
"Dreiteiler", "Aktivität nach links", "zeig mir jetzt nur noch Voicehook", "alles") and by agents
(bridge `POST /api/bridge/ui`, MCP tool `ui`, CLI `voicehook-agent ui ...`). Packet to the humans only,
applied at once exactly like the human's own clicks. The voicebot does these itself, never hands them
to an agent, also combined ("Aktivität zu und die Mitschrift groß") and confirms in a few words
("Aktivität zu, Mitschrift groß."); an unclear "das" refers to the window named last, else it asks
which one:

- `{v:2, cmd:"window", win, state}`: `win` = `log` (activity), `dec` (decisions), `board`, `wrap`
  (transcript), `cards`, `ring` (on/off: `open`/`close`); `state` = `open`, `close`, `min`, `medium`, `large`, `max` (one large window at a
  time; the transcript cannot close, `close` minimizes it).
- `{v:2, cmd:"layout", preset}`: `halves`, `quarters`, `thirds`, `bigmid` (default), `bar`.
- `{v:2, cmd:"move", win, slot, preset?}`: slot `l`, `m`, `r`, `b`, `tl`, `tr`, `bl`, `br`, `main`, `side`;
  a slot the current preset lacks switches to a preset that has it.
- `{v:2, cmd:"focus", project}`: `voicehook`, `salesengine`, `kunden`, `sonstiges` or `all`. Sections in
  activity, status and decisions of other projects collapse, matching ones expand; a small removable chip
  shows the focus. Project per sender: the optional `project` field of `operator.status`/`operator.activity`
  (helpers: relay `status` body), else derived from the session/helper name (voicehook*, vh*, Ring-Lab:
  Voicehook; SE*, Sales Engine: Sales Engine; Kunde*: Kunden; else Sonstiges).
- `via`: `voice` (voicebot) or `agent`. On phones layout and move have no effect (one sheet).

## Decision Board (`decide`)

For every question the human decides: yes/no, A/B, an approval, picking a value. ALWAYS here,
plus one short `say` that points to it; never only `say`, never as an item in status `open` (the
status board is for progress only). Every PR an agent opened that can be merged goes here too,
asked by that agent: repo, number, one sentence of content, test state; for repos with
auto-deploy add "merge after the call". Bridge: `POST /api/bridge/decide`, MCP: tool
`decide` (same fields). The voicebot holds the board for the length of the call, in memory
only; reload and rejoin replay it, after the call it is gone.

- Create: `{question, options?, recommend?}`. Default is a yes/no question: leave `options`
  out, the person sees the buttons Yes and No. Otherwise `options` = 2 to 3 strings of 1-40
  chars, `recommend` = 0-based index of your pick. `question` 1-160 chars (required). Too long,
  too many or out of range: 422 with a clear message, never cut. Answer `{id, number}`:
  `number` counts up per call (1, 2, 3 ...) and never changes. Limits: 10 open per agent,
  30 open per call (422).
- Background for Delta (always pass it): `context` (max 1500 chars) = 2-4 plain sentences:
  what it is about, why, what happens on yes, what on no; `recent` (max 600, optional) = the
  last turn of the asking session (the CLI fills it when its hook knows it). The human never
  sees either; at creation the voicebot also keeps a snapshot of the asker (name, project,
  board, last 5 activity lines). When the human asks "what is meant by number 2?", Delta
  explains it in plain words from that. Without any background Delta says so and sends you
  `verdict: "unclear"` (below). Longer: 422, never cut.
- Change: `{id, question?, options?, recommend?, context?}` (`recommend: null` removes it), only while
  open (409 after the decision). Delete: `{id, delete: true}`, only while open. Only the agent
  that created it (same identity) may change or delete it, else 403.
- The browser shows `#2 · <your label>: <question>` with Yes and No (thumb icons) or the
  options as buttons (your recommendation marked). Decided items move into a dimmed history
  with result, who and time.
- Several humans: anyone may decide, the first one counts. The same person may change their
  decision for 10 s, until you `say` something or update your board after it; then it is
  fixed.
- Voice (pipeline mode): "ja für Nummer zwei", "Daumen hoch für zwei" (= yes; on an option
  question = your recommendation, without one the voicebot asks which option), "Nummer drei
  Option B", "thumbs up for two", "number one, no". Answering freely works only by voice, with
  a fixed prefix: "Nummer zwei, eigene Antwort: ...", "number two, my answer: ...". Recognised
  by fixed patterns, never by the model; the voicebot confirms with your label ("Nummer 2 für
  dev2: ja.") and asks back when the number is missing and several are open. Such a turn never
  reaches any agent as a `user` turn.
- Result: only to you, `next` -> `{type: "decision", id, number, verdict:
  "yes"|"no"|"option"|"other"|"unclear", option?: index, text?: "<own answer>", by: "<label of
  the person>", changed?: true}`. Confirm in one `say` and act. `unclear` (the question stays
  open): the person does not understand it and Delta had no background; `text` says so ("Oliver
  versteht #2 nicht, bitte einfacher neu stellen"): delete it and ask again more simply, with
  `context`.
- Status OR Decision Board, never both: a question for the human lives only on the Decision
  Board, never also in `open`. Several agents in one call: see MULTI-AGENT.md.
- Helper sessions (activity relay, REFERENCE.md): the `vh.decide` RPC payload takes an
  optional `sender` (1-40 chars, scrubbed like activity, display only). The item shows
  `Helfer <sender>` in its own group, Delta reads the sender as the asker, the result event
  carries `sender`. Change and delete need the same identity AND the same `sender`; 10 open
  per (identity, sender), 30 per call. Over the relay the server sets `<token label>: <from>`.
- Open decisions: bridge `GET /api/bridge/decisions`, MCP tool `decisions`, WebRTC RPC
  `vh.decisions` to the voicebot (payload `{}`, same answer). Lists every open decision of the
  call, from all agents and their helper sessions (relay, MCP, bridge), lowest number first:
  `{ok, open: [{id, number, question, options, recommend, agent, from, sender?, state, at,
  age_s, mine, context?}], count}`. `agent` = label of the asking agent, `sender` = its helper
  session, `from` = both as shown on the card, `at` = created (epoch), `age_s` = seconds since,
  `mine` = asked by you or one of your helpers, `context` = the background for Delta. Decided
  ones are not listed; their answer still goes only to the asker. Agents only (403 for humans).
- Helper boards: `operator.status` takes an optional `sub` (1-40 chars, scrubbed): its own
  board per (identity, sub), shown as `Helfer <sub>` below your board, same limits. A `sub`
  board with empty `doing`, `open` and `done` removes it. Your own board (no `sub`) is unchanged.

## operator.activity (activity feed)

What you are doing right now, without you having to `say` it. Recommended setup step for
Claude Code on the same machine (once, CLI 0.10.0): `voicehook-agent hook install`. It adds a
Claude Code PostToolUse hook (`~/.claude/settings.json`)
that appends ONE line per tool call to `activity.log` in the session dir of the running
join: `HH:MM:SS Tool: description`, e.g. `17:12:03 Bash: Tests laufen lassen` (the
`description` you gave the tool call; tool name only when there is none). It never writes
commands, arguments, paths, file contents or output, and scrubs secret-looking tokens
(`sk_`, `rk_`, `re_`, `whsec_`, `vhw_`, `ghp_`, `Bearer`, JWTs, long base64). The join reads
the file and sends the last 15 lines as `operator.activity` `{lines, ts}`, only on change and
at most every 5 s (the newest state wins). With several joins on one machine the hook writes
nothing unless `VOICEHOOK_SESSION=<slug>/<identity>` names the target.

HTTPS bridge (no CLI): `POST /api/bridge/activity {"text": "Tests laufen"}` with the same
`Authorization: Bearer <session>` header as every bridge call appends `HH:MM:SS note: <text>`;
the server keeps the newest 15 lines per session and publishes them like the CLI does.

The voicebot puts it in its own block "Zuletzt hat Claude gemacht: ..." next to the board
(budget 800 chars, oldest lines dropped first; scrubbed
again), replaced on every update, cleared when you leave. Delta may tell the user from it,
always briefly summarized, in the user's language, in the third person, only what is there,
without tool names or times and never reading a line out verbatim (lines are often English
tool descriptions): "Claude hat gerade die Tests laufen lassen". The board field `doing` is
not needed for "was macht Claude gerade": Delta answers from the newest feed line, and a
`status_request` answered with a board without `doing` makes Delta say one sentence in his
own words from the newest feed line (since 08.10.2026; before, the raw line was spoken). The
voicebot applies at most one feed update per 5 s in Normal and per 20 s in Live; the newest
state in the window wins.

### Status takt (pipeline mode)

Delta can tell the user on his own, in pauses, what you are doing in the background. Once
per call, after your greeting, in a pause, Delta asks: "Wie oft soll ich dir sagen, was
Claude im Hintergrund macht: alle 15 Sekunden, jede Minute, alle fünf Minuten, nur wenn etwas
passiert, oder gar nicht?". Until the user answers it is off. A remembered "only on request"
from the call panel does not count as an answer, so Delta asks in every call once. The answer is recognized by fixed phrases,
not by the model, and can be changed any time ("alle 15 Sekunden", "jede Minute", "alle fünf
Minuten", "nur wenn was passiert", "Updates aus"); Delta confirms with a short fixed sentence. Such a switch
phrase is shown to humans in the transcript but does not reach you as a `user` turn.

- every N seconds (10 to 59) / every minute / every five minutes: at each tick at most one sentence, only if there is
  something NEW since the last one; otherwise silence (never "nothing new").
- only when something happens: triggered by a board change (new `doing` or a new `done`
  item), never by a feed line alone; at most one sentence per minute, the newest wins.
- Freshness: only what is happening NOW is said. An item counts only if the voicebot
  received it less than 60 s ago and after the last takt sentence: your current `doing`,
  else a new `done` item, else the newest feed line. Exactly one item, one sentence, in
  Delta's own words in the user's language (never the raw line, no lists, no history). If
  you said the same yourself within the last 10 s, Delta skips it.
- Only in real pauses: the user silent for 2 s (5 s after a turn nobody answered yet),
  Delta and all operators silent, no `say` of yours queued. Your `say` always goes first: an
  arriving `say` cuts a running takt sentence. If the user starts talking, the sentence is
  dropped, never repeated.
- Not in Live mode, not with `operator.mode` strict.

For you: keep `doing` and the feed fresh (hook installed, short `description`s). A stale
board stays silent; nothing older than a minute is ever said.

## operator.say modes

| mode | behaviour |
|---|---|
| `append` (default since 06.10.2026) | Queued behind everything that is running and queued, from you and from other operators. Starts right after the running say ends: no pause between two operator says (the 600 ms silence only applies after the user spoke), and the next say is synthesized while the current one still plays (prefetch, pipeline mode). Use it for almost everything. |
| `overwrite` | Replaces YOUR says that have not started at all (state `queued`), at the place of the first one. A say that already started (playing, or a `requeued` rest) is never cut and never replaced: then the new text is simply appended. Says of other operators are never touched. |
| `revise` | Like `overwrite`, plus: if it replaced anything, only you get `operator.revise` with the replaced texts (`unspoken`) and the new one (`new`); send one merged statement with `mode:"overwrite"` if any of it still matters (it replaces `new` as long as that has not started). Nothing is held, nothing is cut, no revise when nothing was replaced. It was the default until 06.10.2026. |

A running say is only ever cut by `operator.interrupt` (your own) or `priority:"urgent"`
(alias `"interrupt"`, or `urgent:true`) on any mode: urgent cuts whoever is speaking right
now (Delta's own answer or another operator) and your say plays next. A cut operator loses
nothing: the unspoken rest of their say plays right after yours (state `requeued`; live
mode: `interrupted`, no replay). With `overwrite`/`revise` plus urgent your own not-started
says are replaced first.

### Several operators

Several agents may be in one call (e.g. Claude over the HTTPS bridge and DeepSeek over the
CLI). The operator queue is shared, the owner of a say is the sender's participant identity:

1. No cutting in: while a say plays (yours or another operator's), a new one waits behind
   it; `overwrite` and `revise` only replace your own not-started says, `operator.interrupt`
   only stops your own. Only `priority:"urgent"` cuts another speaker.
2. Delta first: while the voicebot speaks on its own (own answer, notice), your say waits
   until it is done (`urgent` cuts it).
3. `operator.revise` and `operator.say_status` go only to the owner
   (`destination_identities`) and carry `owner`. Ignore packets with a foreign `owner`.
4. Liveness (`operator.alive`) is tracked per operator: Delta says "<Name> ist gerade nicht
   erreichbar" only when no operator in the room is reachable.
5. Own voice (pipeline mode): every operator speaks with its own Chirp3-HD voice, never
   Delta's. Wish via `--voice` (`vh.voice`) wins unless it is Delta's voice or taken.
   Otherwise the first free voice by rank: Leda, Zephyr, Orus (1st, 2nd, 3rd agent in the
   room), then Kore, Puck and the rest. Never assigned automatically: Fenrir, Aoede,
   Despina, Sulafat. The rank depends on join order, not on your identity: for the same
   voice across calls pass `--voice`. Your join greeting is spoken in your voice too.
   Solo call (the default): the voicebot speaks as you with ONE voice for the whole call,
   from its first word to the last (greeting, says, its own lines): your `voice`, without one
   the voicebot's own voice. More agents may join a solo call: each gets its own voice (its
   `voice` if free, else the first free one), and the voicebot speaks as each of them.
6. Live mode has ONE voice per session (`operator.say` is spoken by the model itself), so
   operators sound like Delta there.
7. Participants: Delta gets a self-replacing block "Im Call sind gerade: Oliver (Mensch),
   Claude · opus-5-5 (Agent), DeepSeek · v3 (Agent)" (names from `vh.name`/`vh.user`, model
   from `vh.model`) and a silent context entry on every join/leave, so it answers "Wer ist
   im Call?" from that list only.
8. Everyone reads everyone: a CLI join prints every `transcript` line, so the other
   operators' spoken lines (`role:"operator"`, `op` = their identity, `speaker` = their
   name) show up in its output. A bridge or MCP session gets them in `next` as
   `operators_said` (own lines filtered by `op`, lines without `op` skipped, `model` from
   the peer's `vh.model`). Never repeat what another operator already said.

### Several agents in one call: behaviour

Full rules with reasons and good/bad examples: https://voicehook.ai/agent/MULTI-AGENT.md
(short form, delivered as join `context.rules`: `web/agent/multi-agent-rules.txt`).
1. Read `operators_said` in EVERY `next` event (`timeout` included) or every `role:"operator"` line (CLI).
2. The addressed one answers, first: answer when `addressed_to_you` is true; false = stay silent and do nothing (the server decides once per turn: name or speech-recognition variant, mention, follow-up to the last agent, else `addressed_by` `all` = everyone).
3. A question another agent asks the human is the human's: never answer it yourself.
4. Delta addressed (`addressed_to` contains `"Delta"`, also heard as Data/Della/Delter): all agents stay silent, except a user event with `routed_by_delta: true` (a request for Delta alone; Delta never refuses, says "I'm checking that right now." and the request is yours, `addressed_to_you` is true).
5. Speak the call language strictly (`context.lang`; `next` type `context` brings a new one), greeting included.
6. Participants do not act: no code changes, deploys, edits or forwarding unless the human asks by name.
7. Check `operators_said`, `status` and `peers` before you claim what you receive or what is broken.
8. Short, only when needed; else stay silent and show your work on board (items stand alone, `emoji`) and feed.
Fields: see the bridge table above (`context`, `addressed_to`, `addressed_to_you`, `to_human`, `next` types `operator`
and `context`). Unspoken says of others are dropped when someone else was addressed (`say_status` `dropped`,
`revise` reason `addressed_other`), and Delta does not answer agents.

### Operator queue: a say never gets lost

The voice pipeline cuts the running output whenever the user starts talking or a user turn
ends. Users speak in fragments, so the server queues says:

1. Every `operator.say` goes into the operator queue. Only one say is handed to the voice
   pipeline at a time. It starts when the user is silent: `user_state` is `listening` for
   at least 600 ms.
2. If the user cuts a say off with real speech, the say ends there (`interrupted`,
   `reason:"barge_in"`, final, both modes): whoever interrupts wants to talk, so the voicebot
   or you answer the interjection; the unspoken rest is not replayed but shown as "Nicht
   gesagt" and brought up once in the next natural pause ("Noch kurz von vorhin: ...", never
   after the user said stop). A cut without confirmed user speech (noise, echo) puts the
   unspoken rest back at the FRONT of the queue (`requeued`; a rest under 15 characters
   restarts its whole sentence); after 3 such cuts it counts as real. `operator.interrupt`
   cancels your own says on purpose: no replay, you get `operator.revise`.
3. While anything of yours is queued or playing, a finished user turn gets NO answer
   of the voicebot's own (pipeline mode); the queue plays instead. The user's words stay in
   its context and reach you as usual.
4. User stop: when the user says "erledigt", "passt", "reicht", "genug", "stopp", "hör auf",
   "schon gut", "ok danke", "that's enough", "done" (a short turn, or a longer one that
   starts with it; questions like "Ist das erledigt?" do not count), the voicebot cuts the
   running say at once and drops EVERY queued say of every operator (`dropped`,
   `reason:"user_stop"`). Each owner gets one `operator.revise` with `reason:"user_stop"`
   and the dropped texts in `unspoken`. Do NOT send them again; speak next when the user
   says or asks something. The voicebot answers a pure stop with a short "Alles klar."
   (nothing in strict mode); new says are accepted right away.

### operator.say_status

One packet per state change of a say, keyed by its `seq` (the CLI's `_seq`, the bridge's
`seq`; a say without one gets `vh-<n>`):

```json
{"seq": 12, "state": "requeued", "spoken_chars": 31, "owner": "claude-ab12"}
{"seq": 13, "state": "dropped", "spoken_chars": 0, "owner": "claude-ab12", "reason": "user_stop"}
```

| state | meaning |
|---|---|
| `queued` | accepted, waiting for its turn (silence after the user, Delta and other says done) |
| `spoken` | played to the end; `spoken_chars` = length of the spoken text (also right after `interrupted` when the cut came after the last word) |
| `interrupted` | the user cut it off after `spoken_chars` characters (or `operator.interrupt`, or an `urgent` say of another operator in live mode). `reason:"barge_in"` = the user cut in (final, no replay); `reason:"no_user_speech"` = cut without confirmed user speech (noise, echo, own voice): it does not count as a user cut-in, the rest follows as `requeued` and does not use up the single replay (after 3 such cuts of one say it counts); `reason:"max_duration"` = emergency brake (below) |
| `requeued` | the unspoken rest is queued again and will be spoken (follows `interrupted` with `reason:"no_user_speech"`, pipeline mode only). A say the user really cut off is never repeated: `interrupted` is final and you decide what to say next |
| `replaced` | not started and dropped on purpose by your `overwrite`/`revise` (or `operator.interrupt` for queued ones) |
| `covered` | live mode only: your say arrived while the user had the floor, went to the model as context, and Delta already said its content in his answer, so it is not spoken again (final state) |
| `no_audio` | no audio started within the start limit (live: 4 s; pipeline: 10 s), e.g. the live model accepted the say but produced nothing. The voicebot retries exactly once (live: "speak verbatim now"); `spoken` or a second `no_audio` + `dropped` follows. `reason:"no_audio"` |
| `dropped` | thrown away by the voicebot, final; `reason` says why: `user_stop` = the user said stop/done (do not resend), `stale` = a replayed rest was cut again (resend only if it still matters, short and rephrased), `no_audio` = the retry produced no audio either (extra fields `text` = your say and `hint`; resend it if it still matters), `quota` = budget or credit used up, the call ends (extra `spoken:false`, `quota` = end reason) |

Every say is released within bounded time, so one stuck say never stalls the queue: no
audio within the start limit -> `no_audio`; once audio runs, an emergency brake of
2 s + 0.1 s per character (max 30 s) after audio start cuts it and reports `interrupted`
with `reason:"max_duration"`, even if the voice engine never reports it finished.

Live mode: every `say_status` also carries `verbatim:false` and `mode:"live_paraphrase"`; the voice phrases your text itself, so `spoken_chars` is no word count.

Every state is also logged by the voicebot (`[operator.say_status] seq=… state=…`).

The `operator.revise` text reads
`REVISE: Ersetzt, noch NICHT gesprochen: [1] ... Stattdessen eingereiht: [neu] ...` and
asks for one summary as `operator.say` with `mode:"overwrite"` if any of it still matters;
after `operator.interrupt`: `REVISE: Abgebrochen, noch NICHT gesprochen: ...`. A `revise`
with `reason` reports dropped says: `user_stop` (`STOP: ...`, never resend) or `stale`.

## operator.notice

Sent by the voicebot to everyone in the room (operator CLI and browser), at most once per
call per `kind`. The CLI prints it as a system line with `"topic": "operator.notice"` and
the `text` field.

`kind:"low_balance"`: the free allowance plus credit will last about `minutes_left` more
minutes at the current usage (estimated from the last 3 minutes). Fired when that drops to
5 minutes or less.

| field | type | meaning |
|---|---|---|
| `kind` | `"low_balance"` | notice type |
| `minutes_left` | int | rounded up |
| `seconds_left` | int | estimate in seconds |
| `free_s` | int or null | estimated seconds the free rest lasts at the current usage; `0` = free part used up, `null` = room has no free part or usage still unknown |
| `free_eur` | float or null | free allowance left today in EUR; `0` = used up, `null` = room has no free part |
| `balance_eur` | float or null | wallet balance; `null` = room has no wallet |
| `topup_url` | string | `https://voicehook.ai/aufladen` |
| `text` | string | what the voicebot says at the same moment: "Hey, Achtung, das Guthaben ist in wenigen Minuten leer." |

Operator: do not repeat the sentence; mention top-up once in your next `say`. Browser:
show a visible hint with a link to `topup_url`. The call ends when the free allowance and
credit are both used up (free first, then credit), with its own short announcement.

## cost

Running cost of the call for the browser subtitle, sent by the voicebot only when the
sum changed (silence sends nothing). `eur` is the customer price incl. VAT, the same amount
that is booked from the free allowance and the wallet. `mode` is `"pipeline"` or `"live"`.

## Transcript roles

The worker publishes everything that was actually spoken on `transcript`:

| role | meaning |
|---|---|
| `user` | final STT of the human (after the speech and speaker filters); `speaker` = the user's name when exactly one human is in the room and the name is known |
| `operator` | text from `operator.say`, published after it was spoken; on an interruption only the spoken part. `op` = identity of the sending operator, `speaker` = its name (`vh.name`). Browser: chip color of that operator |
| `agent` | the agent's own answer (auto mode), `speaker` = `"Delta"`. Browser: blue |

Pipeline mode matches operator lines by speech handle and text prefix; live mode only
by handle, because the realtime model rephrases.

`transcript.live` (separate topic, so the `transcript` echo semantics stay unchanged):
`phase:"start"` is sent when the first audio frame of an `operator.say` output plays,
with the full text and the speech `id`; `phase:"end"` when that output is done, with
`interrupted:true` if it was cut off. The browser shows the text from the start (word
reveal at speaking pace) and replaces it with the spoken part from `transcript`, marking
the unspoken rest. Live mode sends nothing here (the realtime model rephrases).
Operators should keep treating only `transcript` role `operator` as "spoken".

Note for CLI users: `--suppress-echo` in CLI 0.4.0 and older filters only
`role:"agent"`, so it does not hide `operator` lines.

## Live mode

A separate voicebot serves rooms created through:

| endpoint | result |
|---|---|
| `GET /api/live/status` | `{"available": bool}`, always 200, never amounts |
| `POST /api/live-room` `{identity, ttl_seconds?}` | host-call format `{token, url, room, identity}` plus `invite_url`, `expires_in`, `agent` |

Errors on `POST /api/live-room`: `404` switched off, `503` not configured, `402` live budget
used up, `429` rate limit.
The room stays bound to the live worker for every later dispatch, so an operator joins
with the invite URL as usual.

Live budget used up during a call: the agent says "Das Live-Budget für diesen Monat ist aufgebraucht. Ich beende den
Call." and ends it.

In live mode `operator.say` is not verbatim: it reaches the model as a marked user turn
("[Agent] Sprich jetzt diese Aussage. Übernimm ihren Inhalt vollständig ..."; "wörtlich:" =
word for word), and `operator.persona` is added as a "[System]" turn framed as knowledge.
The user's turn always comes first: a local voice detector tells the queue in real time that
the user speaks, and after about 0.3 s of his speech whatever is playing (your say or Delta's
answer) stops at once; a cut-off say is `interrupted` (final in live mode, not repeated). A say that arrives while the user has the floor (speaking, his turn still
open at the model, or Delta about to answer) goes to the model at once as a context turn
("[Claude-Info, bitte in deiner Antwort aufgreifen, falls passend] «…»", no reply
triggered), so Delta can weave it into his answer. It stays queued and is released by the
first of: Delta's answer ends (then it is spoken only if the answer does not already contain
most of its key terms; otherwise `covered`), the model closes the user turn without
answering (spoken 0.3 s later), or an emergency limit of 3 s after the user stopped.

## Server-side filters (pipeline mode)

- Speech filter: only audio that contains speech goes to speech recognition. Silence is
  not billed.
- Speaker filter: final segments of other speakers are dropped, so background voices (TV,
  neighbour) are ignored. Until one speaker has about 3 s of confirmed speech everything
  passes; the main speaker changes only if another speaker talks clearly more.
