# Rooms and messages — what a Space holds A Space is a room: its members, their messages and the assistant's, a name, and the files people share. This page is how your page makes or opens a room, reads what is in it and writes to it. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Create and open a room {#open} ```js const space = await wb.createSpace({ agent: { name: 'Helper', instructions: 'You help a small group plan together.' } }) const same = await wb.openSpace(link) await space.ready const saved = space.snapshot() space.close() ``` - **`wb.createSpace(opts)`** makes a room and returns it, live. `agent` is `{ name, instructions, tools?, model? }`: the name up to 80 characters, the instructions up to 4,000. `tools`, `immediateTools`, `tier` and `key` are fixed when the room is made, and `admission` changes only through [`changeMembers()`](./sdk-people.md#members); everything else [changes in place](./sdk-assistant.md#changing-the-assistant). An option the SDK does not know is refused. - **`wb.openSpace(link, { resume?, tier? })`** joins a room from its link. The key is in the link's fragment, which never reaches a server. `openSpace` does not read the page's own address; to open it, pass `location.href` ([invite links](./sdk.md#invite-links)). - **`space.ready`** resolves once the room's whole history has been read — a long room loads in pages. From then, `messages` and `data.rows()` hold everything the room had. It rejects only when the key can never open the room (`cannot_open`) or the handle was closed first (`closed`). - **`space.snapshot()`** returns `{ count, etag, messages, rows }` as JSON. Pass it back as `resume` and the next open continues from it instead of reading the whole room again. - **`space.shareLink`** is the link to give someone: holding it lets them in. `space.room` is the room's id. - **`space.close()`** stops listening and releases the handle. ### Temporary rooms {#temporary} ```js const desk = await wb.createSpace({ agent, tier: 'enclave', temporary: { days: 7 } }) // kept 7 days after its last question const quick = await wb.createSpace({ agent, temporary: true }) // wiped about 30 minutes after it goes quiet ``` - **`temporary: true`** makes a room Witbitz wipes once it has been idle for about 30 minutes, like the app's temporary Spaces. **`temporary: { days }`** (1 to 7) keeps it that many days after its last question, then wipes it: a help desk on a public page that a visitor comes back to. Each question pushes the date out again. - A temporary room is never in the [account backup](./sdk-accounts.md#backup). `space.temporary` is `true` for one. - Where a deployment cannot wipe rooms, the call is refused (`temporary_unavailable`) rather than making a room that stays. A value other than `true` or `{ days: 1–7 }` is refused (`invalid_temporary`). Not in a vault yet (`vault_unsupported`). ## Messages: who sent what {#messages} `space.on('message', cb)` hears every entry in the room — your messages, other members', the assistant's replies, store writes and system lines — and `space.messages` is the list so far. On a room you open, the history is replayed to `cb` first; pass `{ replay: false }` to hear only what arrives after it. Every `on()` returns a function that removes the listener. A message is the room's entry as stored, decrypted on the device, with `from` taken from `name` when it has none. Its fields depend on its kind ([`Message`](https://witbitz-spaces.pages.dev/witbitz-sdk.d.ts) types them all): | `kind` | What it is | Notable fields | |---|---|---| | `'text'` | A member's message, an assistant's reply, a private lane's message into the room, or a system line | `from`, `text`; replies: `self`, `sources?`, `usage?`, `cert?` (confidential rooms); from a lane: `via: 'bridge'`, `draft.by`; system lines: `sys` | | `'media'` | A member's message with `images` or `files` | the bytes: [`space.attachment(att)`](#attachments) | | `'data'` | A write to the [store](./sdk-store.md) (also fires `'data'`) | `ops`; `self` when the assistant wrote it | | `'mcp'` | A call from your app to the assistant over the embed bridge | `mcpName`, `mcpArgs` | | `'fork'` | A move an older client made (also fires `'moved'`) | handled for you | Any message may also carry `verified`, `user` and `spk` (the sender passed the room's gate), `replyTo`, `md`, and `voice` (it was dictated). One copied in by an [older client's move](./sdk-people.md#fork) carries `carried` — its marks from the room it was written in — and `carriedBy`. **`self: true` means the room's assistant produced it.** The server sets it on the assistant's replies and data writes and on nothing else; it never means "I sent this". ```js space.on('message', (m) => { if (m.sys) return renderSystemLine(m) // a settings change, a connection call if (m.self && m.kind === 'text') return renderReply(m) // the assistant render(m, { mine: m.from === MY_NAME }) // MY_NAME: the `from` you send with }) ``` In a gated room, `from` is only a name the sender typed. What the server verified is `m.user`, the sender's @username when they have one, and `m.spk`, the signing key of the **device** that sent it — each device has its own. So "is this mine" is `m.user` against your own @username, or, for a member with none, `m.spk` against this device's key, which reads your other devices' messages as someone else's. The Spaces app checks both: ```js const me = await wb.usernames.mine().catch(() => null) // throws when signed out const isMine = (m) => !!(me && me.username && m.user === me.username) || !!(m.spk && session.sign && m.spk.x === session.sign.pub.x && m.spk.y === session.sign.pub.y) ``` ## Send {#send} ```js const r = await space.send('Plan us a weekend in Kyoto', { from: 'Dana' }) if (!r.ok) showError(r.error) ``` - **The answer comes separately.** The assistant decides whether to reply, on its own turn, and the reply arrives on `on('message')`. To wait for the answer to this message, use [`space.turn()`](./sdk-assistant.md#turn). - **`send()` never throws.** It answers `{ ok: true }` when the message was posted, and `{ ok: false, status, error }` for a refusal; every case is in [Error codes](./sdk-reference.md#errors). - **`instructions`** (up to 8,000 characters) reach the assistant for this reply only — today's date, "answer in Hebrew" — and are never stored. **`mode`** overrides the [reply mode](./sdk-assistant.md#reply-mode) for this turn. - **`images`** (up to 4) and **`files`** (up to 2) are `{ b64 | bytes, mime, name? }`, stored sealed in the room. Over the limits, the call is refused before anything is sent. ## Replies and reactions {#replies} ```js await space.send('Saturday works for me', { replyTo: m }) await space.react(m, '👍') await space.react(m, '👍', { off: true }) space.on('update', ({ message, fields }) => rerender(message)) ``` A reply is shown with the message it answers quoted beside it, as in the Spaces app; it carries `replyTo: { key, name?, excerpt? }`. A reaction is written on the message itself — no new entry, no assistant turn — and reaches every device as an `'update'`, the event for any message changed in place (a reaction, an image's caption, a reply's cards). A reacted message carries `reactions`, emoji → the names that reacted; in a verified room `reactKeys` maps each name to its key. ## Attachments {#attachments} The images and files on a message are stored apart from it, sealed under the room key. `await space.attachment(att)` fetches one — `message.images[i]` or `message.files[i]` — and opens it on the device: `{ b64, mime, name? }`. ## Safe media {#safe-media} In a room with **Safe media** on, a photo or a PDF someone else posted is never opened on a member's device. What is shown is a **safe copy**: JPEG images made once, when the file is posted, by an attested Witbitz enclave (the tools enclave). It opens the file in a one-shot process with no network and no keys, and keeps nothing. A PDF's copy is its first page. The copy is stored beside its original, and each device checks the enclave's hardware attestation over the copy's exact bytes before it draws anything. A copy that does not check out is never shown. The same setting is **Safe media** in the Spaces app (⋯ → This Space). ```js const space = await wb.createSpace({ agent: { name: 'Desk' }, safeMedia: true }) // or, later: await space.safeMedia.set(true) // = space.configure({ safeMedia: true }); a line says who turned it on space.on('message', async (m) => { if (!m.images?.length || m.self) return const copy = await space.safeMedia.copy(m) // { ok, versions: [{ w, h, b64 }] }, or { ok: false, why } if (copy.ok) draw(copy.versions.at(-1).b64) // the largest version, image/jpeg else showCard(m) // no verified copy (yet): show a card with the name instead, never the original }) space.on('safeCopy', ({ for: f }) => redraw(f.id)) // a copy arrived after its message ``` - **The setting.** `createSpace({ safeMedia: true })`, `space.configure({ safeMedia })` or `space.safeMedia.set(on)`. In a room that names admins, only an admin may change it. In a room without admins, any member may. Every member sees a line saying who did, and `'safeMedia'` fires (`{ on, from, ts }`). `space.safeMedia.on` is what this handle knows (`null` before `await space.safeMedia.refresh()`). - **Making the copies.** While the setting is on, `send()` makes the copies of the photos and PDFs it sends, and posts each one beside its message once the room has stored it. This needs a signed-in session ([Sign in](./sdk-people.md#sign-in)): it is counted to the account (300 files an hour). If the page is signed out or offline, or a file is over 3 MB, the message goes without a copy. Anyone may then call `await space.safeMedia.make(message, { at, i })` (→ `{ ok, error? }`). - **Reading.** `await space.safeMedia.copy(message, { at, i })` returns the first copy whose every version verifies here. `at` is `'images'` or `'files'`, `i` the attachment's index; the default is the first image, else the first file. Copies are kept beside their message: they never appear in `messages`. - **Opening the original** is the reader's choice, and only after asking. Fetch it with `space.attachment(…)`. Before you open it, `await space.safeMedia.isOriginal(copy, b64)` checks that it is the file the copy was made from. - **Your own files** are yours: draw them as you sent them. What the enclave sees: the file, sealed by the posting device to the enclave's attested key. The Space server carries it but cannot read it. The enclave answers with the copies and a signed statement that names the original's hash and each copy's hash; the hardware attests that statement. In a vault ([vault mode](./sdk-keys.md#vault-mode)), the setting and `copy()` work, `make()` is refused (`vault_unsupported`), and `send()` makes no copies. ## The room's name {#room-name} `space.title` is the room's name, the same on every member's device and in the Spaces app; `null` until it has one. `space.rename(name)` changes it: every member sees the new name and is notified as for a message, and the Spaces app shows "Alon renamed this Space to “Kyoto”". ```js space.on('title', ({ title, manual, by }) => { document.title = title }) await space.rename('Kyoto in September', { by: 'Alon' }) // → { ok: true, title: 'Kyoto in September' } ``` While a room holds one person and the assistant, its name follows the conversation: one is made after the first exchange and revised as the subject changes, with no line and no notification. Once anyone renames the room, or a second person joins, no name is made again. - A name is 1–80 characters; whitespace is folded and control characters dropped. - A name is not a message: it is never in `messages` and never fires `message`. `snapshot()` keeps it. - When two renames arrive at nearly the same moment, every device keeps the one the server stamped last. - **Refusals:** an empty name throws `bad_request`. In a room with owners or admins only they may rename it (`not_admin`); in a room without them, any member may. A [direct chat](./sdk-people.md#direct-chats) is named after the other person and is never renamed (`direct_chat`). In a gated room a rename is a write like any other: `not_authorized` with `e.reason` `not_on_list` (the signed-in email is not on the list) or `unsigned` (no one is signed in; a sign-in that did not verify says `token: …`). - `unavailable`: the room did not answer, and nothing was stored — try again. `ok: false` means an older server stored the name without announcing it; members see it the next time they open the room. ## Live updates {#live} In a browser, each open room holds one socket on the Witbitz relay (`wss://code-relay.witbitz.chat`), and what happens in the room arrives down it at once: the assistant's reply grows on `partial` as it is written, and a new message is fetched the moment it lands. It works in every room — open, gated and confidential. - **The relay learns nothing.** The socket's channel is derived from the room key and every frame is sealed under it. A gated room's channel also needs a short-lived grant that only a read that passed the gate hands out, so a member you remove stops hearing the room from the next message on. - **Every write, not only replies.** A store write, a reaction, an approved card, a copied message or a settings change sends a signal that carries no content, so other open devices fetch it at once. - **Polling is still the record.** If the socket cannot open, the room reads every 0.9 s after activity and every 3 s when idle; a lost frame costs one read, never a message. - **Cards come with the reply.** A card the assistant proposed during a turn is fetched as that reply lands, not at the next 1.5 s check, whenever you listen for `proposal`. - **Your CSP** needs `wss://code-relay.witbitz.chat` in `connect-src`. `new Witbitz({ live: false })` turns the socket off.