# The assistant — who it is, when it answers, what it may use Every Space has an assistant. This page is how you set it up and change it, when it answers, how your code gets its answer — or runs it as a function — which tools it may use, and voice. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Its name, instructions and model {#changing-the-assistant} You name the assistant when you make the room (`createSpace({ agent: { name, instructions, model? } })`), and change it later, in place, with `configure`: ```js await space.configure({ agent: { instructions: 'Never invent a value. Never create a project unasked.' } }) ``` - **What changes in place:** the assistant's `name`, `instructions` and `model`; the store's collections (`data`; `data: null` removes them); which [platform tools](#platform-tools) it gets (`platformTools`); the builder [`framing`](#builder-mode); and whether it may write rows (`dataWrites`). `configure` answers `{ changed }`, the fields it applied. - **Every change is visible.** The room's settings are sealed again with the change, and a system line — “⚙️ You changed the assistant's instructions” — lands in the room for every member, and in the assistant's own context. The next turn runs on the new settings. A room's assistant never changes silently. - **What does not change in place:** the room's tools (`tools`, `immediateTools`, `agent.tools`), its tier and its key are fixed when it is made; its members change only through [`changeMembers()`](./sdk-people.md#members). For a different setting, make a new room and copy what you need into it. - **Refusals,** made by `configure` before anything is sent: `create_time` (a field fixed at creation), `use_fork` (`admission` — use `changeMembers`), `lane` (on a lane), `unknown_field` (anything else). A room whose owner signed its policy refuses every change in place with `governed_room`; another client that tries gets `config_immutable` from the server. ### The model and its reasoning effort {#model-effort} `model` is a model id — `'kimi-k3'` — or an object that also sets how hard the model reasons before it answers: ```js const models = await wb.models({ tier: 'enclave' }) // [{ id, label, wire, efforts, defaultEffort, default }] await space.configure({ agent: { model: { catalog: 'kimi-k3', params: { reasoning_effort: 'high' } } } }) ``` The levels run `none` · `minimal` · `low` · `medium` · `high` · `xhigh` · `max`, and **each model takes only some of them** — the list differs by model, and for some models by tier. `wb.models()` returns each model's `efforts`, measured against the live provider rather than copied from its documentation; an empty list means the model takes none, so leave the field out. - A room that names no effort gets `defaultEffort`, which is `low`. A room that names no model gets the tier's default: `gpt-6-sol` on the standard tier, `deepseek-v4.1-flash` on the confidential one. - `createSpace`, `privateLane` and `configure` refuse a level the model does not take, before anything is made, with `invalid_effort` and a message naming the levels it does take. A stored room whose model no longer takes its level (after switching models) runs at `low` rather than failing. - Higher levels cost more tokens and time. On most models `low` is right for a conversational assistant; reach for `high` or above in a room whose job is analysis. The table is generated from the live catalog; when they differ, `wb.models()` is right. | Model | Tiers | `reasoning_effort` | |---|---|---| | `gpt-5.5` | standard | `none` `low` `medium` `high` `xhigh` | | `gpt-5.1` | standard | `none` `low` `medium` `high` | | `gpt-6-sol` | standard | `none` `low` `medium` `high` `xhigh` | | `gpt-6-luna` | standard | `none` `low` `medium` `high` `xhigh` | | `gpt-6-astra` | standard | `low` `medium` `high` `xhigh` | | `gpt-5.6-sol` | standard | `none` `low` `medium` `high` `xhigh` | | `gpt-5.6-luna` | standard | `none` `low` `medium` `high` `xhigh` | | `gpt-5.6-terra` | standard | `none` `low` `medium` `high` `xhigh` | | `claude-opus-5` | standard | `none` `low` `medium` `high` `xhigh` `max` | | `claude-opus-5.5` | standard | `low` `medium` `high` `xhigh` `max` | | `claude-sonnet-4.6` | standard | `low` `medium` `high` `max` | | `claude-sonnet-5.5` | standard | `low` `medium` `high` `xhigh` `max` | | `claude-sonnet-5` | standard | `none` `low` `medium` `high` `xhigh` `max` | | `claude-fable-5.1` | standard | `low` `medium` `high` `xhigh` `max` | | `claude-haiku-4.5` | standard | `low` `medium` `high` | | `gemini-2.5-pro` | standard | `low` `medium` `high` | | `gemini-2.5-flash` | standard | `none` `minimal` `low` `medium` `high` | | `deepseek-v4-pro` | standard | `low` `high` `xhigh` `max` | | `deepseek-v4-flash` | standard | `low` `high` `xhigh` `max` | | `deepseek-v4.1-flash` | both | `low` `high` `xhigh` `max` | | `grok-4.6` | standard | `minimal` `low` `medium` `high` `xhigh` | | `kimi-k3` | both | `none` `low` `high` `max` | | `glm-5.3-flash` | both | `low` `high` `max` | | `glm-5.3` | both | `low` `high` `max` | | `gpt-oss-120b` | both | `low` `medium` `high` | | `gemma-4-31b` | both | `none` `minimal` `low` `medium` `high` `xhigh` `max` | | `llama-3.3-70b` | both | none — omit the field | | `mistral-large` | standard | none — omit the field | | `qwen-3.6-plus` | standard | `none` `minimal` `low` `medium` `high` `xhigh` | | `nemotron-3-ultra` | standard | `none` `minimal` `low` `medium` `high` `xhigh` `max` | | `gemma-4-uncensored` | standard | none — omit the field | ## When it answers {#reply-mode} ```js await space.setReplyMode('ondemand') await space.replyMode() // → 'ondemand' await space.send('Thoughts?', { mode: 'always' }) // this turn only ``` | Mode | The assistant answers | |---|---| | `'auto'` (default) | when the room is one person and the assistant, or a message is addressed to it; in a group, a small model decides whether a message wants help | | `'always'` | every message | | `'ondemand'` | only when it is named or @-mentioned | It is the same setting as the Spaces app's reply chip, and a turn re-reads it once it is 20 s old. How the choice is made: [how a turn runs](./how-a-turn-runs.md#when-the-assistant-answers). ## A turn you can hold {#turn} `send()` behaves like a room: you post, and whatever happens next arrives on `on('message')` — the assistant may answer or stay quiet, and other people may speak first. When your code needs the answer to *this* message, use `turn()`: ```js const t = space.turn('Plan the trip', { timeoutMs: 120_000 }) t.on('partial', ({ text }) => draft.textContent = text) const { status, reply, error } = await t.finished if (status === 'done') show(reply) else if (status === 'silent') hint('The assistant stayed out of it.') else if (status === 'failed') warn(error) ``` - **`finished`** resolves `{ status, reply, text, proposed, usage?, error? }` and never rejects. `status` is `'done'`, `'silent'` (the assistant chose not to answer), `'failed'` (with the server's `error`, such as `not_admitted`), `'aborted'` or `'timeout'`. `reply` is the reply as a message — the same object `on('message')` receives. - **`partial`** streams this reply as it is written; `t.text` is the text so far, and `t.result` is `send()`'s answer once the server gave it. - **`t.abort()`** stops the reply and keeps what was written as the reply. Only the device that started a turn can stop it. `timeoutMs` (default 180 s) stops it the same way. - **One reply at a time.** A message sent while another reply is running is held and sent again, as `send()` does; a held message is not sent after `abort()` or the timeout. The turn's reply is the first assistant message after the attempt that ran. ### What a turn used {#turn-usage} `send()`'s answer and `finished` carry `usage` — the turn's model calls added up, from the provider's own figures — so you can measure a turn without waiting for the day's billing: ```js const { usage } = await space.turn('Summarise the week').finished // { tokensIn: 18240, tokensOut: 612, tokensCached: 15872, costMicros: 2140, costUsd: 0.00214, // calls: 2, models: ['deepseek/deepseek-v4.1-flash'], source: 'provider' } ``` - `costMicros` is in millionths of a dollar. `source: 'provider-partial'` or `'none'` means some calls reported no cost, and the total counts only those that did. No `usage` means the turn made no model call. - A reply longer than about 30 s reaches `send()` as `pending: true`, with no usage; `space.turn()` then reads it from the stored reply. Every assistant message in `messages` carries the server's record as `usage` (`in`, `out`, `cached`, `costMicros`, `models`). ## The assistant as a function {#run} `space.run(input, opts)` runs the room's assistant once and gives you its answer. **Nothing is written to the room** — no message, no reply, no card, no notification. Use it for work that is not a conversation: pull fields out of a text, classify a request, draft something your app shows its own way. ```js const r = await space.run('Invoice 2291 from Acme, total 1,240.50 EUR, due 30 Nov', { instructions: 'Extract the invoice fields.', schema: { vendor: 'string', total: 'number', due: 'string' }, }) if (r.ok) console.log(r.output) // { vendor: 'Acme', total: 1240.5, due: '30 Nov' } else console.log(r.error, r.detail) ``` - **The room is the container.** It admits the call (its gate and its key) and pays for it, as for a turn; its [store](./sdk-store.md) is readable to the assistant through `data_query`. The assistant sees `input`, not the room's conversation. - **Your prompt only.** A run gets the room's instructions plus `instructions`, with no Spaces framing — as in [builder mode](#builder-mode), whatever the room is set to. - **Only tools that need no approval.** A card is a write, and a run has no member to approve it, so tools that would propose — data writes, effectful app tools, a lane's message into the room — are not offered. - **`schema`** asks for structured output: `{ field: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' }` (every field required, no others) or a JSON Schema object. The assistant hands its result to a `final_answer` tool, the result is checked, and `output` always fits. - **Text and images** (`images`, up to 4). Files are read through the room, with `read_file`. - **Who pays:** your user's own key (a [user token](./sdk-builder.md#user-billing)), your app's key, or a [funded room](./sdk-builder.md#user-billing) — never Witbitz. - **Limits, per room:** 4 runs at once and 60 a minute. | `error` | Meaning | |---|---| | `too_slow` | the run did not finish within the endpoint's ~30 s, so its answer could not be returned (nothing was kept). Shorten the task or lower the effort. | | `no_structured_output` | `schema` was given and the assistant never produced an answer that fits it (its `text` comes with the error) | | `bad_schema` | the schema is neither form above | | `run_needs_payer` | no user token, app key or room grant pays for it | | `rate_limited` | more than 4 at once or 60 a minute in this room; wait `retryAfter` seconds | | `run_unavailable` | the server cannot run it here (a confidential room on a server whose enclave does not run them) | | `run_unsupported` | the server is older than runs | | `unavailable` (or another transport code, with `detail`) | the request got no answer — the network, or an enclave that could not be reached; a run keeps nothing, so it is safe to try again | ## Tools {#tools} Name the tools the assistant may use when you make the room. The ones in `immediateTools` it calls directly; the rest become [proposals](./sdk-approvals.md) a member approves before they run. ```js const space = await wb.createSpace({ agent: { name: 'Travel', instructions: 'Plan trips; put options on the shared map and list.' }, tools: ['search_places', 'show_places', 'add_place', 'search_flights', 'show_flights', 'set_itinerary'], immediateTools: ['search_places', 'show_places', 'set_itinerary'], }) ``` The whole catalogue — places, flights, the itinerary, charts, photos, `read_file`, `read_url`, `write_pdf` — and what each produces: [How a turn runs](./how-a-turn-runs.md#tools-and-what-they-produce). Your page's own functions become tools as a private lane's [actions](./sdk-approvals.md#actions), or for one question in any room (below). ### Your page's tools, for one question {#page-tools} `send(text, { tools })` offers the assistant your page's own functions for that question only, in the shape a lane's [actions](./sdk-approvals.md#actions) take. An `await` tool runs on this page while the turn waits and its result goes back to the assistant; a `propose` tool becomes a proposal a member approves. ```js await space.send(question, { tools: [ { name: 'search_guide', description: 'Search the help guide', input: { topic: 'string' }, mode: 'await', run: async ({ topic }) => searchGuide(topic) }, { name: 'where_am_i', description: 'The screen the person is on', mode: 'await', run: async () => currentScreen() }, ] }) ``` - Nothing is stored in the room: offer the tools again with each question that may need them. - At most 20 tools a question; the call is refused before anything is sent over that. ### Choosing the platform tools {#platform-tools} Every assistant also gets Witbitz's **platform tools**: `web_search`, `fetch_url`, `read_file`, `write_pdf`, `look_at_image`, `show_photo`, `show_chart`, `show_diagram`, `search_places`, `show_places` and `add_place`; `connection_call` in a room with an API-key connection; and `cross_to_shared` in a private lane. Their descriptions go with **every model call** — about 4k tokens, plus about 1k of guidance for places and photos, paid again on each round of a turn's tool loop — so if your assistant uses only some, name them: ```js await wb.createSpace({ agent, platformTools: ['web_search', 'read_file'] }) // only these two await wb.createSpace({ agent, platformTools: false }) // none await space.configure({ platformTools: true }) // all again (the default) ``` - Your own `tools`, a lane's actions and the store's tools are not platform tools; this setting leaves them alone. - The guidance for places, photos and shared files goes to the model only when those tools are offered. - `cross_to_shared` is how a private lane posts into its shared room, after the member approves. A lane whose list leaves it out cannot post there, and its assistant says so when asked to. ### Builder mode: only your prompt {#builder-mode} By default the assistant is framed as a member of a Spaces group chat: a line saying so, rules for formatting and place links, and, in a private lane, how the member's shared room works and how `cross_to_shared` reaches it. That suits a chat app; in an app of your own it can get in the way. `framing: 'builder'` leaves it all out: the model gets your instructions, the conversation, the message and the tools you chose, and nothing else. ```js const lane = await space.privateLane({ agent: { name: 'Keep', instructions: 'You are Keepwarden\'s assistant. Change things only through our actions.' }, framing: 'builder', data: { tasks: { title: 'string', done: 'boolean' } }, dataWrites: false, // it may read the store (data_query) but proposes no data_put / data_delete platformTools: ['web_search'], }) await space.configure({ framing: null, dataWrites: null }) // back to the defaults ``` `framing` works on `createSpace`, `privateLane` and `configure`. Tool descriptions are unchanged — they describe each tool, not your app — and a shared file is still announced to the model when `read_file` is offered. **See the prompt.** `send(text, { showPrompt: true })` (and `turn()` with the same option) answers with `prompt`: the exact system text, user text, model and tools that turn sent. Nothing extra is stored. A turn longer than about 30 s answers `pending` without it; send a quick message to read it. ```js const { prompt } = await lane.send('explain how to use the app', { showPrompt: true }) console.log(prompt.system, '\n---\n', prompt.user, prompt.tools.map((t) => t.name)) ``` ## Voice input {#dictation} `wb.dictation()` is the press-to-talk the Spaces app uses: words appear as the person speaks, and the final text arrives about half a second after they let go. ```js const mic = wb.dictation() button.onpointerdown = () => mic.start({ onPartial: (text) => (input.value = text) }) button.onpointerup = async () => { input.value = await mic.stop() } ``` `mic.cancel()` throws a press away, `mic.close()` hangs up at once (otherwise after 15 quiet minutes), and `mic.listening` is true while it records. - **The audio never reaches Witbitz.** The page streams it straight to Tinfoil's confidential realtime enclave (Voxtral Mini 4B Realtime); Witbitz only gives the page a short-lived key, capped at a few cents. - **Who may use it:** a signed-in person (`wb.signIn`) or one your server vouched for (`wb.useUserToken`, see [billing, per user](./sdk-builder.md#user-billing)). With neither, `start()` throws `sign_in_required` before it asks for the microphone. - **Languages:** 13, among them English, French, Spanish and German. **Not Hebrew** — Hebrew speech comes back in Latin letters. - **Cost:** one connection stays open across presses, about $0.002 each. Today Witbitz pays; it is not yet billed to your tenant. - **Your CSP** needs `wss://inference.tinfoil.sh` in `connect-src`; the browser needs a secure context and the person's permission for the microphone. - **Errors** (`code`): `sign_in_required` (not signed in, or the sign-in or token was refused), `stt_rate_limited` (a few keys per person per hour), `stt_unavailable`, `voice_unattested` (the speech service did not prove what it runs — nothing was sent to it), `mic_denied`, `already_listening`. ## Reading aloud {#speak} `wb.speak(text)` reads text aloud with the Spaces app's **Play aloud** player, and resolves when it has finished: ```js button.onclick = () => wb.speak(lastReply.text) stopButton.onclick = () => wb.speech.stop() ``` `wb.speech.playing` is true while it plays, and `wb.speech.play(text)` is the same call. - **The text never reaches Witbitz.** The page sends it straight to Tinfoil's attested speech model and plays what comes back; Witbitz only gives the page a short-lived, capped key. - **Who may use it:** as for [voice input](#dictation) — a signed-in person or one your server vouched for; with neither, `speak()` rejects with `sign_in_required` before anything is sent. - **Long text** is read in pieces cut at sentence ends: a short first piece, so sound starts in about two seconds, and each next piece fetched while the one before plays. A new `speak()` stops the one playing. - **Languages:** **not Hebrew** — neither speech model has it. - **Cost:** about $0.002 a request whatever its length; a paragraph is two to four. Today Witbitz pays. - **Your CSP** needs `https://inference.tinfoil.sh` in `connect-src` and `blob:` in `media-src`. Most browsers play sound only after the person has tapped something on the page. - **Errors** (`code`): `no_hebrew` (refused before anything is spent), `empty`, `sign_in_required`, `tts_rate_limited`, `tts_unavailable`, `voice_unattested`, `http_`, `audio` / `play` (the browser would not play it).