# The room as a store — your app's data, sealed with the conversation A Space can hold your app's **data**, not only its conversation: rows in named collections, sealed like everything else, ordered by the server without reading them, and readable by the assistant. An app can keep all its state in its rooms, with no database of its own. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Declare and write {#the-room-as-a-store} Declare the collections when you make the room, or later with [`configure`](./sdk-assistant.md#changing-the-assistant). Give each field one line: what it is, and the rule for it. Those lines go into the assistant's tool descriptions on every turn and are read at the moment it fills the field, so a rule about a field belongs on the field; who the assistant *is* belongs in its instructions. ```js const space = await wb.createSpace({ agent: { name: 'Planner', instructions: 'Keep my week honest.' }, data: { tasks: { description: 'things to do', fields: { title: 'short text', due: 'ISO date — only if I said one', done: '0 or 1' } }, projects: { fields: { name: 'short text' } }, }, }) await space.data.put('tasks', { title: 'Buy milk', due: '2026-09-22', done: 0 }) await space.data.put('tasks', { title: 'Buy oat milk', done: 1 }, { id: 't1' }) // with an id: replace that row await space.data.delete('tasks', 't2') space.data.rows('tasks') // [{ id, title, due, done, _updated, _by, _v }, …] space.data.get('tasks', 't1') space.on('data', ({ ops, from, self }) => rerender()) // every write, yours or the assistant's ``` `space.data.batch(ops)` writes several rows as one entry. A write answers `{ ok: true, id, ops }` and throws on a refusal. **The store is the room's history.** Every write is one entry in the room — `▦ Dana saved tasks “Buy milk”` — so every member sees it, two devices writing different rows never collide, and any device holding the key rebuilds the store by reading the room. There is no second copy to drift. A row is at most 16 KB. **What the assistant gets**, in a room that declares collections: | Tool | Runs | What it does | |---|---|---| | `data_query` | in the turn, at once | SQL over one table, `rows(collection, id, row JSON, updated, by)` — `SELECT … WHERE json_extract(row,'$.due') < ?` — so it answers "what is due this week" without being handed everything. Read-only, enforced by the engine; at most 200 rows. A query that runs longer than a few seconds is stopped, and the assistant is told to narrow it. | | `data_put` | **proposes** | Add or replace one row. It is written when a member [approves](./sdk-approvals.md), never during the turn. | | `data_delete` | **proposes** | Delete one row by id, the same way. | `dataWrites: false` lets the assistant read the store without proposing writes ([builder mode](./sdk-assistant.md#builder-mode)). `id` and every field name starting with `_` are the room's own: a write that sets one is refused (`reserved_field`) — what they carry is in [who did what](./sdk-people.md#who-did-what). ## Row schemas {#schemas} Field descriptions guide the assistant; a **schema** is enforced. Give a collection `schema` — JSON Schema for one row — and the room checks every write against it, so a row that does not fit is never stored: ```js const space = await wb.createSpace({ agent: { name: 'Ops' }, data: { tasks: { description: 'things to do', schema: { type: 'object', properties: { title: { type: 'string', minLength: 1, maxLength: 80, description: 'what to do' }, due: { type: 'string', format: 'date' }, priority: { enum: ['low', 'normal', 'high'] }, estimate: { type: 'number', minimum: 0, maximum: 40, description: 'hours' }, }, required: ['title'], additionalProperties: false, }, }, }, }) try { await space.data.put('tasks', { title: 'Call', priority: 'urgent' }) } catch (e) { e.code === 'invalid_row' /* tasks: priority must be one of "low", "normal", "high" */ } ``` - **Your writes:** a row that does not fit is refused with `invalid_row`, naming the field. In a `batch`, one bad row refuses the whole batch. - **The assistant's writes:** a `data_put` that does not fit is never proposed — the assistant is told why and tries again, so no member is asked to approve a row that would fail. A card is checked again when it is approved, in case the schema changed meanwhile. - **The assistant reads the rules:** each field's type, allowed values, `required` and `description` appear in its data tools. A schema's descriptions take the place of `fields`. - **Supported:** `type`, `properties`, `required`, `additionalProperties` (true/false), `enum`, `const`, `minLength`/`maxLength`, `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `items`, `minItems`/`maxItems`, and `format` (`date`, `date-time`, `email`, `uri`); `description`, `title`, `default` and `examples` are kept as notes. Anything else — `pattern` too, since our servers do not run regular expressions from a room's settings — is refused when you declare it (`invalid_schema`), never silently ignored. Up to 8 KB a schema, nested 6 levels. - **New writes only.** Changing a schema with `configure({ data })` does not touch the rows already stored, and rows an [older client's move](./sdk-people.md#fork) carried are not checked again. Confidential rooms run the same checks. ## Conditional writes {#conditional-writes} Two devices editing the **same** row is the one case a room's history cannot settle by itself — the last write would win — so a write can be made conditional. Every row carries `_v`, the id of the entry that last wrote it; pass it back, and the write is refused if the row has changed since: ```js const t = space.data.get('tasks', 't1') try { await space.data.put('tasks', { ...t, done: 1 }, { id: 't1', ifVersion: t._v }) } catch (e) { if (e.code === 'row_changed') { /* e.conflicts: [{ collection, id, version }] — read again, merge, retry */ } else throw e } await space.data.put('tasks', { title: 'first' }, { id: 't9', ifVersion: null }) // only if t9 does not exist yet await space.data.delete('tasks', 't1', { ifVersion: t._v }) ``` - The check and the write are one step on the server: it reads, compares, and appends only if the room has not grown in between, retrying under contention, so the losing device gets a clean `409 row_changed` and never a silent overwrite. - `snapshot()` keeps `_v`, so a conditional write after a resume compares against the right version. - Without `ifVersion`, a write is unconditional. ## Collections only admins write {#admin-collections} Declare a collection `write: 'admins'` and only the room's admins may write it — see [admins](./sdk-people.md#admins). The limits on collections and fields: [Reference](./sdk-reference.md#limits).