openvibers/openvibe.chat README.md
README.md
| 1 |  |
| 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 | |
| 10 | Part 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, |
| 13 | Chat 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 |
| 15 | on 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 |
| 16 | site (global chat, messages, rooms, |
| 17 | settings; 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 |
| 19 | is 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 | |
| 26 | The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while |
| 27 | browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions. |
| 28 | |
| 29 | ## What Wave 6 did |
| 30 | |
| 31 | The roadmap's method for this wave: **move the existing implementation behind a service boundary |
| 32 | before 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 | |
| 155 | Migration 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 | |
| 161 | Chat 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 | |
| 167 | Chat 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`. |
| 169 | Live reads them through `GET /internal/moderation/*`. |
| 170 | |
| 171 | Stays 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 }`, |
| 183 | which 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 |
| 185 | in one transaction with its outbox row, and the relay publishes in outbox order, so the deletion |
| 186 | always follows the message it removes. Messages deleted before this existed are redacted by |
| 187 | OpenVibe.Events' `scripts/redact-backfill.js`. |
| 188 | |
| 189 | #### Consumed |
| 190 | |
| 191 | Chat 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 | |
| 219 | Platform blocks (WS-E task 5): Chat's own `dm_blocks` stay in force beside Network's. To carry them over |
| 220 | once, export them as subject pairs and import those on Network (`scripts/import-blocks.js` there): |
| 221 | |
| 222 | ```bash |
| 223 | node scripts/migrate-dm-blocks-to-network.js [--resolve] # dry run: counts and unmapped Live ids |
| 224 | node scripts/migrate-dm-blocks-to-network.js --apply --out /root/dm-blocks.json # write the pairs file (0600) |
| 225 | ``` |
| 226 | |
| 227 | Mentions: Chat produces no mention notifications (the @mention highlight, tab flash and sound are drawn by |
| 228 | Live's chat client from the broadcast message), so there is nothing in Chat for a block to suppress yet. |
| 229 | |
| 230 | ```bash |
| 231 | sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --dry-run # list them |
| 232 | sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries |
| 233 | sudo 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, |
| 237 | failed, the last type and outcome); `SELECT * FROM deploy_releases ORDER BY created_at DESC` shows |
| 238 | which path delivered each deploy first and when the other one arrived. |
| 239 | |
| 240 | ### Calls |
| 241 | |
| 242 | Live's voice/video calls moved here (roadmap WS-I task 1, `server/calls/`). They are served with `CHAT_CALLS=1`, |
| 243 | which 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 | |
| 275 | Chat's authority resource index serves `GET /api/v1/resources` (with `project`, `kind`, `cursor`, |
| 276 | and `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, |
| 278 | so they have no project ID or OVRN; the named-resource route always answers 404. The index is |
| 279 | loopback only and is not forwarded by the public nginx vhosts. |
| 280 | |
| 281 | Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and |
| 282 | Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`, |
| 283 | and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people |
| 284 | join. 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 | |
| 288 | Kinds: **community** (anyone starts one), **call** (the room a call happens in: its text chat, and the |
| 289 | voice/video channel `room-<slug>` on `/ws/call`; anyone starts one) and **system** (announcements |
| 290 | written 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 | |
| 301 | Chat staff (`staff.moderation.chat`) read, moderate and manage every room, post in system rooms and talk in |
| 302 | calls; 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 |
| 304 | staff) 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 |
| 307 | once: `/ws/chat` followers get `room_access { role, can }` (or `room_left`), and a running call follows the |
| 308 | room (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 |
| 311 | voice-channel list. Who may read the room may join its call; owner, mods and speakers talk; participants, |
| 312 | viewers and people without a role (anonymous too, in public rooms) join listen-only: force-muted with the |
| 313 | camera forced off, their own unmute ignored, and a moderator cannot lift it (make them a speaker instead). |
| 314 | Peers 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 |
| 316 | the room private drops everyone who is not a member. Media is peer to peer, so listen-only is enforced by |
| 317 | clients honouring force-mute, as every force-mute here. openvibe.chat's room page carries an audio call |
| 318 | client (`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", |
| 323 | resource: <space slug>, title? }` (201 created, 200 already there), `DELETE |
| 324 | /api/chat/rooms/:slug/attachments/community/:space` (idempotent), `GET …/attachments`. Only a person who |
| 325 | manages the room (owner or chat staff), with their own Network token, attaches it (API tokens are refused); |
| 326 | the room's managers or whoever attached it detach. Community keeps the link on the space and checks its own |
| 327 | side (the space's owner or staff), so one person holding both ends makes the link, and no service grant is |
| 328 | involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public |
| 329 | claim 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 |
| 332 | and couples with AI members, and a Food household's chat with one that knows the pantry). A room's owner (or chat |
| 333 | staff) adds up to 3: a name (its `@handle` is the name in lowercase), how it should be, and notes it always knows. |
| 334 | When someone who may post writes `@handle`, it answers: the room's last 30 messages, its notes and any shared context |
| 335 | go 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 |
| 337 | an AI badge, never as a person. Sockets get `room_ai_typing { room, name }` first. Only the first member named answers; |
| 338 | it 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 |
| 340 | quiet (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 |
| 344 | only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room |
| 345 | member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an |
| 346 | account 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 |
| 349 | OpenVibe.Actor agents to a channel from its AI members settings. Anyone who can post in that channel can mention the |
| 350 | agent; Chat starts the owner's personal agent through `actor.agent.run`, records a durable room task, and follows it |
| 351 | through the existing workspace poller. The answer returns as that agent's room message. Actor applies the owner's |
| 352 | budget, daily cap and approvals. The settings form says the answer is public to the channel. Deleting the owner's |
| 353 | account removes its attached Actor agents; ordinary room AI members remain. Network must grant Chat `actor.agent.run` |
| 354 | and 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 |
| 357 | is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may |
| 358 | call `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 |
| 361 | at most 4,000 characters) and sends the results back, at most three rounds with four calls each, then a turn that must |
| 362 | answer 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 |
| 366 | chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter, |
| 367 | Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays). |
| 368 | The 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 |
| 370 | sets a member to think with their own key (`engine: own`; `openvibe` is OpenVibe's model): its answers are runs that |
| 371 | name `credential { subject }`, so they reach that provider only, count against the key's budget and not the room's |
| 372 | daily cap. The room page and the API (`model`) say which model each member uses ("openai (openrouter.ai) · gpt-4o-mini, |
| 373 | Ann's key"). Removing the key (or the account) sends its members back to OpenVibe's model; a new model relabels them. |
| 374 | API: `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`, |
| 376 | linked 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, |
| 380 | with no key and nothing spent. OpenVibe.AI reaches it through OpenVibe.Bot and the person's OpenVibe Link, only where they |
| 381 | turned 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 |
| 391 | conversation 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 |
| 394 | person renames it. The first is made after the first answer; then every 8 messages (configurable) the cheapest chat |
| 395 | model 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 |
| 397 | cheapest 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` |
| 399 | recorded 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 |
| 402 | only. `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, |
| 406 | channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text |
| 407 | channel is a community room and a voice channel a call room (its call is `room-<slug>`), so messages, live delivery, |
| 408 | unread 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 |
| 410 | channel: speakers by default, or listeners when the channel says participant); a public server is read by anyone |
| 411 | and 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 |
| 413 | admins, admins appoint moderators and manage channels, settings and invites, moderators remove and ban members; |
| 414 | nobody 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 |
| 416 | text channel. A channel's room slug is random (`ch…`); its page is `/s/<server>/<channel>` and `/r/<slug>` goes |
| 417 | there. Channels follow the server's visibility (going private redacts what they had published); the rooms API refuses |
| 418 | room-level joins, roles and visibility there, and `/rooms` leaves channels out. The page: your servers down the side |
| 419 | with unread counts, the channels, the channel (its call for a voice channel) and the people by rank; on a phone the |
| 420 | servers run across the top and the channel list folds away. API: `/api/chat/servers` (list, create, read, update, |
| 421 | delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:code`). Deleting an account removes its |
| 422 | memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when |
| 423 | nobody is. |
| 424 | |
| 425 | Forum channels are available by default for a server manager who can connect a Community forum they moderate; |
| 426 | the owner can disable them with `forum_channels_enabled`. Slash commands and custom server emoji are available by |
| 427 | default and can be disabled with `CHAT_SLASH_COMMANDS_ENABLED=0` and `CHAT_SERVER_EMOJI_ENABLED=0`. Stage channels |
| 428 | use 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 |
| 431 | may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the |
| 432 | file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it |
| 433 | to 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 |
| 436 | caption and the picture's link. Rooms and channels show OpenVibe.Pics direct links (`https://openvibe.pics/i/<slug>.<ext>`) |
| 437 | as 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 |
| 440 | pictures. 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 |
| 446 | the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation |
| 447 | counts 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 |
| 450 | and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and |
| 451 | the only openvibe.live URLs are profile links. `test/live-down.test.js` renders every page and uses the |
| 452 | forms, 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 | |
| 456 | A 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 |
| 459 | migration with Vibe in it) is OpenVibe staff's own: every member of staff (chat staff, `staff.moderation.chat`) becomes a |
| 460 | member on their first visit, and nobody else reads it, whatever role they once had. **Any community room** becomes a |
| 461 | workspace 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 |
| 463 | link 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 |
| 464 | announce the work. Switching it off hides the queue and drops the shared screen; the staff room stays a workspace, and |
| 465 | a 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 | |
| 498 | API (`/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 | |
| 503 | The 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, |
| 505 | Yesterday, Previous 7 days…), the conversation in a readable column, one composer at the bottom, suggestions on a new |
| 506 | chat, 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 |
| 508 | socket, 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 | |
| 554 | The owner, 2026-10-24: one chat system "from your private conversations to your random live chat widget on websites", |
| 555 | for 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 | |
| 686 | The same tag puts a **public room** on any site (`server/web/room-embed-pages.js`, `public/web/room-embed.js`): as a |
| 687 | bubble, `<script src="https://openvibe.chat/widget.js" data-room="<slug>" async></script>`, or inside an element of the |
| 688 | page, `data-target="#chat" data-height="520"`. The owner's room settings give both tags. Only public rooms embed (not |
| 689 | private ones, Vibe chats or a server's channels), and the frame may sit on any site. Anyone reads, live over the chat |
| 690 | socket (`join_room`); people sign in with OpenVibe to write, and the room's moderation and OpenVibe's global standard |
| 691 | apply because posting goes through `rooms.post`. |
| 692 | |
| 693 | Signing in from a frame: browsers give a frame on another site no openvibe.chat cookies, and the sign-in window loses |
| 694 | its 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 | |
| 701 | The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built |
| 702 | into the openvibe.chat system". A **feed** (`server/feeds/`, `migrations/0017_feeds.sql`) is live activity a person |
| 703 | publishes into Chat; vibe coding, moved from OpenVibe.Live (its `server/vibe-coding/`, same canonical events), is the |
| 704 | first kind. Viewers see a card (`public/web/feed-view.js`): turns of a prompt, the steps taken (tools used, files |
| 705 | changed) 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 | |
| 738 | A member's messages in a creator's room (stream or offline channel chat) carry that creator's VIP |
| 739 | badge: a perk of the member's plan **version** with a `chat badge` product binding (VIP's network |
| 740 | perk `subscriber-badge` has one), unless the member turned it off in VIP (`show_badge`). The frame |
| 741 | gets `vip_badge: { creator, perk, name, badge, label? }` and the row's `metadata` keeps it, so history |
| 742 | shows it. `badge` is a short id (`subscriber`, else `member`) and `label` plain text: binding config is |
| 743 | written 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` |
| 767 | removes a field; optional `If-Match: <revision>`) read and change the signed-in person's chat |
| 768 | preferences. They live in OpenVibe.Network as the user module `chat.preferences` (openvibe-contracts |
| 769 | namespace, schema v1: `timestamps`, `compact`, `font_scale` 0.75–2, `show_badges`, `hide_emotes`), which |
| 770 | Chat owns since the cutover; Network validates, versions (revision, the ETag here) and announces each |
| 771 | change as `network.module.updated`. Chat reads and writes with its service token (Network grants `chat |
| 772 | network.modules.read` and `network.modules.write` on `chat.preferences`) and names the revision it read on |
| 773 | every write, so another writer is never overwritten (Chat reads again and re-applies the patch; a browser |
| 774 | If-Match is strict: 412). |
| 775 | |
| 776 | Three 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 | |
| 784 | openvibe.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 |
| 795 | npm install |
| 796 | cp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATH |
| 797 | npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready (CHAT_CALLS=1: /ws/call, /api/streams/…) |
| 798 | npm test # Node 22; stub Live and Network in-process |
| 799 | npm 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) |
| 801 | node scripts/subscribe-events.js --dry-run # Chat's Events subscriptions (boot creates missing ones) |
| 802 | node scripts/parity.js # chat parity scenarios: a dry run; --apply only on test accounts (docs/parity.md) |
| 803 | N1_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 |
| 807 | this one and its SQL against this schema, from `test/fixtures/n-1/`: every call Chat's pages, the |
| 808 | openvibe.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 |
| 810 | reads, and every statement the previous release runs, which must still prepare after this release |
| 811 | migrated a database the previous one created. After each deploy, |
| 812 | record the release now in production as the next N-1 (`npm run n-1:record [ref]`, with `N1_LIVE_REF` |
| 813 | the Live release in production, from a Live checkout at `N1_LIVE_REPO`, default `../OpenVibe.Live`) |
| 814 | and commit the fixtures. |
| 815 | |
| 816 | `/ready` (openvibe-shared/ready shape: `status` ready/degraded/not_ready and `checks`) is 503 only |
| 817 | when the `db` check (a read of `chat_messages`) fails. `live_sync` is optional: it reads degraded when |
| 818 | the last clean Live sync is older than `LIVE_SYNC_STALE_MS` (default 60 s) or a sync step has missed |
| 819 | its own interval by that much. It also reports whether events are relayed and the Events consumer's |
| 820 | counters. |
| 821 | Production: `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 |
| 824 | instead of getting 502s (open WebSockets still reconnect); ovhost restarts the service, never the socket. |
| 825 | |
| 826 | Request telemetry: counted by route template, method and status class, not raw URL; `/shared` files, |
| 827 | static assets, health, readiness and `/metrics` are not counted. One `http.request` sample per key a |
| 828 | minute, 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 |
| 830 | 14 days. `server/app.js` mounts the SDK's `telemetryMiddleware` (a no-op until init); `server/index.js` |
| 831 | calls `telemetry.init()` at production start, runs the retention and flushes on stop. |
| 832 | |
| 833 | ### Per-actor limits |
| 834 | |
| 835 | The REST API also limits who calls it (`server/net/actor-limits.js`, openvibe-sdk/limits, roadmap |
| 836 | WS-R task 4). A person counts as `user:usr_…` whether they call with their Network token or an `hbt_` |
| 837 | API token (a bot counts as its owner); anyone else by their address. Browsers call these routes |
| 838 | themselves, 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 |
| 840 | limited. The per-address `/api/` limit and the chat flood controls (the socket's: a message a second, and |
| 841 | the same line at most 3 times in 30 s in a room, with punctuation-only lines counted as one and commands and |
| 842 | the room's moderators exempt; DMs 10 a minute and 5 new conversations an hour, rooms 6 every 10 s, 6 rings a |
| 843 | minute) stay. Past a limit: `429` |
| 844 | problem+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 | |
| 862 | Never limited: `/health`, `/ready`, `/release.json`, `/metrics`, `/internal/*` (the ingress, the call |
| 863 | hooks 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 | ``` |
| 870 | server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop |
| 871 | server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks |
| 872 | server/live-context.js the only module that talks to Live (interface in its header) |
| 873 | server/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 |
| 874 | server/auth/ token resolution through Live; the chat subset of Live's permissions |
| 875 | server/events/service-outbox.js Chat envelopes and openvibe-sdk outbox wiring |
| 876 | server/events/consumer.js POST /internal/events: Chat's Events subscriptions (live.release.deployed, network.module.updated) |
| 877 | server/events/subscriptions.js creates them at boot when missing; list/disable/enable for scripts/subscribe-events.js |
| 878 | server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat) |
| 879 | server/prefs/ chat preferences in the Network user module chat.preferences (routes, cache, migration from Live) |
| 880 | server/calls/ moved from Live: the call server (/ws/call), its REST routes, Live's stream hooks (/internal/calls), the calls lifecycle |
| 881 | server/media/client.js OpenVibe.Media objects (namespace chat): emote image bytes, with Chat's own service token |
| 882 | server/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) |
| 883 | server/persona/ Vibe personas: settings.js (chat.persona owner settings), index.js (the first name, the topic check, the cap, the avatar) |
| 884 | server/db/ database.js (Live's chat functions, same names and arguments; PostgreSQL, migrations/) |
| 885 | scripts/ parity, subscribe-events, n-1-record |
| 886 | docs/ 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 |
| 899 | persisted queue with skip/clear/failed states; bans, channel moderation settings and the media-request |
| 900 | queue 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 | |
| 910 | Introduced 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 |
| 913 | guards Live's stream hooks for calls (`/internal/calls/stream-channel`) and `chat.presence.read` the |
| 914 | presence 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 |
| 916 | by `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 |
| 918 | enforces it. Planned families: |
| 919 | `chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`. |
| 920 | |
| 921 | Events: `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 |
| 927 | subscriptions, created at boot when missing). |
| 928 | |
| 929 | Called 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 | |
| 958 | Reporting 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 | |
| 984 | Production deploys with `sudo ovhost deploy chat` on the host (strategy `git-checkout`: fetch, |
| 985 | fast-forward `/opt/openvibe.chat`, install on a lockfile change, restart, wait for `/ready`). |
| 986 | The 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) |
| 988 | for openvibe.chat and [deploy/nginx/openvibe.live-chat.locations.conf](deploy/nginx/openvibe.live-chat.locations.conf), |
| 989 | included 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 |
| 991 | rollback is a Chat release rollback, not a switch back to Live. |
| 992 | |
| 993 | Rollback: ovhost puts the previous sha back by itself when `/ready` does not answer 2xx after the |
| 994 | restart; afterwards `sudo ovhost rollback chat --to <sha>`. Nothing blocks a rollback: the schema |
| 995 | code only adds tables and columns. |
| 996 | |
| 997 | ## Launch rule |
| 998 | |
| 999 | This 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 |
| 1001 | following existed here (plan §12.12): |
| 1002 | |
| 1003 | 1. an owning runtime with health/readiness endpoints and observability; |
| 1004 | 2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals); |
| 1005 | 3. server-rendered or static public routes that are useful without JavaScript; |
| 1006 | 4. real persistence and end-to-end workflows; |
| 1007 | 5. capability and event registration against `OpenVibe.Contracts`; |
| 1008 | 6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour; |
| 1009 | 7. acceptance tests proving the advertised functionality. |
| 1010 | |
| 1011 | The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and |
| 1012 | registers maturity in the ecosystem registry atomically. A placeholder is never counted as an |
| 1013 | implemented service. `openvibe.chat` launched on 2026-09-24 (global chat, messages, settings): it |
| 1014 | left OpenVibe.Sites, and this repository's vhost serves it. |
| 1015 | |
| 1016 | --- |
| 1017 | |
| 1018 | Part 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 |