openvibers/openvibe.chat README.md

README.md

main 89.0 KB · 1025 lines · markdown

1![OpenVibe.Chat](https://openvibe.chat/shared/social/chat.jpg)
2
3# OpenVibe.Chat
4
5**Open source chat for communities** — [openvibe.chat](https://openvibe.chat). Servers with text and voice channels, AI members on their own models, DMs, calls, Live's stream chat with TTS, moderation and presence — one identity, every conversation.
6
7**Live site:** https://openvibe.chat · **API docs:** https://openvibe.services · **For AI:** https://openvibe.chat/llms.txt
8**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
9
10Part of the [OpenVibe network](https://openvibe.network); [help](https://openvibe.help) and the [developer API](https://openvibe.services).
11
12**Status:** alpha — deployed. Since the cutover on 2026-09-23 at 02:03 UTC,
13Chat is the authority for openvibe.live's chat in every mode: the service runs on `openvibe-ovh`
14(unit `openvibe-chat`, 127.0.0.1:4400), Live runs no chat of its own (its `CHAT_AUTHORITY` switch was retired
15on 2026-10-11), mounts no local chat routes, reads Chat's internal read API for the chat data it needs, and chat events go to OpenVibe.Events. `openvibe.chat` is Chat's own
16site (global chat, messages, rooms,
17settings; the service manifest records it live since 2026-09-24).
18**Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat
19is also served on Live's origin: `https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`,
20`/api/tts/`, `/api/sounds`, `/api/emotes` via nginx.
21**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.
22**License:** AGPL-3.0 (same as every OpenVibe service).
23
24## Purpose
25
26The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while
27browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions.
28
29## What Wave 6 did
30
31The roadmap's method for this wave: **move the existing implementation behind a service boundary
32before changing behaviour.** Browser JavaScript does not change; nginx routes the chat paths here.
33
34- **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, every
35 message type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`,
36 `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /subonly /paste /ai`, `!sr !queue
37 !np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`),
38 DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,
39 `/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word
40 filter, the deploy-notice card. Changes are confined to where they touched Live's data.
41- **A channel room can be joined by its name** (2026-10-31): `join` takes `channel: '<username>'` next
42 to `channelUserId`, so a client that only knows the channel's name (the overlay page on
43 `openvibe.live`) sits in that channel's room — live or not — without a stream id. The name is
44 resolved to the channel's owner and cached for a minute. Naming a channel grants nothing: the
45 socket stays anonymous, exactly an anonymous viewer in that room.
46- **For bots on `/ws/chat`** (Maticus, 2026-10-26): a `!word` that is not a channel sound is sent to Live as
47 `button:<word>` for a signed-in viewer, so it presses the channel robot's button of that name, and says nothing when
48 there is none. A token in the socket's address (`?token=`) is never read; the socket gets a `system` frame with
49 `code: "token_in_url"` saying to send an `Authorization: Bearer` header or the join's `token`. When a channel goes
50 live again, sockets still on one of its ended streams (same slot) move to the new stream and get
51 `{ type: "stream_changed", stream_id, previous_stream_id, channel_user_id }`, so a bot stays in the viewer list,
52 TTS and sounds without re-joining (`live-context.onStreamLive`, from the active-streams sync).
53- **The TTS and sound queue** (`audio-queue.js`, table `audio_requests`). TTS, channel `!sounds` and
54 101soundboards clips are persisted requests played one at a time per room, across every Chat
55 process (a claim holds the room's advisory lock; a partial unique index allows one `playing` row
56 per room) — `queued → playing → played`, or `skipped` / `failed` — so the queue survives a Chat
57 restart (what waited plays once the room has listeners again, requests older than 5 minutes
58 expire) and a keyed request (the TTS of message `m<id>`) is read once. A playing row is owned:
59 `claimed_by` (the process's instance id, host and port or `CHAT_AUDIO_INSTANCE_ID`) and a
60 30-second `lease_until` its owner renews every 10 s. Only a dead owner's row is settled — its
61 lease passed, or it is the restarted process's own — at boot and by the heartbeat's sweep; a live
62 process's clip is never touched. Such a row is `played` if its delivery had started, otherwise
63 queued again in its old place, once: claimed a second time and dead again undelivered, it
64 `failed` (`attempts` counts claims), so a clip that crashes Chat cannot loop. The broadcaster and
65 moderators control it: `/skiptts [id]`, `/cleartts`, and `GET /api/tts/queue`,
66 `POST /api/tts/queue/skip|clear`, `POST /api/tts/queue/:id/report` (the playing client's
67 `played`/`failed`). Browsers get the same `tts-audio` / `soundboard-audio` frames as before, now
68 with `request_id` and paced by the clip's length, plus two new frames old clients ignore:
69 `audio-skip { request_id }` and `audio-clear { request_ids }`.
70- **One adapter to Live — `server/live-context.js`.** Accounts and roles, streams, slots and channels,
71 channel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, site
72 settings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware,
73 pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks
74 Live for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTL
75 caches that serve stale while refreshing) warmed when a socket joins, so a chat message never
76 waits on a network read. Live pushes invalidations when it changes cached data.
77- **Live integration.** Live serves `/internal/chat-context/*` and `/internal/chat-effects/*` and
78 has no chat switch. Its chat calls reach Chat through `/internal/chat/*`
79 (`docs/chat-ingress.md`).
80- **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new
81 rows, and a transactional events outbox. Chat's changes were copied into Live's tables by a read
82 mirror until 2026-10-05: since Live #31 nothing in Live reads its mirror-filled chat tables in chat
83 mode (every read goes to Chat's internal read API, `server/chat/internal-reads.js`), so the mirror —
84 and the cutover rollback through it — is retired. `server/db/database.js` serves
85 through `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, through
86 PgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without
87 `DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`).
88 `migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through
89 `ov_now()`/`datetime()`); `0006_stop_live_mirror_triggers.sql` dropped the twelve read-mirror capture
90 triggers, and `0009_drop_live_mirror_outbox.sql` drops `live_mirror_outbox` (a contract migration,
91 after 0008's SDK outbox: a held contract would hold every migration after it).
92 `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on Valkey instead of this process.
93- **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`,
94 `chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only
95 writer, there is no authority switch any more. Live's writers go through Chat's APIs below and the
96 Chat-owned ingress (`docs/chat-ingress.md`); the Live bridge they used before is retired (T3 J3a).
97- **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can
98 stop touching them at the flip:
99 - `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT),
100 `/search`, `/all/:streamId`, `/file/:filename`, `POST /api/emotes`, `PATCH/DELETE /api/emotes/:id`
101 — Live's exact paths, bodies, limits and error messages; uploaded bytes go to OpenVibe.Media with
102 Chat's own token (`media.object.upload` / `.delete`, namespace `chat`), the row stores
103 `media_url` + `media_asset_id`, responses use `media_url`. No local emote files (`/file/…` 404s).
104 `ffz`/`bttv`/`7tv` are cached provider proxies on fixed hosts.
105 - `GET /api/chat/channels/moderation/mine`, `GET/POST /api/chat/channels/:id/mods`,
106 `DELETE /api/chat/channels/:id/mods/:userId`, `GET/PUT /api/chat/channels/:id/moderation`,
107 `…/moderation/logs`, `…/moderation/chat-search`,
108 `POST …/moderation/messages/:messageId/delete` — owner/admin write, channel mods read.
109 - Live's read API for what Chat owns: `GET /internal/moderation/channels/:channelId`,
110 `GET /internal/moderation/users/:userId/channels`,
111 `GET /internal/moderation/channels/:channelId/emote-count` — loopback + service token,
112 capability `chat.moderation.read` (`chat.channel-moderation-result@1`,
113 `chat.moderated-channels-result@1`, `chat.emote-count-result@1`). Live caches each answer 30 s.
114 - Live's reads of Chat's message, moderation-queue and sound tables (plan T3 J4b):
115 `POST /internal/chat/stats`, `GET /internal/chat/messages`, `/timeline`, `/moderation/*`,
116 `/sounds` and `POST /internal/chat/sounds/asset` (`server/chat/internal-reads.js`; routes and
117 capabilities in `docs/chat-ingress.md`).
118 - Alert sounds: `playAlertSound(chatServer, streamerId, streamId, kind)` (`server/chat/alert-sounds.js`)
119 resolves the channel's own settings row, reads the clip and broadcasts it to the channel room.
120 - **The chat-AI summaries** (plan T3 step 2, decision 5) — Live's `server/ai/chat-ai.js` job moved
121 here: a background poller (off unless `CHAT_AI_ENABLED=1`) that folds Chat's own messages into
122 rolling global/per-user/per-relay/per-anon insights and the append-only timeline, writing
123 Chat's own `chat_ai_summaries`/`chat_timeline_events` tables. The model is OpenVibe.AI
124 called with Chat's own service token (`ai.run.create`/`ai.run.read`, audience `openvibe.ai`,
125 namespace `chat.*`, workflows `chat.global`/`chat.profile`); when AI does not answer the job
126 stores a deterministic extractive summary instead, so the routes always have something to serve.
127 Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`):
128 `GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`,
129 `/relay/:platform/:username`, `/timeline/:username`.
130 - **Person chat memory** (`server/ai/person-memory.js`) is a separate default-off worker
131 (`CHAT_PERSON_MEMORY_ENABLED=1`). It summarizes new messages into channel day records,
132 folds days into weeks and weeks into months, and keeps an own-view cross-channel rollup.
133 The `chat.person-memory` workflow in OpenVibe.AI routes model calls through Exchange;
134 the worker checks its $0.25 UTC-day spend ledger before calls (maximum 25 calls, 450 output tokens each).
135 `GET /api/v1/people/:username/memory?channel=&level=day|week|month` returns public
136 fields; the person and channel owner see the private fields. A person's `DELETE` erases
137 their records and opts out; `PUT .../preferences` with `{"opted_out":false}` opts back in.
138
139## How it fits the network
140
141| Concern | Where it lives | How Chat reaches it |
142| --- | --- | --- |
143| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own database on PostgreSQL (`DATABASE_URL` through `openvibe-sdk/db`; `migrations/` applies at boot); Live reads them through Chat's internal read API |
144| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
145| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
146| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | the Chat-owned ingress `/internal/chat/*` (`docs/chat-ingress.md`), presence `GET /internal/chat/presence` (`chat.presence.read`); the bridge `/internal/live/*` is retired (T3 J3a) |
147| The six chat tables (moderators, moderation settings, emotes, tags, AI summaries, timeline) | **Chat** (C-04 done) | Chat's own database; Live reads through `GET /internal/moderation/*` (`chat.moderation.read`), 30 s cached |
148| Emote image bytes | **OpenVibe.Media** (namespace `chat`) | `server/media/client.js` (openvibe-sdk `createObjectsClient`), Chat's service token (`media.object.upload` / `.delete`); the row keeps `media_url` + `media_asset_id` |
149| Chat-AI summaries (global / per-chatter insight + timeline) | **OpenVibe.AI** (namespace `chat.*`) | `server/ai/client.js` (openvibe-sdk `createAiClient`), Chat's service token (`ai.run.create` / `ai.run.read`); workflows `chat.global` / `chat.profile`; extractive fallback when AI does not answer |
150| Vibe's search (OpenVibe and the web) | **OpenVibe.Search** (Search Fabric, `GET /api/v1/query`) | `server/rooms/search-fabric.js` (openvibe-sdk/search 0.70), Chat's service token (Network grants `search.query.delegate` on `openvibe.search`), the person in `X-OV-Subject`; each result keeps its provider, date and URL; Search's `/api/v1/search` and Actor's web task are the fallbacks only when the fabric is down |
151| Vibe personas (name, glyph, hue, topic, avatar) | **OpenVibe.AI / Exchange** and **OpenVibe.Media** | `server/persona/index.js`: `ai.complete` with an explicit catalogue model and `chat.persona.image` for a picture, Chat's service token; each run's `usage.cost_usd` is kept in `vibe_persona_spend` under the owner's cap; avatar bytes go to Media (namespace `chat`) |
152| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
153| Events | **OpenVibe.Events** | `service_outbox` (openvibe-sdk outbox) → `POST /api/v1/events` when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set (`events.event.publish`); Chat's subscriptions deliver to `POST /internal/events` (`server/events/consumer.js`, `events.subscription.manage`) |
154
155Migration 0008 copies pending `events_outbox` rows into `service_outbox`. The old table stays through the N-1 rollback window; a later contract migration removes it.
156| A person's chat preferences | **OpenVibe.Network** user module `chat.preferences` (Chat owns the namespace) | `server/prefs/` → `GET/PUT/DELETE /internal/modules/chat.preferences/:subject` (`network.modules.read` / `.write`), cached per person |
157| Member badges | **OpenVibe.VIP** (Billing holds the entitlement) | `server/vip/badges.js` → `POST /api/v1/entitlements/check` with `product: 'chat'` (`vip.entitlement.check`), behind the shared product cache |
158
159### Data
160
161Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`,
162`dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`,
163`pending_ip_messages`, `stream_first_chats`, `moderation_actions`. Same columns and ids as Live, plus
164`subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` /
165`created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer).
166
167Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`,
168`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`.
169Live reads them through `GET /internal/moderation/*`.
170
171Stays in Live: `media_requests`, `media_request_settings`.
172
173### Events
174
175| Event | Visibility | When |
176| --- | --- | --- |
177| `chat.message.created` | public | every stored message in a public room (global, channel, stream) |
178| `chat.dm.created` | subject | every DM; the payload names the participants, never the text |
179| `chat.message.deleted` | public | every deletion of public-room messages: one message (moderation), a user's, an anon's or a relay user's history (self-delete, `/api/mod` purge), a time-range purge, and the auto-delete sweep. Only ids (`message_ids`, at most 500 per event), never the text, the author or who deleted it |
180| `chat.moderation.action` | internal | every moderation log row (chat commands and Live's `/api/mod`) |
181
182`chat.message.deleted` also carries `payload.redacts: { subject_type: "chat_message", subject_ids }`,
183which makes OpenVibe.Events rewrite the stored `chat.message.created` of each id into a tombstone
184(no text, no anon id, no author) on every read path, public SSE replay included. Every delete runs
185in one transaction with its outbox row, and the relay publishes in outbox order, so the deletion
186always follows the message it removes. Messages deleted before this existed are redacted by
187OpenVibe.Events' `scripts/redact-backfill.js`.
188
189#### Consumed
190
191Chat subscribes (consumer `chat`) to these topics (and `network.user.token_valid_after`, `vip.membership.changed`), delivered to `POST /internal/events`
192(`server/events/consumer.js`):
193
194| Event | From | What Chat does |
195| --- | --- | --- |
196| `live.release.deployed` | Live (`server/events/release-events.js`), subject `{ type: release, id: <head commit> }` | stores or folds the deploy card in global chat (`server/chat/deploy-notice.js`: one rolling card, folded while nobody has spoken in any room and within 3 hours; the broadcast carries the row id; late joiners get it once) |
197| `network.module.updated` | Network (`server/identity/module-events.js`) | for `chat.preferences`, a revision newer than the cached copy drops it (`prefs.handleEvent()`); an older or equal revision and other namespaces change nothing |
198| `network.block.changed` | Network (`server/identity/blocks.js`, platform blocks) | keeps the newest revision per (blocker, blocked) in `network_blocks` (`server/chat/network-blocks.js`); while a block is active neither person can start a DM with the other, message them in a 1:1, add them to a group or call them, and the DM user search hides them, exactly like `dm_blocks` (same errors) |
199
200- **One card per deploy** (compatibility register C-84, closed). The event is the only path: the
201 bridge op `deployNotice` is retired (T3 J3a); `deploy_releases.first_via` / `bridge_at` keep what
202 it recorded. The event claims the head commit in `deploy_releases` in the transaction that stores
203 or folds the card; a later delivery for that head finds it claimed and changes nothing but its
204 `event_at`. A redelivered event and Live re-announcing after a crash are the same card. A redeploy of a head already announced (a rollback to it) says nothing.
205 A release event older than 6 hours (an operator replay) is `ignored:stale`.
206- **Exactly once.** The openvibe-sdk inbox (`chat_event_inbox`, pruned after 35 days) claims
207 `(chat, event_id)` in the same transaction as the change, and broadcasts run only after it
208 commits. A failure answers 500 and rolls both back; Events retries.
209- **Signature v2 only** (`parseDelivery` with `requireV2`): HMAC over `"<t>.<raw body>"` under
210 `CHAT_EVENTS_SECRET`, `t` within ±300 s. A bad, stale, v1-only or unsigned delivery is 401; no secret
211 is 503; a request that came through nginx (a forwarding header) is 403.
212- **Subscriptions** are created at boot when missing (`server/events/subscriptions.js`: list, then
213 create; Events answers 409 for a duplicate, so it is idempotent), retried in the background while
214 Network or Events cannot answer. An existing subscription is left as it is, even disabled, so a
215 rollback survives restarts. `CHAT_EVENTS_SUBSCRIBE=0` turns this off. Needs `EVENTS_URL`,
216 `CHAT_EVENTS_SECRET`, `OV_OAUTH_CLIENT_SECRET` and the Network grant
217 `chat events.subscription.manage openvibe.events`.
218
219Platform blocks (WS-E task 5): Chat's own `dm_blocks` stay in force beside Network's. To carry them over
220once, export them as subject pairs and import those on Network (`scripts/import-blocks.js` there):
221
222```bash
223node scripts/migrate-dm-blocks-to-network.js [--resolve] # dry run: counts and unmapped Live ids
224node scripts/migrate-dm-blocks-to-network.js --apply --out /root/dm-blocks.json # write the pairs file (0600)
225```
226
227Mentions: Chat produces no mention notifications (the @mention highlight, tab flash and sound are drawn by
228Live's chat client from the broadcast message), so there is nothing in Chat for a block to suppress yet.
229
230```bash
231sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --dry-run # list them
232sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries
233sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --enable # undo it
234```
235
236`/ready` reports the consumer under `events.consumer` (received, applied, duplicates, ignored, refused,
237failed, the last type and outcome); `SELECT * FROM deploy_releases ORDER BY created_at DESC` shows
238which path delivered each deploy first and when the other one arrived.
239
240### Calls
241
242Live's voice/video calls moved here (roadmap WS-I task 1, `server/calls/`). They are served with `CHAT_CALLS=1`,
243which production has run since the calls cutover of 2026-10-10 (`docs/calls-cutover.md`):
244
245- **`/ws/call`** — Live's call server with the same protocol: `?channelId=` (or a stream number),
246 signalling only (full-mesh WebRTC; `offer`/`answer`/`ice-candidate` relayed, `mute`, `camera-off`,
247 `speaking`, `auth-update`, `force-mute`, `force-camera-off`, `kick`, `ban`, `unban`, `end-call`), the
248 permanent `public` lobby, one temporary channel per person (private for a 1:1 call), stream-linked
249 channels, 8 participants, 3 sockets per address, a one-minute kick cooldown, per-channel bans, empty
250 channels deleted after an hour. Sign-in is Chat's (as on `/ws/chat`); accounts, streams and cosmetics
251 come through `live-context`; the voice-channel list and call invites go out through Chat's chat server.
252- **REST on Live's paths** (`server/calls/routes.js`): `GET/POST /api/streams/voice-channels`,
253 `GET/DELETE /api/streams/voice-channels/:channelId`, `POST …/call-user` and `…/call-user/respond`,
254 `GET/PUT /api/streams/:id/call` (the streamer, checked through `live-context`).
255- **Live's stream hooks** (`server/calls/internal.js`, Live `CALLS_AUTHORITY=chat`):
256 `POST /internal/calls/stream-channel { stream_id, mode, user_id }` and
257 `DELETE /internal/calls/stream-channel/:streamId` — a Live service token with capability
258 `chat.live_bridge.write` (the name is from the retired chat bridge; Live holds the grant), loopback only.
259- **From a conversation on openvibe.chat** (`server/web/pages.js`, `public/web/ring.js`, `public/web/call.js`): a 1:1
260 conversation nobody blocked has **Call <name>**. It rings through `call-user` (its limits, block check and ring row)
261 and opens `/call/<channel>`, the call's page. The page is for its caller and the people it invited (404 to anyone
262 else), and it joins as it opens. While the caller waits it says *Ringing …* and listens on `/ws/chat` for the answer;
263 declined, busy or no answer leaves the call. Every signed-in page has `ring.js`: a ring shows who is calling with
264 **Answer** (tells the caller, opens the call page) and **Decline**. After 30 s it gives up with `no-answer`, and a
265 person already ringing or in a call answers `busy`. The invite reaches Live's chat sockets too, so a call can start
266 on one site and be answered on the other.
267- **Lifecycle** (`server/calls/lifecycle.js`, table `calls`): a ring is `direct`, `pending → ringing →
268 active → ended`, or `missed` (no answer within `CALL_RING_TIMEOUT_MS`, 45 s; the caller is told),
269 `declined` (declined or busy) or `failed` (the invite could not be delivered, with the reason); a
270 channel's occupancy is a `channel` / `stream` session, `active` while anyone is in, `ended` when it
271 empties or closes. A restart closes what was open (reason `restart`).
272
273### Rooms (openvibe.chat)
274
275Chat's authority resource index serves `GET /api/v1/resources` (with `project`, `kind`, `cursor`,
276and `limit` filters) and `GET /api/v1/resources/:ovrn` to first-party service tokens carrying
277`chat.resource.read`. It lists active rooms as `chat.room` summaries. Rooms belong to people,
278so they have no project ID or OVRN; the named-resource route always answers 404. The index is
279loopback only and is not forwarded by the public nginx vhosts.
280
281Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and
282Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`,
283and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people
284join. Private rooms: members only; to anyone else they answer exactly like a room that does not exist
285(read, join, post, members, attachments, the call). Unread counts per room (`last_read_id`) and
286`last_seen_at` per member (moderators see it).
287
288Kinds: **community** (anyone starts one), **call** (the room a call happens in: its text chat, and the
289voice/video channel `room-<slug>` on `/ws/call`; anyone starts one) and **system** (announcements
290written by chat staff, read-only for people; only chat staff create them). Roles, and what they may do
291(`rooms.access()` is the one place this is decided):
292
293| Kind | Role | Read | Post | Join the call | Talk | Moderate | Manage |
294| --- | --- | --- | --- | --- | --- | --- | --- |
295| community | owner / mod / member / viewer | all | owner, mod, member | – | – | owner, mod | owner |
296| call | owner / mod / speaker / participant / viewer | all | owner, mod, speaker, participant | all | owner, mod, speaker | owner, mod | owner |
297| system | owner / mod / viewer | all | owner, mod | – | – | owner, mod | owner |
298| any | blocked | – | – | – | – | – | – |
299| any | no role | public rooms only | – | public call rooms (listening) | – | – | – |
300
301Chat staff (`staff.moderation.chat`) read, moderate and manage every room, post in system rooms and talk in
302calls; a site ban stops posting and talking. Joining gives the kind's role: member, the call room's
303`join_role` (participant by default; the owner may pick speaker or viewer) or viewer. The owner (and chat
304staff) appoint mods; owners and mods set every other role; roles outside the room's kind are refused
305(`rooms.role_kind`). Role changes on someone else are logged (`room_block`, `room_unblock`,
306`room_mod_add|remove`, `room_speaker_add|remove`) through the moderation log. A change reaches open sockets at
307once: `/ws/chat` followers get `room_access { role, can }` (or `room_left`), and a running call follows the
308room (below).
309
310**Call rooms.** The call server makes the channel `room-<slug>` on first use and keeps it; it is not in Live's
311voice-channel list. Who may read the room may join its call; owner, mods and speakers talk; participants,
312viewers and people without a role (anonymous too, in public rooms) join listen-only: force-muted with the
313camera forced off, their own unmute ignored, and a moderator cannot lift it (make them a speaker instead).
314Peers see `roomRole` / `canTalk` on each participant. Promoting, demoting or blocking someone sends them
315`room-role` and the force-mute state, or drops them; a ban inside the call is also the room's block; making
316the room private drops everyone who is not a member. Media is peer to peer, so listen-only is enforced by
317clients honouring force-mute, as every force-mute here. openvibe.chat's room page carries an audio call
318client (`public/web/call.js`), shown when `CHAT_CALLS=1`; ICE comes from `GET /api/chat/ice-servers`
319(`server/net/turn.js`, Live's `turn.js` logic: STUN, plus TURN when `TURN_URL` is set with
320`TURN_AUTH_SECRET` or `TURN_USERNAME`/`TURN_CREDENTIAL`).
321
322**Attached to a Community space.** `POST /api/chat/rooms/:slug/attachments { service: "community",
323resource: <space slug>, title? }` (201 created, 200 already there), `DELETE
324/api/chat/rooms/:slug/attachments/community/:space` (idempotent), `GET …/attachments`. Only a person who
325manages the room (owner or chat staff), with their own Network token, attaches it (API tokens are refused);
326the room's managers or whoever attached it detach. Community keeps the link on the space and checks its own
327side (the space's owner or staff), so one person holding both ends makes the link, and no service grant is
328involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public
329claim on the room page.
330
331**AI members** (`server/rooms/ai-members.js`, `migrations/0012_room_ai_members.sql`; the owner's ask: friend groups
332and couples with AI members, and a Food household's chat with one that knows the pantry). A room's owner (or chat
333staff) adds up to 3: a name (its `@handle` is the name in lowercase), how it should be, and notes it always knows.
334When someone who may post writes `@handle`, it answers: the room's last 30 messages, its notes and any shared context
335go to OpenVibe.AI's `chat.member.reply` with Chat's own service token, attributed to the room
336(`{ service: chat, type: room }`), and the answer is a room message with `ai_member_id` set and `user_id` 0, shown with
337an AI badge, never as a person. Sockets get `room_ai_typing { room, name }` first. Only the first member named answers;
338it never answers an AI message; one answer at a time per room, 20 an hour from one person, and
339`CHAT_AI_MEMBER_REPLIES_PER_DAY` (100) a room a day, past which it says so instead. `CHAT_AI_MEMBERS=0` keeps them
340quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked to an OpenVibe.Food household
341(`link_service: food`) also gets the household's pantry and plan, read from Food's
342`/internal/households/:plan/chat-context` with Chat's service token (`food.household.read`, at most once a minute;
343`CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (people
344only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room
345member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
346account keeps the ordinary AI members it added (they belong to the room) without its subject on them, and exports them.
347
348**Actor agents in server channels** (`actor_members_enabled` in Chat owner settings, on by default; `CHAT_ACTOR_MEMBERS=1` forces it on): a server manager can attach one of their own
349OpenVibe.Actor agents to a channel from its AI members settings. Anyone who can post in that channel can mention the
350agent; Chat starts the owner's personal agent through `actor.agent.run`, records a durable room task, and follows it
351through the existing workspace poller. The answer returns as that agent's room message. Actor applies the owner's
352budget, daily cap and approvals. The settings form says the answer is public to the channel. Deleting the owner's
353account removes its attached Actor agents; ordinary room AI members remain. Network must grant Chat `actor.agent.run`
354and Actor must allow Chat to read only the personal agent tasks it started.
355
356**Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer
357is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may
358call `search_openvibe` (OpenVibe.Search's public results through the Search Fabric's network scope, `CHAT_SEARCH_URL`),
359`web_search` for Vibe (the fabric's web scope; Actor only when the fabric is down), `current_time`, `roll_dice`,
360`pick_one`, and for a member linked to a Food household `household_pantry`; Chat runs them (read-only, bounded, results
361at most 4,000 characters) and sends the results back, at most three rounds with four calls each, then a turn that must
362answer in text. If a turn fails (a provider without tools), the member answers with `chat.member.reply` as before.
363`CHAT_AI_MEMBER_TOOLS=0` turns tools off.
364
365**Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model group
366chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter,
367Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays).
368The key goes to OpenVibe.AI (`PUT|GET|DELETE /api/v1/credentials/:subject`, Chat as its owner, Network grant
369`chat ai.credential.manage`) and is never shown again, only a four-character hint and today's spend. A room's manager
370sets a member to think with their own key (`engine: own`; `openvibe` is OpenVibe's model): its answers are runs that
371name `credential { subject }`, so they reach that provider only, count against the key's budget and not the room's
372daily cap. The room page and the API (`model`) say which model each member uses ("openai (openrouter.ai) · gpt-4o-mini,
373Ann's key"). Removing the key (or the account) sends its members back to OpenVibe's model; a new model relabels them.
374API: `GET|PUT|DELETE /api/chat/me/ai-key` (own sign-in only, not API tokens); pages: `/settings#oc-ai-key` and the
375"Thinks with" choice on each AI member. A server's admins give a text channel AI members at `/r/<channel room>/settings`,
376linked from the channel on the server's settings page.
377
378**A model on their own computer** (AI on your own machines; contracts 0.220.0/0.221.0). The key may instead be provider
379`node`: a `machine` (a `dev_` id from openvibe.link/machines, or `any`, the fastest of theirs that is on) and a model,
380with no key and nothing spent. OpenVibe.AI reaches it through OpenVibe.Bot and the person's OpenVibe Link, only where they
381turned on "Let my AI use this computer's models".
382- **The form** shows only the fields the chosen provider takes (`chat.css` `:has()`). It comes prefilled from Bot's
383 "Use these models in OpenVibe.Chat" (`/settings?own=node&machine=dev_…|any&model=…`).
384- **A room** sees "llama3.1:8b on Fran's computer(s)" (`credentials.memberLabel`).
385- **Vibe** thinks with it when the person ticks "Vibe thinks with it too" (`vibe_own_engine`, migration 0035;
386 `GET|PUT /api/chat/vibe/engine`): every Vibe chat of theirs and each new one. There is no daily limit then. The page
387 says "Vibe thinks with … (change)", "How Vibe did this" says which model answered, and a computer that does not answer
388 is named, with what to check. Removing the key ends it.
389
390**Vibe personas** (`server/persona/`, `migrations/0078_vibe_personas.sql`; the owner, 2026-11-12). Each Vibe
391conversation gets a name and a face made from what it is about, and a new one when the topic changes. The shape is
392`persona { name, glyph, hue, image_url|null, topic, made_at }`, on every item of `GET /api/vibe/conversations` and on
393`chat` in `GET /api/vibe/chat` (null until the first answer, and past the cap). The name also names the chat until the
394person renames it. The first is made after the first answer; then every 8 messages (configurable) the cheapest chat
395model answers one word, `YES` or `NO`, to "has the topic changed?". The glyph is one name from
396`openvibe-shared/glyphs`; an invalid answer is retried once and then replaced from the registry. Auto models are the
397cheapest chat and image models in Exchange's catalogue; every call is `ai.complete` (or Chat's
398`chat.persona.image` workflow for a picture) under Chat's own grant, billed to Chat, with its `usage.cost_usd`
399recorded against the owner's monthly cap. Images are off by default and go to OpenVibe.Media. The owner page is
400`/admin/persona` (spend shown, model choices, interval, cap, remake one chat); the same values are the revisioned
401`chat.persona` namespace, and `GET|POST /api/admin/persona` + `POST /api/admin/persona/remake` are owner-sign-in
402only. `docs/vibe-personas.md` has the shape, settings and test notes.
403
404**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
405`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
406channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
407channel is a community room and a voice channel a call room (its call is `room-<slug>`), so messages, live delivery,
408unread counts, calls and AI members work in channels unchanged. `rooms.access()` asks the server for any room with
409`server_id`: the owner is the channel's owner, admins and moderators its moderators, members its members (in a voice
410channel: speakers by default, or listeners when the channel says participant); a public server is read by anyone
411and joined by signed-in people, a private one is a 404 to outsiders and joined only through an invite
412(`/join/<code>`: any number of uses or 1/10/100, for a day, a week, a month or forever, revocable). The owner appoints
413admins, admins appoint moderators and manage channels, settings and invites, moderators remove and ban members;
414nobody acts on their own rank or above, and every action is logged (scope `server:<slug>`). A server starts with
415`#general` and a voice Lounge; a person owns up to 5, a server has up to 50 channels and 25 live invites and keeps one
416text channel. A channel's room slug is random (`ch…`); its page is `/s/<server>/<channel>` and `/r/<slug>` goes
417there. Channels follow the server's visibility (going private redacts what they had published); the rooms API refuses
418room-level joins, roles and visibility there, and `/rooms` leaves channels out. The page: your servers down the side
419with unread counts, the channels, the channel (its call for a voice channel) and the people by rank; on a phone the
420servers run across the top and the channel list folds away. API: `/api/chat/servers` (list, create, read, update,
421delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:code`). Deleting an account removes its
422memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
423nobody is.
424
425Forum channels are available by default for a server manager who can connect a Community forum they moderate;
426the owner can disable them with `forum_channels_enabled`. Slash commands and custom server emoji are available by
427default and can be disabled with `CHAT_SLASH_COMMANDS_ENABLED=0` and `CHAT_SERVER_EMOJI_ENABLED=0`. Stage channels
428use Chat's voice rooms and are available by default; the owner can disable them with `stage_channels_enabled`.
429
430**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who
431may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the
432file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it
433to OpenVibe.Pics **as that person**: their own Network token, through MediaHub on this host
434(`CHAT_PICS_INTERNAL_URL`, `Host: openvibe.pics`, their address in X-Forwarded-For), so the picture is theirs on Pics
435(re-encoded without its metadata, reportable, deletable, inside their Pics limits). The room gets a message with the
436caption and the picture's link. Rooms and channels show OpenVibe.Pics direct links (`https://openvibe.pics/i/<slug>.<ext>`)
437as thumbnails (`/i/<slug>/thumb.webp`) linked to the picture's page, at most four a message, on the page and live
438(chat.js); no other host's images, and global and stream chat stay text. JPEG, PNG, GIF, WebP and AVIF up to
439`CHAT_PICS_MAX_BYTES` (20 MB); `chat.room.picture` 10 a minute and 120 an hour per person; API tokens cannot send
440pictures. Off (no form; the API answers 503 `pictures.off`) without `CHAT_PICS_INTERNAL_URL`.
441
442**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,
443`/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox
444(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
445`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block
446the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation
447counts unread messages, room messages and server messages. Everything is server-rendered and works without JavaScript.
448
449**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token
450and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and
451the only openvibe.live URLs are profile links. `test/live-down.test.js` renders every page and uses the
452forms, the core APIs and the socket with Live refusing connections and with Live hanging.
453
454### Workspace rooms and the staff room (openvibe.chat/r/staff)
455
456A workspace room has a **work queue** beside its conversation ([server/rooms/workspace.js](server/rooms/workspace.js),
457[server/web/workspace-pages.js](server/web/workspace-pages.js), migration `0027_workspaces.sql`): the team talks, asks
458`@vibe`, and everyone sees what is queued, being worked on and done. The **staff room** (`/r/staff`, seeded by the
459migration with Vibe in it) is OpenVibe staff's own: every member of staff (chat staff, `staff.moderation.chat`) becomes a
460member on their first visit, and nobody else reads it, whatever role they once had. **Any community room** becomes a
461workspace when its owner ticks "Work together here" in the room's settings (or `PATCH /api/chat/rooms/:slug`
462`{ "workspace": true }`), and "Start a room" offers a **Workspace** kind (preselected by `/rooms?new=workspace&name=…`, the
463link other OpenVibe sites use for "Work on it together"; `/work`, the shared navbar's "Work together", goes there); from those pages Vibe joins it, when there is room for one more AI member, to run and
464announce the work. Switching it off hides the queue and drops the shared screen; the staff room stays a workspace, and
465a call room, a server's channel or a chat with Vibe never becomes one.
466
467- **Adding work:** anyone who may post adds a task (title, kind, details) from the panel or the API, or asks Vibe, which
468 queues it with its `queue_task` tool (one task per job) and answers what is going on with `queue_status`.
469- **Quick kinds** (`answer`, `research`, `web`) run at once as OpenVibe.Actor tasks of the person who asked: Chat's
470 service token names them in `X-OV-Subject` (their allowance, their task list; Actor records Chat as `via`). A poller
471 follows them every 10 s.
472- **Other kinds** (`code`, `computer`, `other`) wait for a worker: a member (or an agent working with a member's API
473 token, such as the agent harness) claims one, then reports how it went and where it landed (an https link, such as a
474 pull request). Or the person who asked a `code` or `computer` task presses **Run on my computer**: it becomes their
475 own OpenVibe.Actor task, which hands code to OpenVibe.Codes on their machines (OpenVibe.Run's workers) and computer
476 work to their computer through OpenVibe.Bot, at the level they set there; without such a machine the task fails with
477 Actor's reason.
478- **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page
479 (`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket
480 sends `room_task`.
481- **Alerts into the room** (`server/rooms/hooks.js`, migration 0029). The room's owner makes up to five hooks on its
482 settings page: an address (`POST /api/chat/hooks/rh_…`) and a secret, shown once. A POST signed like an
483 OpenVibe.Events delivery (X-OpenVibe-Signature-V2 over the raw body, ±5 minutes; an OpenVibe.Watch webhook action with
484 the secret as a project `secret_ref` sends exactly that) becomes a message from the room's Vibe: a Watch firing
485 ("🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)" and the watch's link), an OpenVibe event, or `{ "text" }`. A hook
486 can also queue a task of a chosen kind for the person who made it, while they may still post there. Wrong or stale
487 signature 401, unknown hook 404, unreadable body 422, past 60 an hour 429; a repeated X-OpenVibe-Delivery-Id changes
488 nothing. Hooks are in their maker's export and go when the account does.
489- **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room.
490- **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot
491 (`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Members
492 see that computer's screen live above the conversation, and with a "use" link they can start it and click and type on
493 it (at the level the owner set on the computer). The link is a key to the screen, so only members get it (the page,
494 and `screen.url` in `GET /api/chat/rooms/:slug`). Pages allow `frame-src https://openvibe.bot`.
495- **Account data:** a person's tasks are in their export (`room_tasks.json`); on deletion the room keeps the work
496 without who asked or who took it.
497
498API (`/api/chat/rooms`, also in openvibe-sdk): `GET|POST /:slug/tasks`, `GET /:slug/tasks/:id`,
499`POST /:slug/tasks/:id/cancel|claim|run|report`.
500
501### Vibe, OpenVibe's assistant (openvibe.chat/vibe)
502
503The owner, 2026-10-24: "turn openvibe.chat into the main chat app", familiar like ChatGPT (plan track K,
504`OpenVibe_Consumer_Plan_2026-10-24.md`). `/vibe` is that app: the person's chats with Vibe down the left (Today,
505Yesterday, Previous 7 days…), the conversation in a readable column, one composer at the bottom, suggestions on a new
506chat, and the rest of Chat (Messages, Rooms and servers, Global chat) under the list. It works without JavaScript
507(forms post; the page shows Vibe's answer once written); `public/web/vibe.js` starts a chat in place, follows the room
508socket, sends on Enter and keeps a draft typed while signed out through the sign-in.
509
510- **Modes** (the composer's chips, plan track K): **Chat** answers here and now. **Research**, **Agent** and **Code** post
511 the message without Vibe's instant answer and make it a task of the chat (`vibe.work`, the workspace queue's machinery
512 in `server/rooms/workspace.js`) on the person's own OpenVibe.Actor allowance: research with sources at once; Agent on
513 their own computer through Node and Bot; Code with OpenVibe.Codes on their machine through Run. Vibe says it is on it
514 with the task's link, and the result comes back into the chat. `POST /api/chat/vibe { message, mode }` starts a chat
515 that way; `POST /api/chat/vibe/:slug/ask { message, mode }` asks in one; the forms post `mode` too.
516- **A chat is a private room** (`server/rooms/vibe.js`, `migrations/0015_vibe_chats.sql`: `rooms.assistant = 1`) the
517 person owns, with one AI member, Vibe, who answers every message without being named (`ai-members.js` afterPost).
518 Messages, pictures, the socket (`join_room`, `room_message`, `room_ai_typing`), export and account deletion are the
519 rooms'. Nobody can be added to it and it cannot be made public; it is not listed with rooms, does not count toward
520 the 10 rooms a person may own, and is not in the unread counts. A chat is named after its first message (40
521 characters, cut at a word); deleting one deletes its messages (not archived).
522- **Vibe's instructions** (`vibePrompt`): a private chat with the person, Markdown answers (rendered by
523 `public/web/vibe-md.js`, which escapes everything first and links only http(s)), the last 30 messages at 2,000
524 characters each, answers up to 8,000 characters (1,500 tokens). Tools: `search_openvibe`, `read_page` (a page's text
525 through OpenVibe.Tools' `read` tool, `CHAT_TOOLS_URL`; Tools decides what may be reached), `web_search` (the open
526 web through the Search Fabric's web scope: Search's open index, then its paid/free providers, each result with its
527 URL, provider and date; `server/rooms/search-fabric.js`, `server/rooms/web-search.js`; Chat's service token has
528 `search.query.delegate` and names the person in `X-OV-Subject`; `CHAT_SEARCH_URL`; Vibe added to a room has it too),
529 the time, dice, picks. The pre-fabric paths stay only as fallbacks: `/api/v1/search` for the network scope, and an
530 OpenVibe.Actor web task (`CHAT_ACTOR_INTERNAL_URL`; Network grants `actor.task.create`/`actor.task.read`) when the
531 fabric is down. It says plainly what it cannot do yet (make pictures, use the person's computer, reminders).
532- **Pictures** (`pictures.forModel`, `ai-members.js` `picturesFor`): an AI member (Vibe included) sees the
533 OpenVibe.Pics pictures a message links, or those of the asker's message just before it (picture first, then the
534 question), at most two, fetched from Pics on this host (Host `openvibe.pics`, at most 4 MB each) and sent as image
535 parts with the question. The app's composer has a picture button (or paste one); a new chat can start with a
536 picture: `POST /api/chat/vibe/picture` (the body is the picture, the question in `X-Caption`) creates the chat and
537 posts the picture through Pics as the person; a picture Pics refuses leaves no chat behind.
538- **Limits:** `CHAT_VIBE_REPLIES_PER_DAY` (100) answers a person a UTC day across all their chats (`vibe_usage`;
539 deleting a chat gives none back), 60 messages an hour, 500 chats kept (starting one more deletes the oldest), the
540 rooms' post limits. `CHAT_VIBE=0` turns Vibe off (it also needs AI members on and Chat's OAuth secret).
541- **API:** `GET /api/chat/vibe` (chats, allowance), `POST /api/chat/vibe { message }` → 201 `{ room, message }`,
542 `PATCH /api/chat/vibe/:slug { name }`, `DELETE /api/chat/vibe/:slug`; then the rooms API for messages. People only
543 (an API token gets 403).
544- **Model comparison:** `POST /api/vibe/ask { message, models: [id, ...] }` runs one to three AI catalogue models in
545 parallel on the person's own AI allowance and keys (`on_behalf_of`). `GET /api/vibe/chat` groups their answers under
546 the question with model, provider, cost, elapsed time and run id. `POST /api/vibe/pick` keeps one model for follow-ups;
547 `POST /api/vibe/prefer` submits a pairwise preference to Bench. `GET /api/vibe/models` fills the model sheet. Auto
548 still uses Vibe's existing engine and tools. `GET|POST /api/vibe/import` brings unsealed Exchange chats once through
549 its scoped service export; `POST /api/vibe/import/dismiss` hides the notice. The import needs the
550 `exchange.chat.export` grant to Chat's service client.
551
552### Support inboxes and the website widget (openvibe.chat/inbox)
553
554The owner, 2026-10-24: one chat system "from your private conversations to your random live chat widget on websites",
555for people and for companies, with "direct live support with an agent", "fully modular and SDK and API"
556(`docs/platform.md` has the comparison with Intercom, Crisp, Chatwoot, Stream and the order of work). An **inbox**
557(`server/inbox/`, `migrations/0016_support_inboxes.sql`) is a team's support desk that any website opens with one tag:
558
559```html
560<script src="https://openvibe.chat/widget.js" data-inbox="inb_…" async></script>
561```
562
563- **The widget** (`public/web/widget.js`, under 3 KB compressed): a bubble in a shadow root (the site's CSS cannot
564 reach it) that opens the panel `openvibe.chat/embed/inbox/:id` in a frame on first use. `data-position="left"`,
565 `data-color`, `data-open="1"`. From the page: `OpenVibeChat.open()`, `.close()`, `.toggle()`; events
566 `openvibechat:open`, `:close`, `:message` (an answer arrived; the bubble shows a dot while closed). On phones the
567 panel is full screen.
568- **The visitor** needs no account: a random token (24 bytes) kept in the frame's own storage, stored only as its
569 SHA-256, resumes their conversation; the page they wrote from is kept with it. The panel says who answers (Vibe, a
570 person, waiting) and has "Talk to a person" while Vibe answers.
571- **Vibe answers first** when the inbox has it on: from the inbox's instructions and up to 10 of its pages
572 (`read_page`, those hosts only), with the conversation so far; it never invents prices, policies or order details,
573 and ends with `[HANDOFF]` (not shown) when a person should take over, which makes the conversation `waiting`. At
574 most 30 answers a conversation and `CHAT_INBOX_AI_REPLIES_PER_DAY` (500) an inbox a UTC day; then it waits for a
575 person. A person's reply takes the conversation (`open`) and Vibe stays quiet in it; resolving it closes it, and the
576 visitor's next message starts a new one.
577- **The inbox's people** (an owner and up to 50 agents, added by username; a person makes at most 10 inboxes) work at
578 `/inbox/:id`: conversations down the left (Active, Waiting, Resolved), live over the chat socket (`inbox_watch`),
579 replies on Enter. The owner's settings page shows the tag, the greeting, colour, Vibe's instructions and pages, and
580 **allowed sites**: once named, only those sites may frame the panel (`frame-ancestors`) or call the visitor API;
581 until then any site may.
582- **A site assistant** is an inbox whose people do not answer (`handoff` off in its settings): Vibe answers alone, there
583 is no "Talk to a person", Vibe is told nobody else answers (it points to the contact details in the instructions
584 instead of handing off), and when it cannot go on the visitor is told plainly. An inbox needs Vibe or its people.
585- **The visitor has an inbox too:** in any site's panel, "Keep this conversation: sign in" signs them in to OpenVibe
586 (the same ticket window as a room on a site; the token stays in the panel's own storage, sent as `Authorization`).
587 Their conversation moves to their own token for that inbox (derived from their account, never stored), so it
588 follows them to every device, and every conversation they had with any site is listed under **Support** in their
589 openvibe.chat Messages (`/support/:cid`: the thread, live over `inbox_watch`, and a reply box; writing to a
590 resolved one starts a new conversation with that inbox; apps: `GET /api/chat/support`, `GET|POST
591 /api/chat/support/:cid/messages`). The account comes only from the request's credentials. A
592 site's verified identity, when there is one, still decides the token. Export lists these conversations; deletion
593 leaves them with the inbox's team, without the person.
594- **Who answers** (`server/inbox/routing.js`): replying takes a conversation, and any of the inbox's people can assign
595 it to another ("Answered by", `PATCH /api/chat/inboxes/:id/conversations/:cid { assignee_id }`). With **take turns**
596 on (`route: round_robin`), a visitor who starts waiting goes to the person assigned longest ago, and only they are
597 notified. The list has a **Mine** tab (`?status=mine`) and shows who has each conversation.
598- **Business hours** (`server/inbox/hours.js`; settings, or `PATCH { hours: { tz, days, start, end } }`, null for
599 always): outside them the panel says when the team is back ("tomorrow at 9 AM (Los Angeles time)"), a handoff says
600 so, and Vibe tells visitors a person replies when the team is back. Notifications still go out.
601- **Verified visitors** (for a site with its own accounts): the owner turns them on in settings, which makes an
602 identity secret (`POST /api/chat/inboxes/:id/identity { action: on | rotate | off }`) to keep on their server. For a
603 signed-in visitor the site adds `data-user-id`, `data-user-name`, `data-user-email` and `data-user-hash` (the
604 HMAC-SHA256 of the id with the secret, in hex) to the tag, or calls `OpenVibeChat.identify({ id, name, email, hash })`.
605 When the hash checks out, Chat trusts the name and email, the team sees "verified" with the email, and the visitor's
606 token is derived from their id (`CHAT_INBOX_IDENTITY_KEY`, else Chat's OAuth secret; never stored), so their
607 conversation follows them to every device. A wrong or missing hash is just an anonymous visitor. Agents never see the
608 secret.
609- **Webhooks** (`server/inbox/webhooks.js`; up to three an inbox, the owner's; https on the public internet only):
610 any address gets every event as JSON, `{ event: { id, type, source: 'chat', occurred_at, data } }` with `type`
611 `chat.inbox.conversation.created | .waiting | .resolved | .rated` or `chat.inbox.message.created`. Each is signed like
612 an OpenVibe.Events delivery with the webhook's secret (shown once), so `parseDelivery(raw, headers, secret,
613 { requireV2: true })` from `openvibe-sdk/events` checks it. A Slack (`hooks.slack.com`) or Discord
614 (`discord.com/api/webhooks`) address gets a readable line instead, when a visitor waits for a person, writes while
615 waiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); each
616 webhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks
617 { url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`.
618- **Actions** (`server/inbox/actions.js`; up to five an inbox, the owner's; https on the public internet only): Vibe
619 asks the team's own systems while it answers, like looking up an order. The owner names one (`look_up_order`), says
620 what it does and when to use it, lists what it needs (`order_id: the order number`) and gives an address; Vibe sees it
621 as the tool `action_look_up_order`, asks the visitor for what it needs, and Chat POSTs `{ event: { type:
622 'chat.inbox.action', data: { action, arguments, inbox, conversation: { id, page_url, verified, visitor_id,
623 visitor_email } } } }` signed with the action's secret (shown once; `parseDelivery` checks it). `visitor_id` is the
624 site's own id for a verified visitor, else null, so the site answers only about that person's orders. The answer,
625 `{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST
626 /api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`
627 ("Try it" in the settings), `DELETE /:id/actions/:actionId`.
628- **Voice calls** (call escalation; `CHAT_CALLS` on): one of the inbox's people presses Call on a conversation (`POST
629 /api/chat/inboxes/:id/conversations/:cid/call` → `{ channel }`). The thread says so, and the visitor's panel offers
630 "Join the call" or "Not now". The call is `support-<conversation id>` on `/ws/call`, audio and peer to peer
631 (`public/web/support-call.js`), at most four people. The visitor joins with their visitor token (a `support-visitor`
632 message); the inbox's people join as themselves; anyone else gets "not found". Either side ends it for everyone
633 (`DELETE …/call`, or the visitor's `POST /api/chat/widget/inbox/:id/call/end { token }`), and resolving the
634 conversation ends it too. In the call the visitor may **share their screen** (the browser's own picker; not on
635 phones): the team sees it above the call bar, with a full-screen button. A conversation carries `call: true` while one
636 is open. The widget's frame may ask for the
637 microphone (the browser still asks the visitor).
638- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished
639 conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:
640 7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet
641 longer than that go, with their messages and translations; a visitor waiting for a person or a conversation a person
642 has open never does. The owner downloads everything (the 5,000 newest conversations with their messages): `GET
643 /api/chat/inboxes/:id/export?format=csv|json`, or the settings page's links. In the CSV, one row per message, a cell
644 that would run as a spreadsheet formula starts with `'`.
645- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
646 /api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
647 OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
648 inbox show inline (only openvibe.pics, at most four a message). **A visitor signed in to OpenVibe** sends one the
649 same way from the panel's picture button (`POST /api/chat/widget/inbox/:id/picture`, the body is the picture, their
650 token in `X-Visitor-Token`, a line in `X-Caption`): it goes to OpenVibe.Pics as them, and their messages carry
651 `account: true`, so only their Pics links show as pictures. **Vibe sees them too:** the newest picture in a
652 signed-in visitor's latest messages (two at most) goes to the model as an image with their words. Anonymous visitors are asked to sign in (403
653 `inbox.sign_in_to_attach`) until Pics scans anonymous uploads, and a link one pastes stays a link. Wherever a
654 message's pictures show, their links are not repeated as text, and lists say "📷 Picture".
655- **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader's
656 language (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message and
657 language, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's
658 (`POST /:id/translate { text, to }`). OpenVibe.AI translates; `CHAT_INBOX_TRANSLATIONS_PER_DAY` (300) new ones an
659 inbox a UTC day, 30 a minute a person.
660- **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE
661 /:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter or
662 Tab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name.
663- **Ratings:** a resolved conversation asks the visitor "How was this?" (👍 / 👎, then a line if they like:
664 `POST /rating { token, rating: 1 | -1, comment?, conversation? }`); the people see it in the list and the
665 conversation, live (`inbox_rating`).
666- **Nobody misses a visitor:** when a conversation starts waiting (the visitor asked for a person, Vibe handed off,
667 or Vibe is off) or a visitor writes while it waits or is open, and none of the inbox's people has it open live, each
668 of them (or only whoever took the conversation) gets an OpenVibe notification through the Network
669 (`server/inbox/notify.js`; Chat's service token, `network.notifications.push` as service `chat`), at most one per
670 conversation every 10 minutes except when it starts waiting. `CHAT_INBOX_NOTIFY=0` turns it off.
671- **Build your own panel** with the visitor API, which answers any site without cookies (CORS open, the token is the
672 key): `GET /api/chat/widget/inbox/:id` (name, greeting, colour, whether Vibe answers), `POST /visit { token? }` → `{
673 token, conversation, messages }`, `GET /messages?after=` (token in `X-Visitor-Token`), `POST /messages { token,
674 message, page_url?, name? }`, `POST /handoff { token }`. Visits 60 a minute, messages 20 a minute.
675- **Agents API** (signed in, or an API token acting as its person): `GET/POST /api/chat/inboxes`, `GET/PATCH/DELETE
676 /api/chat/inboxes/:id` (the answer carries `embed.script`), `POST /:id/members { username }`, `GET
677 /:id/conversations?status=active|bot|waiting|open|resolved`, `GET|POST /:id/conversations/:cid/messages`, `PATCH
678 /:id/conversations/:cid { status }`, `POST /:id/conversations/:cid/read`. Socket: `{ type: 'inbox_watch', inbox }`
679 (a person of the inbox) or `{ type: 'inbox_watch', inbox, conversation, visitor_token }` (the visitor), then
680 `inbox_conversation`, `inbox_message`, `inbox_status`, `inbox_typing`.
681- Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in
682 other inboxes stay, signed "A former agent".
683
684### A chat room on any website (the widget's room mode)
685
686The same tag puts a **public room** on any site (`server/web/room-embed-pages.js`, `public/web/room-embed.js`): as a
687bubble, `<script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script>`, or inside an element of the
688page, `data-target="#chat" data-height="520"`. The owner's room settings give both tags. Only public rooms embed (not
689private ones, Vibe chats or a server's channels), and the frame may sit on any site. Anyone reads, live over the chat
690socket (`join_room`); people sign in with OpenVibe to write, and the room's moderation and OpenVibe's global standard
691apply because posting goes through `rooms.post`.
692
693Signing in from a frame: browsers give a frame on another site no openvibe.chat cookies, and the sign-in window loses
694its link to the frame on openvibe.network (Cross-Origin-Opener-Policy). So the frame makes a random ticket, opens
695`/embed/signin?ticket=…`, and asks `POST /embed/ticket` until the window has bound the ticket to the person's session
696(two minutes, once). The token lives in the frame's own storage, which the browser partitions by the embedding site.
697"Sign out" forgets it.
698
699### Feeds: vibe coding, live (openvibe.chat/feeds)
700
701The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built
702into the openvibe.chat system". A **feed** (`server/feeds/`, `migrations/0017_feeds.sql`) is live activity a person
703publishes into Chat; vibe coding, moved from OpenVibe.Live (its `server/vibe-coding/`, same canonical events), is the
704first kind. Viewers see a card (`public/web/feed-view.js`): turns of a prompt, the steps taken (tools used, files
705changed) and the answer, the newest open, a live dot while steps arrive.
706
707- **Where it shows:** in a room its owner owns (above the messages), on the owner's Live channel slot while it is live
708 (as Live's `vibe-coding` frame on the stream's chat, which Live's channel widget reads), and on any site:
709 `<iframe src="https://openvibe.chat/embed/feed/fed_…">` (`?theme=light|dark`; it tells the parent its height,
710 `{ ov: 'chat', type: 'feed-height' }`).
711- **Publishing** with the feed's key (`ovf_…`, shown once, stored as its SHA-256; a new one on request):
712 - **Claude Code**: the feed page gives a `.claude/settings.json` block of `http` hooks (SessionStart,
713 UserPromptSubmit, PostToolUse, Stop, SessionEnd) that post each hook's JSON as it is to
714 `POST /api/chat/feeds/:id/hooks/claude-code` with `Authorization: Bearer $OPENVIBE_FEED_KEY`. A prompt, a tool
715 used (an edit is a file event), the turn's answer (`last_assistant_message`); paths become relative to the
716 session's folder; nothing is read from the transcript; the answer is always `{}`, so it never blocks Claude Code.
717 - **Anything else**: `POST /api/chat/feeds/:id/events` with one event, `{ events: [...] }` (at most 50) or Live's
718 `vibe-coding.event` envelopes; `POST /:id/hello { sessionKey, publisher }`. `eventType` is `session.status`,
719 `prompt`, `response`, `thinking`, `tool.call`, `file.change` or `file.save`; a short `{ eventType, text }` works.
720 600 events a minute a feed.
721- **Safety:** every event is scrubbed on the way in: API keys and tokens (OpenAI/Anthropic, GitHub, Slack, AWS,
722 Google, JWTs, OpenVibe's own), `Bearer …`, `PASSWORD=`-style values, credentials in URLs, private keys, and home
723 folders (`/home/<name>` → `~`). What a viewer sees is decided when they see it by the feed's settings (Live's
724 projection): prompts, answers, tools, what the tools were given (off by default), files (names only by default),
725 thinking (off by default), detail, paused. The **delay** is the server's: neither the socket nor the history shows
726 a step before it (Live sent the delay to the browser).
727- **API:** anyone: `GET /api/chat/feeds/:id` (what a viewer sees now), `GET /api/chat/feeds?channel=<username>` or
728 `?slot=<id,…>` (the feeds on Live channel slots, what Live's chat asks); the owner: `GET /api/chat/feeds/mine`,
729 `POST /api/chat/feeds { title, room?, slot?, settings? }` → `{ feed, key }`, `PATCH /:id`, `POST /:id/key`,
730 `DELETE /:id`. The public and publishing routes answer any site without cookies. Socket: `{ type: 'feed_watch',
731 feed }`, then `feed_event { feed, event }`. A feed keeps its newest 5,000 events; at most 10 feeds a person.
732- **From Live:** `scripts/import-live-feeds.js --file <export.jsonl> [--apply]` copies each slot's feed (settings,
733 sessions, its newest 5,000 events, scrubbed) and verifies the copy; Live then drops `vibe_coding_*`.
734- Export and account deletion cover feeds (never the key's hash).
735
736### VIP member badges
737
738A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP
739badge: a perk of the member's plan **version** with a `chat badge` product binding (VIP's network
740perk `subscriber-badge` has one), unless the member turned it off in VIP (`show_badge`). The frame
741gets `vip_badge: { creator, perk, name, badge, label? }` and the row's `metadata` keeps it, so history
742shows it. `badge` is a short id (`subscriber`, else `member`) and `label` plain text: binding config is
743written by creators, so nothing else of it passes and clients render both as text.
744
745- **Never blocks sending.** The badge comes from the cache only (`peekEntitlement`); the lookup starts
746 when a member joins a room, so their first line normally has it. On a miss the message goes out
747 without a badge and, if the lookup then finds one, a `chat_vip_badge` frame `{ id, vip_badge,
748 stream_id, channel_user_id }` follows to the same rooms (clients attach it by message id, as with
749 `chat_translation`) and the row's metadata is updated.
750- **Fails closed.** VIP unreachable or slow, no client secret, a refused grant, a malformed answer, an
751 inactive or `unknown` entitlement → no badge. `CHAT_VIP_BADGES=0` turns lookups off.
752- **Convergence bound.** Chat does not subscribe to VIP's events yet, so when a membership ends (Billing's
753 `billing.entitlement.changed` → VIP's `vip.membership.changed`) the badge stops at most
754 `CHAT_VIP_BADGE_TTL_MS` (60 s) after VIP stops granting, never past the entitlement's `expires_at`;
755 end to end, add VIP's own bound (seconds with events, at most `VIP_PROJECTION_MAX_AGE_MS` without —
756 OpenVibe.VIP README, "The product cache and the convergence bound"). A new member's badge appears
757 within `CHAT_VIP_BADGE_DENY_TTL_MS` (30 s). `badges.handleEvent()` drops a pair at once, for when
758 `vip.membership.changed` is added to the Events consumer. `test/vip-badge.test.js` drives all of this against a stub VIP with an
759 injected clock.
760- Needs the Network grant `chat vip.entitlement.check openvibe.vip`; without it VIP answers 403 and
761 nobody gets a badge. The client is vendored from OpenVibe.VIP (`server/vip/vip-client.js`, copied
762 at VIP 2accbeb) until VIP publishes a tag to pin.
763
764### Chat preferences
765
766`GET /api/chat/preferences` and `PUT /api/chat/preferences { preferences: { … } }` (a patch; `null`
767removes a field; optional `If-Match: <revision>`) read and change the signed-in person's chat
768preferences. They live in OpenVibe.Network as the user module `chat.preferences` (openvibe-contracts
769namespace, schema v1: `timestamps`, `compact`, `font_scale` 0.75–2, `show_badges`, `hide_emotes`), which
770Chat owns since the cutover; Network validates, versions (revision, the ETag here) and announces each
771change as `network.module.updated`. Chat reads and writes with its service token (Network grants `chat
772network.modules.read` and `network.modules.write` on `chat.preferences`) and names the revision it read on
773every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser
774If-Match is strict: 412).
775
776Three more modules work the same way, with `{ settings: { … } }` as the body (openvibe-contracts 0.41.0):
777
778| Route | Module | Fields | Enforced by |
779| --- | --- | --- | --- |
780| `/api/chat/tts-settings` | `chat.tts_defaults` v2 | `send`, `send_while_live`, `volume`, `sounds`, `sound_volume`, `sources` | Live's chat panel |
781| `/api/chat/dm-settings` | `chat.dm_settings` | `new_conversations` (`everyone`/`nobody`), `group_invites`, `previews` | the DM routes. `nobody` refuses a new direct conversation, but an existing one stays open; `group_invites: false` refuses groups (`dm.not_accepting`, `dm.no_group_invites`) |
782| `/api/chat/presence` | `chat.presence_prefs` | `show_in_user_list` | the user list. A hidden person is counted in `hiddenCount`, not named |
783
784openvibe.chat's Settings page edits all four. When Network cannot answer, an enforcement check uses the defaults, so an outage never locks anyone out.
785
786- **Cache.** One entry per person for `CHAT_PREFS_TTL_MS` (60 s). Chat's own writes land in it at once; a
787 change made through Network directly (the person's account page) shows at once: its
788 `network.module.updated` reaches the Events consumer, and `prefs.handleEvent()` drops the cached copy
789 when the revision is newer (without the subscription, within the TTL). Network down: the cached copy
790 with `stale: true`, else 503; writes 503.
791- API tokens read only. A person Live knows without a Network subject gets 409 `prefs.subject_unknown`.
792## Running it
793
794```bash
795npm install
796cp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATH
797npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready (CHAT_CALLS=1: /ws/call, /api/streams/…)
798npm test # Node 22; stub Live and Network in-process
799npm run test:pg # the same on PostgreSQL + PgBouncer + Valkey (OV_TEST_PG_URL, OV_TEST_PG_DIRECT_URL,
800 # OV_TEST_VALKEY_URL: openvibe-sdk scripts/test-services.sh up)
801node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones)
802node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md)
803N1_LIVE_REF=<live sha> npm run n-1:record # after a deploy: the N-1 fixtures from the deployed commit
804```
805
806`test/n-1.test.js` (roadmap WS-P task 11, in `npm test`) runs the previous release's clients against
807this one and its SQL against this schema, from `test/fixtures/n-1/`: every call Chat's pages, the
808openvibe.chat pages' links, scripts and forms, and Live's chat widget, messenger and pickers make
809(status, JSON, the fields they read), the `/ws/chat` messages Live's chat sends with the replies it
810reads, and every statement the previous release runs, which must still prepare after this release
811migrated a database the previous one created. After each deploy,
812record the release now in production as the next N-1 (`npm run n-1:record [ref]`, with `N1_LIVE_REF`
813the Live release in production, from a Live checkout at `N1_LIVE_REPO`, default `../OpenVibe.Live`)
814and commit the fixtures.
815
816`/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 only
817when the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded when
818the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed
819its own interval by that much. It also reports whether events are relayed and the Events consumer's
820counters.
821Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
822`/etc/openvibe/chat.env`. The listener on 127.0.0.1:4400 belongs to `openvibe-chat.socket` (with the
823`openvibe-chat.service.d/socket.conf` drop-in): HTTP requests that arrive during a restart wait for the new process
824instead of getting 502s (open WebSockets still reconnect); ovhost restarts the service, never the socket.
825
826Request telemetry: counted by route template, method and status class, not raw URL; `/shared` files,
827static assets, health, readiness and `/metrics` are not counted. One `http.request` sample per key a
828minute, plus the active-requests, p95 latency and event-loop-lag gauges, goes to `telemetry_samples`
829(openvibe-sdk 0.46, `platform.telemetry-sample@1`; `migrations/0011_telemetry.sql`), pruned daily past
83014 days. `server/app.js` mounts the SDK's `telemetryMiddleware` (a no-op until init); `server/index.js`
831calls `telemetry.init()` at production start, runs the retention and flushes on stop.
832
833### Per-actor limits
834
835The REST API also limits who calls it (`server/net/actor-limits.js`, openvibe-sdk/limits, roadmap
836WS-R task 4). A person counts as `user:usr_…` whether they call with their Network token or an `hbt_`
837API token (a bot counts as its owner); anyone else by their address. Browsers call these routes
838themselves, so no service speaks for many visitors here; Live's service calls (the ingress at
839`/internal/chat`, the stream hooks at `/internal/calls`) carry every viewer's chat and are never
840limited. The per-address `/api/` limit and the chat flood controls (the socket's: a message a second, and
841the same line at most 3 times in 30 s in a room, with punctuation-only lines counted as one and commands and
842the room's moderators exempt; DMs 10 a minute and 5 new conversations an hour, rooms 6 every 10 s, 6 rings a
843minute) stay. Past a limit: `429`
844problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
845`chat_rate_limited_total{limit,window}`.
846
847| Routes | Per caller |
848| --- | --- |
849| Reads of each API (`/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds`, `/api/emotes`, `/api/streams`) | `CHAT_LIMITS_MINUTE` / `CHAT_LIMITS_HOUR` (120 a minute, 3000 an hour) |
850| GIF search and trending (Tenor or Giphy on the site's key) | 60 / 600 |
851| Chat search, staff and streamer logs, purge preview | 30 / 600 |
852| Log export, `GET /api/chat/me/export` | 5 / 30 |
853| `POST /api/chat/send` (bots to global chat) | 20 / 300 |
854| `DELETE /api/chat/admin/purge` | 10 / 100 |
855| DM: new conversation 10 / 60; send 30 / 900; mark read 60 / 1200; members, rename, blocks 20 / 200; delete own message 60 / 600 | as listed |
856| Rooms: create 10 / 60; post 60 / 1200; delete a line 60 / 600; settings, join, leave, members 30 / 300; mark read 60 / 1200; attachments 10 / 100 | as listed |
857| Sounds: upload (channel or alert) 10 / 60; delete, command edit, alert clear 30 / 300 | as listed |
858| TTS: admin settings 30 / 300; admin test (synthesizes) 10 / 100; queue skip and clear 60 / 600; the player's reports 60 / 1800 | as listed |
859| Calls: voice channel create and delete, stream call switch 20 / 200; ring 20 / 200; answer 30 / 300 | as listed |
860| Chat settings save (`PUT /api/chat/preferences` and the other modules) | 30 / 300 |
861
862Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the ingress, the call
863hooks and the signed Events deliveries, which carry account deletions and merges), `/ws/chat` and
864`/ws/call`, the openvibe.chat pages, and the media files players fetch (`/api/tts/audio/…`,
865`/api/sounds/file/…`, `/api/emotes/file/…`), which only the per-address limit bounds. `test/actor-limits.test.js`.
866
867### Layout
868
869```
870server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop
871server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks
872server/live-context.js the only module that talks to Live (interface in its header)
873server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice; plus the T3 APIs: emotes-routes, channel-mod-routes, internal-moderation (Live's read API), alert-sounds
874server/auth/ token resolution through Live; the chat subset of Live's permissions
875server/events/service-outbox.js Chat envelopes and openvibe-sdk outbox wiring
876server/events/consumer.js POST /internal/events: Chat's Events subscriptions (live.release.deployed, network.module.updated)
877server/events/subscriptions.js creates them at boot when missing; list/disable/enable for scripts/subscribe-events.js
878server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
879server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live)
880server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle
881server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token
882server/ai/ moved from Live: chat-ai.js (the rolling insight job, off unless CHAT_AI_ENABLED), client.js (OpenVibe.AI runs, namespace chat.*), extractive.js (the fallback summary)
883server/persona/ Vibe personas: settings.js (chat.persona owner settings), index.js (the first name, the topic check, the cap, the avatar)
884server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/)
885scripts/ parity, subscribe-events, n-1-record
886docs/ calls-cutover.md, parity.md, chat-ingress.md, capabilities-proposal/
887```
888
889## Owns
890
891- room types: global, stream, channel, community, DM, group-DM, call, system; servers (groups of rooms as text and
892 voice channels, with server roles and invites)
893- messages, replies/rich payloads, membership/roles, unread/read state, presence/typing (ephemeral)
894- bans, timeouts, delete/purge, filters, slow/member-only modes
895- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle
896- call signalling metadata and the `pending/ringing/active/ended/missed/declined/failed` lifecycle
897
898(Wave 6 moved messages, DMs, TTS, sounds and the chat side of moderation, and gave TTS and sounds a
899persisted queue with skip/clear/failed states; bans, channel moderation settings and the media-request
900queue are still Live's and reached through `live-context`. Calls are ported with their lifecycle,
901`server/calls/`, and switch over with `docs/calls-cutover.md`.)
902
903## Does not own
904
905- media bytes (attachments are Media references)
906- billing of paid messages (Tips/Billing)
907
908## Capabilities and events
909
910Introduced here and registered in `openvibe-contracts` v0.13.0:
911`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,
912`live.chat_effects.write` (owner live). `chat.live_bridge.write` now only
913guards Live's stream hooks for calls (`/internal/calls/stream-channel`) and `chat.presence.read` the
914presence snapshot (`GET /internal/chat/presence`); the bridge routes they were made for are retired
915(T3 J3a). Enforced here as well: `chat.message.send` (`POST /internal/chat/messages`). It is owned
916by `chat` and listed in the chat manifest since `openvibe-contracts` v0.30.2 (so is the
917`chat.preferences` user module since v0.32.0); the check names it literally, so the contracts check
918enforces it. Planned families:
919`chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`.
920
921Events: `chat.message.created`, `chat.message.deleted`, `chat.dm.created`, `chat.moderation.action`,
922`chat.room.message.created`, `chat.room.message.deleted` (produced); planned `chat.room.updated`,
923`chat.call.*`, `chat.tts.queued|played|failed`. Consumed at `POST /internal/events` (signature v2,
924`CHAT_EVENTS_SECRET`, openvibe-sdk inbox): `live.release.deployed`, `network.module.updated`,
925`network.user.token_valid_after`, `vip.membership.changed`, `network.block.changed`,
926`network.account.export_requested` and `network.account.deleted` (Chat's own
927subscriptions, created at boot when missing).
928
929Called elsewhere, as the service principal `chat` ([server/net/service-auth.js](server/net/service-auth.js)):
930
931| Service | Grant | Why |
932|---|---|---|
933| OpenVibe.Live | `live.chat_context.read`, `live.chat_effects.write` | the chat context Live still owns (bans, channel settings) and effects |
934| OpenVibe.Events | `events.event.publish`, `events.subscription.manage` | the outbox relay; the subscriptions above |
935| OpenVibe.Network | `network.modules.read`, `network.modules.write` | chat preferences and the other chat user modules |
936| OpenVibe.VIP | `vip.entitlement.check` | subscriber badges (fails closed) |
937| OpenVibe.Tools | `tools.tool.run`, `tools.job.read` | sound uploads to MP3 (local ffmpeg is the fallback) |
938
939## Depends on
940
941- OpenVibe.Network (identity, service tokens, the `chat.preferences` user module)
942- OpenVibe.Live (until the rest of chat's data moves)
943- OpenVibe.Events
944- OpenVibe.Media
945- OpenVibe.VIP (member badges; optional — without it nobody has a badge)
946- OpenVibe.Contracts
947
948## Acceptance (must be true before "done")
949
950- stream/global/DM histories preserved on import; old Live URLs and WS messages keep working through the adapter — *done on production data: 70,860 messages, 11 conversations and 1,522 DMs imported with 0 held (two passes, identical), 15/15 read paths identical at the cutover*
951- restart Live without losing Chat; restart Chat's delivery plane and resume persisted messages — *seen in production (Live restarted several times on 2026-09-23 while Chat stayed up; messages and a queued outbox row survived Chat restarts); `test/restart-resume.test.js` restarts Chat as a real process (SIGTERM, new process on the same database) and proves stream, global and channel readers resume from their `after_id` cursor with no gap and no duplicate, including an ingress message Live sends again with its key after the restart (one row, the first id)*
952- a call row without a working signalling/media path is not parity — *Live's `/ws/call` protocol and call routes run in Chat with a `calls` row per call (`test/calls.test.js`: two signed-in sockets exchange offer/answer/ICE, limits, kick/ban, ringing → active/declined/missed/failed, stream channels); not switched over yet (`docs/calls-cutover.md`)*
953- a paid TTS request is never duplicated by a retry — *forwarded writes are applied once per idempotency key; paid TTS does not exist yet*
954- after a reconnect, deleted and blocked state converge; browser parity (join, send, DM, `/tts`, moderation, popout) — *`scripts/parity.js` and `test/parity.test.js` (`docs/parity.md`): a reader that was away converges on what a connected reader saw (cursor reads carry `deleted_ids`); gaps: sub-only mode does not exist, public chat does not apply blocks*
955
956## Security
957
958Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
959
960- **Auth.** People sign in with a Network session JWT, verified here with the Network's key (tokens
961 issued before a subject's `token_valid_after` are refused), or an `hbt_` API token resolved through
962 Live. A WebSocket upgrade authenticates with the Authorization bearer only (bots); a browser's
963 socket starts anonymous and presents the token in its first message (`join`, `join_room`, the
964 call's `auth-update`). Neither a cookie nor `?token=` in the URL signs a socket in (C-05/C-06
965 removed 2026-10-10, after 14 days with no socket relying on either). The `ov_token` cookie only
966 exempts staff from a network (IP) ban at the upgrade, because staff share home networks with
967 banned people.
968 Services use client-credentials tokens for audience
969 `openvibe.chat`, checked per capability (`chat.live_bridge.write`, `chat.message.send`,
970 `chat.presence.read`). Staff gates ask the contracts staff map.
971- **Private data.** DMs are delivered only to participants, and private rooms only to members
972 (`test/security-crawl.js` walks every route); network blocks count like DM blocks; a staff member
973 reading someone else's chat logs is recorded as a moderation action; account export and deletion
974 follow Network's events.
975- **Network exposure.** `/internal/*` and `/metrics` answer 404 through nginx, which sets the client
976 address from the connection only; WS upgrades check the origin and IP bans.
977- **Egress.** Chat calls its configured Network, Live, Events, VIP and Tools hosts; the soundboard
978 import fetches only 101soundboards' own addresses, checked on every DNS answer.
979- **Secrets.** `OV_OAUTH_CLIENT_SECRET` and `CHAT_EVENTS_SECRET` live in `/etc/openvibe/chat.env` (0600),
980 by name only.
981
982## Deploy
983
984Production deploys with `sudo ovhost deploy chat` on the host (strategy `git-checkout`: fetch,
985fast-forward `/opt/openvibe.chat`, install on a lockfile change, restart, wait for `/ready`).
986The unit is `openvibe-chat.service` on `127.0.0.1:4400`, the env file `/etc/openvibe/chat.env`. Readiness is
987`/ready` (not `/api/ready`). nginx: [deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)
988for openvibe.chat and [deploy/nginx/openvibe.live-chat.locations.conf](deploy/nginx/openvibe.live-chat.locations.conf),
989included in openvibe.live's vhost, for Live's chat paths. After a deploy, record the N-1 fixtures
990(`npm run n-1:record`). Chat is required in every mode and Live's chat routes are retired: a
991rollback is a Chat release rollback, not a switch back to Live.
992
993Rollback: ovhost puts the previous sha back by itself when `/ready` does not answer 2xx after the
994restart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema
995code only adds tables and columns.
996
997## Launch rule
998
999This repository does not make the product real, and the domain kept its placeholder page on
1000[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
1001following existed here (plan §12.12):
1002
10031. an owning runtime with health/readiness endpoints and observability;
10042. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
10053. server-rendered or static public routes that are useful without JavaScript;
10064. real persistence and end-to-end workflows;
10075. capability and event registration against `OpenVibe.Contracts`;
10086. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
10097. acceptance tests proving the advertised functionality.
1010
1011The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
1012registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
1013implemented service. `openvibe.chat` launched on 2026-09-24 (global chat, messages, settings): it
1014left OpenVibe.Sites, and this repository's vhost serves it.
1015
1016---
1017
1018Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
1019
1020<!-- versions:start -->
1021- openvibe-contracts: v0.419.0
1022- openvibe-sdk: v0.161.0
1023- openvibe-shared: v4.241.0
1024<!-- versions:end -->
1025