![OpenVibe.Chat](https://openvibe.chat/shared/social/chat.jpg) # OpenVibe.Chat **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. **Live site:** https://openvibe.chat · **API docs:** https://openvibe.services · **For AI:** https://openvibe.chat/llms.txt **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` Part of the [OpenVibe network](https://openvibe.network); [help](https://openvibe.help) and the [developer API](https://openvibe.services). **Status:** alpha — deployed. Since the cutover on 2026-09-23 at 02:03 UTC, Chat is the authority for openvibe.live's chat in every mode: the service runs on `openvibe-ovh` (unit `openvibe-chat`, 127.0.0.1:4400), Live runs no chat of its own (its `CHAT_AUTHORITY` switch was retired 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 site (global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24). **Domain:** `openvibe.chat` ([deploy/nginx/openvibe.chat.conf](deploy/nginx/openvibe.chat.conf)); Chat is also served on Live's origin: `https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/`, `/api/sounds`, `/api/emotes` via nginx. **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5. **License:** AGPL-3.0 (same as every OpenVibe service). ## Purpose The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions. ## What Wave 6 did The roadmap's method for this wave: **move the existing implementation behind a service boundary before changing behaviour.** Browser JavaScript does not change; nginx routes the chat paths here. - **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, every message type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`, `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /subonly /paste /ai`, `!sr !queue !np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`), DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`, `/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word filter, the deploy-notice card. Changes are confined to where they touched Live's data. - **A channel room can be joined by its name** (2026-10-31): `join` takes `channel: ''` next to `channelUserId`, so a client that only knows the channel's name (the overlay page on `openvibe.live`) sits in that channel's room — live or not — without a stream id. The name is resolved to the channel's owner and cached for a minute. Naming a channel grants nothing: the socket stays anonymous, exactly an anonymous viewer in that room. - **For bots on `/ws/chat`** (Maticus, 2026-10-26): a `!word` that is not a channel sound is sent to Live as `button:` for a signed-in viewer, so it presses the channel robot's button of that name, and says nothing when there is none. A token in the socket's address (`?token=`) is never read; the socket gets a `system` frame with `code: "token_in_url"` saying to send an `Authorization: Bearer` header or the join's `token`. When a channel goes live again, sockets still on one of its ended streams (same slot) move to the new stream and get `{ type: "stream_changed", stream_id, previous_stream_id, channel_user_id }`, so a bot stays in the viewer list, TTS and sounds without re-joining (`live-context.onStreamLive`, from the active-streams sync). - **The TTS and sound queue** (`audio-queue.js`, table `audio_requests`). TTS, channel `!sounds` and 101soundboards clips are persisted requests played one at a time per room, across every Chat process (a claim holds the room's advisory lock; a partial unique index allows one `playing` row per room) — `queued → playing → played`, or `skipped` / `failed` — so the queue survives a Chat restart (what waited plays once the room has listeners again, requests older than 5 minutes expire) and a keyed request (the TTS of message `m`) is read once. A playing row is owned: `claimed_by` (the process's instance id, host and port or `CHAT_AUDIO_INSTANCE_ID`) and a 30-second `lease_until` its owner renews every 10 s. Only a dead owner's row is settled — its lease passed, or it is the restarted process's own — at boot and by the heartbeat's sweep; a live process's clip is never touched. Such a row is `played` if its delivery had started, otherwise queued again in its old place, once: claimed a second time and dead again undelivered, it `failed` (`attempts` counts claims), so a clip that crashes Chat cannot loop. The broadcaster and moderators control it: `/skiptts [id]`, `/cleartts`, and `GET /api/tts/queue`, `POST /api/tts/queue/skip|clear`, `POST /api/tts/queue/:id/report` (the playing client's `played`/`failed`). Browsers get the same `tts-audio` / `soundboard-audio` frames as before, now with `request_id` and paced by the clip's length, plus two new frames old clients ignore: `audio-skip { request_id }` and `audio-clear { request_ids }`. - **One adapter to Live — `server/live-context.js`.** Accounts and roles, streams, slots and channels, channel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, site settings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks Live for. Reads are cached projections (`ctx_*` tables kept complete by paged syncs; TTL caches that serve stale while refreshing) warmed when a socket joins, so a chat message never waits on a network read. Live pushes invalidations when it changes cached data. - **Live integration.** Live serves `/internal/chat-context/*` and `/internal/chat-effects/*` and has no chat switch. Its chat calls reach Chat through `/internal/chat/*` (`docs/chat-ingress.md`). - **Own database on PostgreSQL** (ADR-035, plan T3) with Live's tables and ids, the Network subject on new rows, and a transactional events outbox. Chat's changes were copied into Live's tables by a read mirror until 2026-10-05: since Live #31 nothing in Live reads its mirror-filled chat tables in chat mode (every read goes to Chat's internal read API, `server/chat/internal-reads.js`), so the mirror — and the cutover rollback through it — is retired. `server/db/database.js` serves through `openvibe-sdk/db`: production needs `DATABASE_URL` (the runtime role, DML only, through PgBouncer) and `DATABASE_DIRECT_URL` (the owner, which applies `migrations/` at boot); without `DATABASE_URL` outside production it runs on an embedded PGlite in `data/pglite` (`CHAT_PGLITE_DIR`). `migrations/0001_initial.sql` is the whole schema (timestamps stay SQLite-format text through `ov_now()`/`datetime()`); `0006_stop_live_mirror_triggers.sql` dropped the twelve read-mirror capture triggers, and `0009_drop_live_mirror_outbox.sql` drops `live_mirror_outbox` (a contract migration, after 0008's SDK outbox: a held contract would hold every migration after it). `VALKEY_URL`/`VALKEY_PREFIX` put the per-actor rate-limit counters on Valkey instead of this process. - **The six chat tables** (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`): Chat's own since the C-04 cutover — Chat is their only writer, there is no authority switch any more. Live's writers go through Chat's APIs below and the Chat-owned ingress (`docs/chat-ingress.md`); the Live bridge they used before is retired (T3 J3a). - **The features on those tables, served by Chat** (plan T3, plan step 1) — built here so Live can stop touching them at the flip: - `GET /api/emotes/global`, `/channel/:userId`, `/mine`, `/defaults`, `/sources` (GET/PUT), `/search`, `/all/:streamId`, `/file/:filename`, `POST /api/emotes`, `PATCH/DELETE /api/emotes/:id` — Live's exact paths, bodies, limits and error messages; uploaded bytes go to OpenVibe.Media with Chat's own token (`media.object.upload` / `.delete`, namespace `chat`), the row stores `media_url` + `media_asset_id`, responses use `media_url`. No local emote files (`/file/…` 404s). `ffz`/`bttv`/`7tv` are cached provider proxies on fixed hosts. - `GET /api/chat/channels/moderation/mine`, `GET/POST /api/chat/channels/:id/mods`, `DELETE /api/chat/channels/:id/mods/:userId`, `GET/PUT /api/chat/channels/:id/moderation`, `…/moderation/logs`, `…/moderation/chat-search`, `POST …/moderation/messages/:messageId/delete` — owner/admin write, channel mods read. - Live's read API for what Chat owns: `GET /internal/moderation/channels/:channelId`, `GET /internal/moderation/users/:userId/channels`, `GET /internal/moderation/channels/:channelId/emote-count` — loopback + service token, capability `chat.moderation.read` (`chat.channel-moderation-result@1`, `chat.moderated-channels-result@1`, `chat.emote-count-result@1`). Live caches each answer 30 s. - Live's reads of Chat's message, moderation-queue and sound tables (plan T3 J4b): `POST /internal/chat/stats`, `GET /internal/chat/messages`, `/timeline`, `/moderation/*`, `/sounds` and `POST /internal/chat/sounds/asset` (`server/chat/internal-reads.js`; routes and capabilities in `docs/chat-ingress.md`). - Alert sounds: `playAlertSound(chatServer, streamerId, streamId, kind)` (`server/chat/alert-sounds.js`) resolves the channel's own settings row, reads the clip and broadcasts it to the channel room. - **The chat-AI summaries** (plan T3 step 2, decision 5) — Live's `server/ai/chat-ai.js` job moved here: a background poller (off unless `CHAT_AI_ENABLED=1`) that folds Chat's own messages into rolling global/per-user/per-relay/per-anon insights and the append-only timeline, writing Chat's own `chat_ai_summaries`/`chat_timeline_events` tables. The model is OpenVibe.AI called with Chat's own service token (`ai.run.create`/`ai.run.read`, audience `openvibe.ai`, namespace `chat.*`, workflows `chat.global`/`chat.profile`); when AI does not answer the job stores a deterministic extractive summary instead, so the routes always have something to serve. Routes (Live's paths, parameters, shapes and public visibility, now under `/api/chat/ai/`): `GET /global`, `/timeline` (`?before=&since=&q=&limit=`), `/user/:id`, `/anon/:anonId`, `/relay/:platform/:username`, `/timeline/:username`. - **Person chat memory** (`server/ai/person-memory.js`) is a separate default-off worker (`CHAT_PERSON_MEMORY_ENABLED=1`). It summarizes new messages into channel day records, folds days into weeks and weeks into months, and keeps an own-view cross-channel rollup. The `chat.person-memory` workflow in OpenVibe.AI routes model calls through Exchange; the worker checks its $0.25 UTC-day spend ledger before calls (maximum 25 calls, 450 output tokens each). `GET /api/v1/people/:username/memory?channel=&level=day|week|month` returns public fields; the person and channel owner see the private fields. A person's `DELETE` erases their records and opts out; `PUT .../preferences` with `{"opted_out":false}` opts back in. ## How it fits the network | Concern | Where it lives | How Chat reaches it | | --- | --- | --- | | 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 | | 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`) | | Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) | | 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) | | 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 | | 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` | | 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 | | 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 | | 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`) | | Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` | | 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`) | 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. | 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 | | 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 | ### Data Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`. Same columns and ids as Live, plus `subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` / `created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer). Chat owns too (C-04 done; there is no authority switch any more): `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`. Live reads them through `GET /internal/moderation/*`. Stays in Live: `media_requests`, `media_request_settings`. ### Events | Event | Visibility | When | | --- | --- | --- | | `chat.message.created` | public | every stored message in a public room (global, channel, stream) | | `chat.dm.created` | subject | every DM; the payload names the participants, never the text | | `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 | | `chat.moderation.action` | internal | every moderation log row (chat commands and Live's `/api/mod`) | `chat.message.deleted` also carries `payload.redacts: { subject_type: "chat_message", subject_ids }`, which makes OpenVibe.Events rewrite the stored `chat.message.created` of each id into a tombstone (no text, no anon id, no author) on every read path, public SSE replay included. Every delete runs in one transaction with its outbox row, and the relay publishes in outbox order, so the deletion always follows the message it removes. Messages deleted before this existed are redacted by OpenVibe.Events' `scripts/redact-backfill.js`. #### Consumed Chat subscribes (consumer `chat`) to these topics (and `network.user.token_valid_after`, `vip.membership.changed`), delivered to `POST /internal/events` (`server/events/consumer.js`): | Event | From | What Chat does | | --- | --- | --- | | `live.release.deployed` | Live (`server/events/release-events.js`), subject `{ type: release, id: }` | 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) | | `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 | | `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) | - **One card per deploy** (compatibility register C-84, closed). The event is the only path: the bridge op `deployNotice` is retired (T3 J3a); `deploy_releases.first_via` / `bridge_at` keep what it recorded. The event claims the head commit in `deploy_releases` in the transaction that stores or folds the card; a later delivery for that head finds it claimed and changes nothing but its `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. A release event older than 6 hours (an operator replay) is `ignored:stale`. - **Exactly once.** The openvibe-sdk inbox (`chat_event_inbox`, pruned after 35 days) claims `(chat, event_id)` in the same transaction as the change, and broadcasts run only after it commits. A failure answers 500 and rolls both back; Events retries. - **Signature v2 only** (`parseDelivery` with `requireV2`): HMAC over `"."` under `CHAT_EVENTS_SECRET`, `t` within ±300 s. A bad, stale, v1-only or unsigned delivery is 401; no secret is 503; a request that came through nginx (a forwarding header) is 403. - **Subscriptions** are created at boot when missing (`server/events/subscriptions.js`: list, then create; Events answers 409 for a duplicate, so it is idempotent), retried in the background while Network or Events cannot answer. An existing subscription is left as it is, even disabled, so a rollback survives restarts. `CHAT_EVENTS_SUBSCRIBE=0` turns this off. Needs `EVENTS_URL`, `CHAT_EVENTS_SECRET`, `OV_OAUTH_CLIENT_SECRET` and the Network grant `chat events.subscription.manage openvibe.events`. Platform blocks (WS-E task 5): Chat's own `dm_blocks` stay in force beside Network's. To carry them over once, export them as subject pairs and import those on Network (`scripts/import-blocks.js` there): ```bash node scripts/migrate-dm-blocks-to-network.js [--resolve] # dry run: counts and unmapped Live ids node scripts/migrate-dm-blocks-to-network.js --apply --out /root/dm-blocks.json # write the pairs file (0600) ``` Mentions: Chat produces no mention notifications (the @mention highlight, tab flash and sound are drawn by Live's chat client from the broadcast message), so there is nothing in Chat for a block to suppress yet. ```bash sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --dry-run # list them sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --disable # rollback: no more deliveries sudo node --env-file=/etc/openvibe/chat.env scripts/subscribe-events.js --enable # undo it ``` `/ready` reports the consumer under `events.consumer` (received, applied, duplicates, ignored, refused, failed, the last type and outcome); `SELECT * FROM deploy_releases ORDER BY created_at DESC` shows which path delivered each deploy first and when the other one arrived. ### Calls Live's voice/video calls moved here (roadmap WS-I task 1, `server/calls/`). They are served with `CHAT_CALLS=1`, which production has run since the calls cutover of 2026-10-10 (`docs/calls-cutover.md`): - **`/ws/call`** — Live's call server with the same protocol: `?channelId=` (or a stream number), signalling only (full-mesh WebRTC; `offer`/`answer`/`ice-candidate` relayed, `mute`, `camera-off`, `speaking`, `auth-update`, `force-mute`, `force-camera-off`, `kick`, `ban`, `unban`, `end-call`), the permanent `public` lobby, one temporary channel per person (private for a 1:1 call), stream-linked channels, 8 participants, 3 sockets per address, a one-minute kick cooldown, per-channel bans, empty channels deleted after an hour. Sign-in is Chat's (as on `/ws/chat`); accounts, streams and cosmetics come through `live-context`; the voice-channel list and call invites go out through Chat's chat server. - **REST on Live's paths** (`server/calls/routes.js`): `GET/POST /api/streams/voice-channels`, `GET/DELETE /api/streams/voice-channels/:channelId`, `POST …/call-user` and `…/call-user/respond`, `GET/PUT /api/streams/:id/call` (the streamer, checked through `live-context`). - **Live's stream hooks** (`server/calls/internal.js`, Live `CALLS_AUTHORITY=chat`): `POST /internal/calls/stream-channel { stream_id, mode, user_id }` and `DELETE /internal/calls/stream-channel/:streamId` — a Live service token with capability `chat.live_bridge.write` (the name is from the retired chat bridge; Live holds the grant), loopback only. - **From a conversation on openvibe.chat** (`server/web/pages.js`, `public/web/ring.js`, `public/web/call.js`): a 1:1 conversation nobody blocked has **Call **. It rings through `call-user` (its limits, block check and ring row) and opens `/call/`, the call's page. The page is for its caller and the people it invited (404 to anyone else), and it joins as it opens. While the caller waits it says *Ringing …* and listens on `/ws/chat` for the answer; declined, busy or no answer leaves the call. Every signed-in page has `ring.js`: a ring shows who is calling with **Answer** (tells the caller, opens the call page) and **Decline**. After 30 s it gives up with `no-answer`, and a person already ringing or in a call answers `busy`. The invite reaches Live's chat sockets too, so a call can start on one site and be answered on the other. - **Lifecycle** (`server/calls/lifecycle.js`, table `calls`): a ring is `direct`, `pending → ringing → active → ended`, or `missed` (no answer within `CALL_RING_TIMEOUT_MS`, 45 s; the caller is told), `declined` (declined or busy) or `failed` (the invite could not be delivered, with the reason); a channel's occupancy is a `channel` / `stream` session, `active` while anyone is in, `ended` when it empties or closes. A restart closes what was open (reason `restart`). ### Rooms (openvibe.chat) Chat's authority resource index serves `GET /api/v1/resources` (with `project`, `kind`, `cursor`, and `limit` filters) and `GET /api/v1/resources/:ovrn` to first-party service tokens carrying `chat.resource.read`. It lists active rooms as `chat.room` summaries. Rooms belong to people, so they have no project ID or OVRN; the named-resource route always answers 404. The index is loopback only and is not forwarded by the public nginx vhosts. Rooms people create on openvibe.chat (roadmap WS-I task 4, `server/rooms/`), beside the global room and Live's stream and channel rooms: `/api/chat/rooms` (REST), `join_room` / `room_message` on `/ws/chat`, and the pages `/rooms`, `/r/:slug`, `/r/:slug/settings`. Public rooms: anyone reads, signed-in people join. Private rooms: members only; to anyone else they answer exactly like a room that does not exist (read, join, post, members, attachments, the call). Unread counts per room (`last_read_id`) and `last_seen_at` per member (moderators see it). Kinds: **community** (anyone starts one), **call** (the room a call happens in: its text chat, and the voice/video channel `room-` on `/ws/call`; anyone starts one) and **system** (announcements written by chat staff, read-only for people; only chat staff create them). Roles, and what they may do (`rooms.access()` is the one place this is decided): | Kind | Role | Read | Post | Join the call | Talk | Moderate | Manage | | --- | --- | --- | --- | --- | --- | --- | --- | | community | owner / mod / member / viewer | all | owner, mod, member | – | – | owner, mod | owner | | call | owner / mod / speaker / participant / viewer | all | owner, mod, speaker, participant | all | owner, mod, speaker | owner, mod | owner | | system | owner / mod / viewer | all | owner, mod | – | – | owner, mod | owner | | any | blocked | – | – | – | – | – | – | | any | no role | public rooms only | – | public call rooms (listening) | – | – | – | Chat staff (`staff.moderation.chat`) read, moderate and manage every room, post in system rooms and talk in calls; a site ban stops posting and talking. Joining gives the kind's role: member, the call room's `join_role` (participant by default; the owner may pick speaker or viewer) or viewer. The owner (and chat staff) appoint mods; owners and mods set every other role; roles outside the room's kind are refused (`rooms.role_kind`). Role changes on someone else are logged (`room_block`, `room_unblock`, `room_mod_add|remove`, `room_speaker_add|remove`) through the moderation log. A change reaches open sockets at once: `/ws/chat` followers get `room_access { role, can }` (or `room_left`), and a running call follows the room (below). **Call rooms.** The call server makes the channel `room-` on first use and keeps it; it is not in Live's voice-channel list. Who may read the room may join its call; owner, mods and speakers talk; participants, viewers and people without a role (anonymous too, in public rooms) join listen-only: force-muted with the camera forced off, their own unmute ignored, and a moderator cannot lift it (make them a speaker instead). Peers see `roomRole` / `canTalk` on each participant. Promoting, demoting or blocking someone sends them `room-role` and the force-mute state, or drops them; a ban inside the call is also the room's block; making the room private drops everyone who is not a member. Media is peer to peer, so listen-only is enforced by clients honouring force-mute, as every force-mute here. openvibe.chat's room page carries an audio call client (`public/web/call.js`), shown when `CHAT_CALLS=1`; ICE comes from `GET /api/chat/ice-servers` (`server/net/turn.js`, Live's `turn.js` logic: STUN, plus TURN when `TURN_URL` is set with `TURN_AUTH_SECRET` or `TURN_USERNAME`/`TURN_CREDENTIAL`). **Attached to a Community space.** `POST /api/chat/rooms/:slug/attachments { service: "community", resource: , title? }` (201 created, 200 already there), `DELETE /api/chat/rooms/:slug/attachments/community/:space` (idempotent), `GET …/attachments`. Only a person who manages the room (owner or chat staff), with their own Network token, attaches it (API tokens are refused); the room's managers or whoever attached it detach. Community keeps the link on the space and checks its own side (the space's owner or staff), so one person holding both ends makes the link, and no service grant is involved. Chat shows attachments only to the room's managers (room settings, with Detach), never as a public claim on the room page. **AI members** (`server/rooms/ai-members.js`, `migrations/0012_room_ai_members.sql`; the owner's ask: friend groups and couples with AI members, and a Food household's chat with one that knows the pantry). A room's owner (or chat staff) adds up to 3: a name (its `@handle` is the name in lowercase), how it should be, and notes it always knows. When someone who may post writes `@handle`, it answers: the room's last 30 messages, its notes and any shared context go to OpenVibe.AI's `chat.member.reply` with Chat's own service token, attributed to the room (`{ service: chat, type: room }`), and the answer is a room message with `ai_member_id` set and `user_id` 0, shown with an AI badge, never as a person. Sockets get `room_ai_typing { room, name }` first. Only the first member named answers; it never answers an AI message; one answer at a time per room, 20 an hour from one person, and `CHAT_AI_MEMBER_REPLIES_PER_DAY` (100) a room a day, past which it says so instead. `CHAT_AI_MEMBERS=0` keeps them quiet (independent of the insight job's `CHAT_AI_ENABLED`). An AI member linked to an OpenVibe.Food household (`link_service: food`) also gets the household's pantry and plan, read from Food's `/internal/households/:plan/chat-context` with Chat's service token (`food.household.read`, at most once a minute; `CHAT_FOOD_INTERNAL_URL`). API: `GET|POST /api/chat/rooms/:slug/ai-members`, `PATCH|DELETE …/ai-members/:id` (people only; members see names and handles, managers the set-up); the settings page manages them without JavaScript. A room member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an account keeps the ordinary AI members it added (they belong to the room) without its subject on them, and exports them. **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 OpenVibe.Actor agents to a channel from its AI members settings. Anyone who can post in that channel can mention the agent; Chat starts the owner's personal agent through `actor.agent.run`, records a durable room task, and follows it through the existing workspace poller. The answer returns as that agent's room message. Actor applies the owner's budget, daily cap and approvals. The settings form says the answer is public to the channel. Deleting the owner's account removes its attached Actor agents; ordinary room AI members remain. Network must grant Chat `actor.agent.run` and Actor must allow Chat to read only the personal agent tasks it started. **Tools** (`server/rooms/ai-tools.js`; the owner's "modular AI conversation… tool calling"). An AI member's answer is a few turns of OpenVibe.AI's `chat.member.turn` (a Chat Completions passthrough with tools, AI#96): the model may call `search_openvibe` (OpenVibe.Search's public results through the Search Fabric's network scope, `CHAT_SEARCH_URL`), `web_search` for Vibe (the fabric's web scope; Actor only when the fabric is down), `current_time`, `roll_dice`, `pick_one`, and for a member linked to a Food household `household_pantry`; Chat runs them (read-only, bounded, results at most 4,000 characters) and sends the results back, at most three rounds with four calls each, then a turn that must answer in text. If a turn fails (a provider without tools), the member answers with `chat.member.reply` as before. `CHAT_AI_MEMBER_TOOLS=0` turns tools off. **Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model group chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter, Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays). The key goes to OpenVibe.AI (`PUT|GET|DELETE /api/v1/credentials/:subject`, Chat as its owner, Network grant `chat ai.credential.manage`) and is never shown again, only a four-character hint and today's spend. A room's manager sets a member to think with their own key (`engine: own`; `openvibe` is OpenVibe's model): its answers are runs that name `credential { subject }`, so they reach that provider only, count against the key's budget and not the room's daily cap. The room page and the API (`model`) say which model each member uses ("openai (openrouter.ai) · gpt-4o-mini, Ann's key"). Removing the key (or the account) sends its members back to OpenVibe's model; a new model relabels them. API: `GET|PUT|DELETE /api/chat/me/ai-key` (own sign-in only, not API tokens); pages: `/settings#oc-ai-key` and the "Thinks with" choice on each AI member. A server's admins give a text channel AI members at `/r//settings`, linked from the channel on the server's settings page. **A model on their own computer** (AI on your own machines; contracts 0.220.0/0.221.0). The key may instead be provider `node`: a `machine` (a `dev_` id from openvibe.link/machines, or `any`, the fastest of theirs that is on) and a model, with no key and nothing spent. OpenVibe.AI reaches it through OpenVibe.Bot and the person's OpenVibe Link, only where they turned on "Let my AI use this computer's models". - **The form** shows only the fields the chosen provider takes (`chat.css` `:has()`). It comes prefilled from Bot's "Use these models in OpenVibe.Chat" (`/settings?own=node&machine=dev_…|any&model=…`). - **A room** sees "llama3.1:8b on Fran's computer(s)" (`credentials.memberLabel`). - **Vibe** thinks with it when the person ticks "Vibe thinks with it too" (`vibe_own_engine`, migration 0035; `GET|PUT /api/chat/vibe/engine`): every Vibe chat of theirs and each new one. There is no daily limit then. The page says "Vibe thinks with … (change)", "How Vibe did this" says which model answered, and a computer that does not answer is named, with what to check. Removing the key ends it. **Vibe personas** (`server/persona/`, `migrations/0078_vibe_personas.sql`; the owner, 2026-11-12). Each Vibe conversation gets a name and a face made from what it is about, and a new one when the topic changes. The shape is `persona { name, glyph, hue, image_url|null, topic, made_at }`, on every item of `GET /api/vibe/conversations` and on `chat` in `GET /api/vibe/chat` (null until the first answer, and past the cap). The name also names the chat until the person renames it. The first is made after the first answer; then every 8 messages (configurable) the cheapest chat model answers one word, `YES` or `NO`, to "has the topic changed?". The glyph is one name from `openvibe-shared/glyphs`; an invalid answer is retried once and then replaced from the registry. Auto models are the cheapest chat and image models in Exchange's catalogue; every call is `ai.complete` (or Chat's `chat.persona.image` workflow for a picture) under Chat's own grant, billed to Chat, with its `usage.cost_usd` recorded against the owner's monthly cap. Images are off by default and go to OpenVibe.Media. The owner page is `/admin/persona` (spend shown, model choices, interval, cap, remake one chat); the same values are the revisioned `chat.persona` namespace, and `GET|POST /api/admin/persona` + `POST /api/admin/persona/remake` are owner-sign-in only. `docs/vibe-personas.md` has the shape, settings and test notes. **Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`, `migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers, channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text channel is a community room and a voice channel a call room (its call is `room-`), so messages, live delivery, unread counts, calls and AI members work in channels unchanged. `rooms.access()` asks the server for any room with `server_id`: the owner is the channel's owner, admins and moderators its moderators, members its members (in a voice channel: speakers by default, or listeners when the channel says participant); a public server is read by anyone and joined by signed-in people, a private one is a 404 to outsiders and joined only through an invite (`/join/`: any number of uses or 1/10/100, for a day, a week, a month or forever, revocable). The owner appoints admins, admins appoint moderators and manage channels, settings and invites, moderators remove and ban members; nobody acts on their own rank or above, and every action is logged (scope `server:`). A server starts with `#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 text channel. A channel's room slug is random (`ch…`); its page is `/s//` and `/r/` goes there. Channels follow the server's visibility (going private redacts what they had published); the rooms API refuses room-level joins, roles and visibility there, and `/rooms` leaves channels out. The page: your servers down the side with unread counts, the channels, the channel (its call for a voice channel) and the people by rank; on a phone the servers run across the top and the channel list folds away. API: `/api/chat/servers` (list, create, read, update, delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:code`). Deleting an account removes its memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when nobody is. Forum channels are available by default for a server manager who can connect a Community forum they moderate; the owner can disable them with `forum_channels_enabled`. Slash commands and custom server emoji are available by default and can be disabled with `CHAT_SLASH_COMMANDS_ENABLED=0` and `CHAT_SERVER_EMOJI_ENABLED=0`. Stage channels use Chat's voice rooms and are available by default; the owner can disable them with `stage_channels_enabled`. **Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it to OpenVibe.Pics **as that person**: their own Network token, through MediaHub on this host (`CHAT_PICS_INTERNAL_URL`, `Host: openvibe.pics`, their address in X-Forwarded-For), so the picture is theirs on Pics (re-encoded without its metadata, reportable, deletable, inside their Pics limits). The room gets a message with the caption and the picture's link. Rooms and channels show OpenVibe.Pics direct links (`https://openvibe.pics/i/.`) as thumbnails (`/i//thumb.webp`) linked to the picture's page, at most four a message, on the page and live (chat.js); no other host's images, and global and stream chat stay text. JPEG, PNG, GIF, WebP and AVIF up to `CHAT_PICS_MAX_BYTES` (20 MB); `chat.room.picture` 10 a minute and 120 an hour per person; API tokens cannot send pictures. Off (no form; the API answers 503 `pictures.off`) without `CHAT_PICS_INTERNAL_URL`. **openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`, `/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox (conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock), `/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation counts unread messages, room messages and server messages. Everything is server-rendered and works without JavaScript. **Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and the only openvibe.live URLs are profile links. `test/live-down.test.js` renders every page and uses the forms, the core APIs and the socket with Live refusing connections and with Live hanging. ### Workspace rooms and the staff room (openvibe.chat/r/staff) A workspace room has a **work queue** beside its conversation ([server/rooms/workspace.js](server/rooms/workspace.js), [server/web/workspace-pages.js](server/web/workspace-pages.js), migration `0027_workspaces.sql`): the team talks, asks `@vibe`, and everyone sees what is queued, being worked on and done. The **staff room** (`/r/staff`, seeded by the migration with Vibe in it) is OpenVibe staff's own: every member of staff (chat staff, `staff.moderation.chat`) becomes a member on their first visit, and nobody else reads it, whatever role they once had. **Any community room** becomes a workspace when its owner ticks "Work together here" in the room's settings (or `PATCH /api/chat/rooms/:slug` `{ "workspace": true }`), and "Start a room" offers a **Workspace** kind (preselected by `/rooms?new=workspace&name=…`, the 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 announce the work. Switching it off hides the queue and drops the shared screen; the staff room stays a workspace, and a call room, a server's channel or a chat with Vibe never becomes one. - **Adding work:** anyone who may post adds a task (title, kind, details) from the panel or the API, or asks Vibe, which queues it with its `queue_task` tool (one task per job) and answers what is going on with `queue_status`. - **Quick kinds** (`answer`, `research`, `web`) run at once as OpenVibe.Actor tasks of the person who asked: Chat's service token names them in `X-OV-Subject` (their allowance, their task list; Actor records Chat as `via`). A poller follows them every 10 s. - **Other kinds** (`code`, `computer`, `other`) wait for a worker: a member (or an agent working with a member's API token, such as the agent harness) claims one, then reports how it went and where it landed (an https link, such as a pull request). Or the person who asked a `code` or `computer` task presses **Run on my computer**: it becomes their own OpenVibe.Actor task, which hands code to OpenVibe.Codes on their machines (OpenVibe.Run's workers) and computer work to their computer through OpenVibe.Bot, at the level they set there; without such a machine the task fails with Actor's reason. - **Results** of every task, whoever ran it, are announced in the room by its Vibe with a link to the task's page (`/r/:slug/tasks/:id`: details, the full result, sources, cost). Open pages refresh the panel when the chat socket sends `room_task`. - **Alerts into the room** (`server/rooms/hooks.js`, migration 0029). The room's owner makes up to five hooks on its settings page: an address (`POST /api/chat/hooks/rh_…`) and a secret, shown once. A POST signed like an OpenVibe.Events delivery (X-OpenVibe-Signature-V2 over the raw body, ±5 minutes; an OpenVibe.Watch webhook action with the secret as a project `secret_ref` sends exactly that) becomes a message from the room's Vibe: a Watch firing ("🔔 Site errors: Live home p95 fired: 2.4 (was 0.9)" and the watch's link), an OpenVibe event, or `{ "text" }`. A hook can also queue a task of a chosen kind for the person who made it, while they may still post there. Wrong or stale signature 401, unknown hook 404, unreadable body 422, past 60 an hour 429; a repeated X-OpenVibe-Delivery-Id changes nothing. Hooks are in their maker's export and go when the account does. - **Cancelling** is the asker's or a moderator's. Limits: 20 tasks an hour per person, 300 a day per room. - **A shared screen** (migration `0028_room_screen.sql`): a room's managers set a screen share link from OpenVibe.Bot (`https://openvibe.bot/screen/…`, made by the computer's owner on its screen page; nothing else is accepted). Members see that computer's screen live above the conversation, and with a "use" link they can start it and click and type on 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, and `screen.url` in `GET /api/chat/rooms/:slug`). Pages allow `frame-src https://openvibe.bot`. - **Account data:** a person's tasks are in their export (`room_tasks.json`); on deletion the room keeps the work without who asked or who took it. API (`/api/chat/rooms`, also in openvibe-sdk): `GET|POST /:slug/tasks`, `GET /:slug/tasks/:id`, `POST /:slug/tasks/:id/cancel|claim|run|report`. ### Vibe, OpenVibe's assistant (openvibe.chat/vibe) The owner, 2026-10-24: "turn openvibe.chat into the main chat app", familiar like ChatGPT (plan track K, `OpenVibe_Consumer_Plan_2026-10-24.md`). `/vibe` is that app: the person's chats with Vibe down the left (Today, Yesterday, Previous 7 days…), the conversation in a readable column, one composer at the bottom, suggestions on a new chat, and the rest of Chat (Messages, Rooms and servers, Global chat) under the list. It works without JavaScript (forms post; the page shows Vibe's answer once written); `public/web/vibe.js` starts a chat in place, follows the room socket, sends on Enter and keeps a draft typed while signed out through the sign-in. - **Modes** (the composer's chips, plan track K): **Chat** answers here and now. **Research**, **Agent** and **Code** post the message without Vibe's instant answer and make it a task of the chat (`vibe.work`, the workspace queue's machinery in `server/rooms/workspace.js`) on the person's own OpenVibe.Actor allowance: research with sources at once; Agent on their own computer through Node and Bot; Code with OpenVibe.Codes on their machine through Run. Vibe says it is on it with the task's link, and the result comes back into the chat. `POST /api/chat/vibe { message, mode }` starts a chat that way; `POST /api/chat/vibe/:slug/ask { message, mode }` asks in one; the forms post `mode` too. - **A chat is a private room** (`server/rooms/vibe.js`, `migrations/0015_vibe_chats.sql`: `rooms.assistant = 1`) the person owns, with one AI member, Vibe, who answers every message without being named (`ai-members.js` afterPost). Messages, pictures, the socket (`join_room`, `room_message`, `room_ai_typing`), export and account deletion are the rooms'. Nobody can be added to it and it cannot be made public; it is not listed with rooms, does not count toward the 10 rooms a person may own, and is not in the unread counts. A chat is named after its first message (40 characters, cut at a word); deleting one deletes its messages (not archived). - **Vibe's instructions** (`vibePrompt`): a private chat with the person, Markdown answers (rendered by `public/web/vibe-md.js`, which escapes everything first and links only http(s)), the last 30 messages at 2,000 characters each, answers up to 8,000 characters (1,500 tokens). Tools: `search_openvibe`, `read_page` (a page's text through OpenVibe.Tools' `read` tool, `CHAT_TOOLS_URL`; Tools decides what may be reached), `web_search` (the open web through the Search Fabric's web scope: Search's open index, then its paid/free providers, each result with its URL, provider and date; `server/rooms/search-fabric.js`, `server/rooms/web-search.js`; Chat's service token has `search.query.delegate` and names the person in `X-OV-Subject`; `CHAT_SEARCH_URL`; Vibe added to a room has it too), the time, dice, picks. The pre-fabric paths stay only as fallbacks: `/api/v1/search` for the network scope, and an OpenVibe.Actor web task (`CHAT_ACTOR_INTERNAL_URL`; Network grants `actor.task.create`/`actor.task.read`) when the fabric is down. It says plainly what it cannot do yet (make pictures, use the person's computer, reminders). - **Pictures** (`pictures.forModel`, `ai-members.js` `picturesFor`): an AI member (Vibe included) sees the OpenVibe.Pics pictures a message links, or those of the asker's message just before it (picture first, then the question), at most two, fetched from Pics on this host (Host `openvibe.pics`, at most 4 MB each) and sent as image parts with the question. The app's composer has a picture button (or paste one); a new chat can start with a picture: `POST /api/chat/vibe/picture` (the body is the picture, the question in `X-Caption`) creates the chat and posts the picture through Pics as the person; a picture Pics refuses leaves no chat behind. - **Limits:** `CHAT_VIBE_REPLIES_PER_DAY` (100) answers a person a UTC day across all their chats (`vibe_usage`; deleting a chat gives none back), 60 messages an hour, 500 chats kept (starting one more deletes the oldest), the rooms' post limits. `CHAT_VIBE=0` turns Vibe off (it also needs AI members on and Chat's OAuth secret). - **API:** `GET /api/chat/vibe` (chats, allowance), `POST /api/chat/vibe { message }` → 201 `{ room, message }`, `PATCH /api/chat/vibe/:slug { name }`, `DELETE /api/chat/vibe/:slug`; then the rooms API for messages. People only (an API token gets 403). - **Model comparison:** `POST /api/vibe/ask { message, models: [id, ...] }` runs one to three AI catalogue models in parallel on the person's own AI allowance and keys (`on_behalf_of`). `GET /api/vibe/chat` groups their answers under the question with model, provider, cost, elapsed time and run id. `POST /api/vibe/pick` keeps one model for follow-ups; `POST /api/vibe/prefer` submits a pairwise preference to Bench. `GET /api/vibe/models` fills the model sheet. Auto still uses Vibe's existing engine and tools. `GET|POST /api/vibe/import` brings unsealed Exchange chats once through its scoped service export; `POST /api/vibe/import/dismiss` hides the notice. The import needs the `exchange.chat.export` grant to Chat's service client. ### Support inboxes and the website widget (openvibe.chat/inbox) The owner, 2026-10-24: one chat system "from your private conversations to your random live chat widget on websites", for people and for companies, with "direct live support with an agent", "fully modular and SDK and API" (`docs/platform.md` has the comparison with Intercom, Crisp, Chatwoot, Stream and the order of work). An **inbox** (`server/inbox/`, `migrations/0016_support_inboxes.sql`) is a team's support desk that any website opens with one tag: ```html ``` - **The widget** (`public/web/widget.js`, under 3 KB compressed): a bubble in a shadow root (the site's CSS cannot reach it) that opens the panel `openvibe.chat/embed/inbox/:id` in a frame on first use. `data-position="left"`, `data-color`, `data-open="1"`. From the page: `OpenVibeChat.open()`, `.close()`, `.toggle()`; events `openvibechat:open`, `:close`, `:message` (an answer arrived; the bubble shows a dot while closed). On phones the panel is full screen. - **The visitor** needs no account: a random token (24 bytes) kept in the frame's own storage, stored only as its SHA-256, resumes their conversation; the page they wrote from is kept with it. The panel says who answers (Vibe, a person, waiting) and has "Talk to a person" while Vibe answers. - **Vibe answers first** when the inbox has it on: from the inbox's instructions and up to 10 of its pages (`read_page`, those hosts only), with the conversation so far; it never invents prices, policies or order details, and ends with `[HANDOFF]` (not shown) when a person should take over, which makes the conversation `waiting`. At most 30 answers a conversation and `CHAT_INBOX_AI_REPLIES_PER_DAY` (500) an inbox a UTC day; then it waits for a person. A person's reply takes the conversation (`open`) and Vibe stays quiet in it; resolving it closes it, and the visitor's next message starts a new one. - **The inbox's people** (an owner and up to 50 agents, added by username; a person makes at most 10 inboxes) work at `/inbox/:id`: conversations down the left (Active, Waiting, Resolved), live over the chat socket (`inbox_watch`), replies on Enter. The owner's settings page shows the tag, the greeting, colour, Vibe's instructions and pages, and **allowed sites**: once named, only those sites may frame the panel (`frame-ancestors`) or call the visitor API; until then any site may. - **A site assistant** is an inbox whose people do not answer (`handoff` off in its settings): Vibe answers alone, there is no "Talk to a person", Vibe is told nobody else answers (it points to the contact details in the instructions instead of handing off), and when it cannot go on the visitor is told plainly. An inbox needs Vibe or its people. - **The visitor has an inbox too:** in any site's panel, "Keep this conversation: sign in" signs them in to OpenVibe (the same ticket window as a room on a site; the token stays in the panel's own storage, sent as `Authorization`). Their conversation moves to their own token for that inbox (derived from their account, never stored), so it follows them to every device, and every conversation they had with any site is listed under **Support** in their openvibe.chat Messages (`/support/:cid`: the thread, live over `inbox_watch`, and a reply box; writing to a resolved one starts a new conversation with that inbox; apps: `GET /api/chat/support`, `GET|POST /api/chat/support/:cid/messages`). The account comes only from the request's credentials. A site's verified identity, when there is one, still decides the token. Export lists these conversations; deletion leaves them with the inbox's team, without the person. - **Who answers** (`server/inbox/routing.js`): replying takes a conversation, and any of the inbox's people can assign it to another ("Answered by", `PATCH /api/chat/inboxes/:id/conversations/:cid { assignee_id }`). With **take turns** on (`route: round_robin`), a visitor who starts waiting goes to the person assigned longest ago, and only they are notified. The list has a **Mine** tab (`?status=mine`) and shows who has each conversation. - **Business hours** (`server/inbox/hours.js`; settings, or `PATCH { hours: { tz, days, start, end } }`, null for always): outside them the panel says when the team is back ("tomorrow at 9 AM (Los Angeles time)"), a handoff says so, and Vibe tells visitors a person replies when the team is back. Notifications still go out. - **Verified visitors** (for a site with its own accounts): the owner turns them on in settings, which makes an identity secret (`POST /api/chat/inboxes/:id/identity { action: on | rotate | off }`) to keep on their server. For a signed-in visitor the site adds `data-user-id`, `data-user-name`, `data-user-email` and `data-user-hash` (the HMAC-SHA256 of the id with the secret, in hex) to the tag, or calls `OpenVibeChat.identify({ id, name, email, hash })`. When the hash checks out, Chat trusts the name and email, the team sees "verified" with the email, and the visitor's token is derived from their id (`CHAT_INBOX_IDENTITY_KEY`, else Chat's OAuth secret; never stored), so their conversation follows them to every device. A wrong or missing hash is just an anonymous visitor. Agents never see the secret. - **Webhooks** (`server/inbox/webhooks.js`; up to three an inbox, the owner's; https on the public internet only): any address gets every event as JSON, `{ event: { id, type, source: 'chat', occurred_at, data } }` with `type` `chat.inbox.conversation.created | .waiting | .resolved | .rated` or `chat.inbox.message.created`. Each is signed like an OpenVibe.Events delivery with the webhook's secret (shown once), so `parseDelivery(raw, headers, secret, { requireV2: true })` from `openvibe-sdk/events` checks it. A Slack (`hooks.slack.com`) or Discord (`discord.com/api/webhooks`) address gets a readable line instead, when a visitor waits for a person, writes while waiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); each webhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks { url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`. - **Actions** (`server/inbox/actions.js`; up to five an inbox, the owner's; https on the public internet only): Vibe asks the team's own systems while it answers, like looking up an order. The owner names one (`look_up_order`), says what it does and when to use it, lists what it needs (`order_id: the order number`) and gives an address; Vibe sees it as the tool `action_look_up_order`, asks the visitor for what it needs, and Chat POSTs `{ event: { type: 'chat.inbox.action', data: { action, arguments, inbox, conversation: { id, page_url, verified, visitor_id, visitor_email } } } }` signed with the action's secret (shown once; `parseDelivery` checks it). `visitor_id` is the site's own id for a verified visitor, else null, so the site answers only about that person's orders. The answer, `{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST /api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }` ("Try it" in the settings), `DELETE /:id/actions/:actionId`. - **Voice calls** (call escalation; `CHAT_CALLS` on): one of the inbox's people presses Call on a conversation (`POST /api/chat/inboxes/:id/conversations/:cid/call` → `{ channel }`). The thread says so, and the visitor's panel offers "Join the call" or "Not now". The call is `support-` on `/ws/call`, audio and peer to peer (`public/web/support-call.js`), at most four people. The visitor joins with their visitor token (a `support-visitor` message); the inbox's people join as themselves; anyone else gets "not found". Either side ends it for everyone (`DELETE …/call`, or the visitor's `POST /api/chat/widget/inbox/:id/call/end { token }`), and resolving the conversation ends it too. In the call the visitor may **share their screen** (the browser's own picker; not on phones): the team sees it above the call bar, with a full-screen button. A conversation carries `call: true` while one is open. The widget's frame may ask for the microphone (the browser still asks the visitor). - **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days: 7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet longer than that go, with their messages and translations; a visitor waiting for a person or a conversation a person has open never does. The owner downloads everything (the 5,000 newest conversations with their messages): `GET /api/chat/inboxes/:id/export?format=csv|json`, or the settings page's links. In the CSV, one row per message, a cell that would run as a spreadsheet formula starts with `'`. - **Pictures:** one of the inbox's people sends a picture from the reply box (`POST /api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the inbox show inline (only openvibe.pics, at most four a message). **A visitor signed in to OpenVibe** sends one the same way from the panel's picture button (`POST /api/chat/widget/inbox/:id/picture`, the body is the picture, their token in `X-Visitor-Token`, a line in `X-Caption`): it goes to OpenVibe.Pics as them, and their messages carry `account: true`, so only their Pics links show as pictures. **Vibe sees them too:** the newest picture in a 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 `inbox.sign_in_to_attach`) until Pics scans anonymous uploads, and a link one pastes stays a link. Wherever a message's pictures show, their links are not repeated as text, and lists say "📷 Picture". - **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader's language (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message and language, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's (`POST /:id/translate { text, to }`). OpenVibe.AI translates; `CHAT_INBOX_TRANSLATIONS_PER_DAY` (300) new ones an inbox a UTC day, 30 a minute a person. - **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE /:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter or Tab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name. - **Ratings:** a resolved conversation asks the visitor "How was this?" (👍 / 👎, then a line if they like: `POST /rating { token, rating: 1 | -1, comment?, conversation? }`); the people see it in the list and the conversation, live (`inbox_rating`). - **Nobody misses a visitor:** when a conversation starts waiting (the visitor asked for a person, Vibe handed off, 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 of them (or only whoever took the conversation) gets an OpenVibe notification through the Network (`server/inbox/notify.js`; Chat's service token, `network.notifications.push` as service `chat`), at most one per conversation every 10 minutes except when it starts waiting. `CHAT_INBOX_NOTIFY=0` turns it off. - **Build your own panel** with the visitor API, which answers any site without cookies (CORS open, the token is the key): `GET /api/chat/widget/inbox/:id` (name, greeting, colour, whether Vibe answers), `POST /visit { token? }` → `{ token, conversation, messages }`, `GET /messages?after=` (token in `X-Visitor-Token`), `POST /messages { token, message, page_url?, name? }`, `POST /handoff { token }`. Visits 60 a minute, messages 20 a minute. - **Agents API** (signed in, or an API token acting as its person): `GET/POST /api/chat/inboxes`, `GET/PATCH/DELETE /api/chat/inboxes/:id` (the answer carries `embed.script`), `POST /:id/members { username }`, `GET /:id/conversations?status=active|bot|waiting|open|resolved`, `GET|POST /:id/conversations/:cid/messages`, `PATCH /:id/conversations/:cid { status }`, `POST /:id/conversations/:cid/read`. Socket: `{ type: 'inbox_watch', inbox }` (a person of the inbox) or `{ type: 'inbox_watch', inbox, conversation, visitor_token }` (the visitor), then `inbox_conversation`, `inbox_message`, `inbox_status`, `inbox_typing`. - Export and account deletion cover inboxes: the owner's inboxes go with their conversations; an agent's replies in other inboxes stay, signed "A former agent". ### A chat room on any website (the widget's room mode) The same tag puts a **public room** on any site (`server/web/room-embed-pages.js`, `public/web/room-embed.js`): as a bubble, ``, or inside an element of the page, `data-target="#chat" data-height="520"`. The owner's room settings give both tags. Only public rooms embed (not private ones, Vibe chats or a server's channels), and the frame may sit on any site. Anyone reads, live over the chat socket (`join_room`); people sign in with OpenVibe to write, and the room's moderation and OpenVibe's global standard apply because posting goes through `rooms.post`. Signing in from a frame: browsers give a frame on another site no openvibe.chat cookies, and the sign-in window loses its link to the frame on openvibe.network (Cross-Origin-Opener-Policy). So the frame makes a random ticket, opens `/embed/signin?ticket=…`, and asks `POST /embed/ticket` until the window has bound the ticket to the person's session (two minutes, once). The token lives in the frame's own storage, which the browser partitions by the embedding site. "Sign out" forgets it. ### Feeds: vibe coding, live (openvibe.chat/feeds) The owner, 2026-10-24: "improve the vibe coding feed feature on live and migrate that whole entire feature so it's built into the openvibe.chat system". A **feed** (`server/feeds/`, `migrations/0017_feeds.sql`) is live activity a person publishes into Chat; vibe coding, moved from OpenVibe.Live (its `server/vibe-coding/`, same canonical events), is the first kind. Viewers see a card (`public/web/feed-view.js`): turns of a prompt, the steps taken (tools used, files changed) and the answer, the newest open, a live dot while steps arrive. - **Where it shows:** in a room its owner owns (above the messages), on the owner's Live channel slot while it is live (as Live's `vibe-coding` frame on the stream's chat, which Live's channel widget reads), and on any site: `