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 · every call: the reference.
Create and open a room
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.agentis{ name, instructions, tools?, model? }: the name up to 80 characters, the instructions up to 4,000.tools,immediateTools,tierandkeyare fixed when the room is made, andadmissionchanges only throughchangeMembers(); everything else changes in place. 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.openSpacedoes not read the page's own address; to open it, passlocation.href(invite links).space.readyresolves once the room's whole history has been read — a long room loads in pages. From then,messagesanddata.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 asresumeand the next open continues from it instead of reading the whole room again.space.shareLinkis the link to give someone: holding it lets them in.space.roomis the room's id.space.close()stops listening and releases the handle.
Temporary rooms
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 quiettemporary: truemakes 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.
space.temporaryistruefor one. - Where a deployment cannot wipe rooms, the call is refused (
temporary_unavailable) rather than making a room that stays. A value other thantrueor{ days: 1–7 }is refused (invalid_temporary). Not in a vault yet (vault_unsupported).
Messages: who sent what
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 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) |
'data' |
A write to the store (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 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".
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:
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
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, usespace.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.instructions(up to 8,000 characters) reach the assistant for this reply only — today's date, "answer in Hebrew" — and are never stored.modeoverrides the reply mode for this turn.images(up to 4) andfiles(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
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
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
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).
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 })orspace.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.onis what this handle knows (nullbeforeawait 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): 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 callawait 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.atis'images'or'files',ithe attachment's index; the default is the first image, else the first file. Copies are kept beside their message: they never appear inmessages. - 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), the setting and
copy() work, make() is refused (vault_unsupported), and send() makes no copies.
The room's 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”".
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
messagesand never firesmessage.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 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_authorizedwithe.reasonnot_on_list(the signed-in email is not on the list) orunsigned(no one is signed in; a sign-in that did not verify saystoken: …). unavailable: the room did not answer, and nothing was stored — try again.ok: falsemeans an older server stored the name without announcing it; members see it the next time they open the room.
Live updates
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.chatinconnect-src.new Witbitz({ live: false })turns the socket off.
Machine-readable source: sdk-rooms.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)