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 · every call: the reference.
Its name, instructions and model
You name the assistant when you make the room (createSpace({ agent: { name, instructions, model? } })), and change
it later, in place, with configure:
await space.configure({ agent: { instructions: 'Never invent a value. Never create a project unasked.' } })- What changes in place: the assistant's
name,instructionsandmodel; the store's collections (data;data: nullremoves them); which platform tools it gets (platformTools); the builderframing; and whether it may write rows (dataWrites).configureanswers{ 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 throughchangeMembers(). For a different setting, make a new room and copy what you need into it. - Refusals, made by
configurebefore anything is sent:create_time(a field fixed at creation),use_fork(admission— usechangeMembers),lane(on a lane),unknown_field(anything else). A room whose owner signed its policy refuses every change in place withgoverned_room; another client that tries getsconfig_immutablefrom the server.
The model and its reasoning effort
model is a model id — 'kimi-k3' — or an object that also sets how hard the model reasons before it answers:
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 islow. A room that names no model gets the tier's default:gpt-6-solon the standard tier,deepseek-v4.1-flashon the confidential one. createSpace,privateLaneandconfigurerefuse a level the model does not take, before anything is made, withinvalid_effortand a message naming the levels it does take. A stored room whose model no longer takes its level (after switching models) runs atlowrather than failing.- Higher levels cost more tokens and time. On most models
lowis right for a conversational assistant; reach forhighor 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
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.
A turn you can hold
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():
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)finishedresolves{ status, reply, text, proposed, usage?, error? }and never rejects.statusis'done','silent'(the assistant chose not to answer),'failed'(with the server'serror, such asnot_admitted),'aborted'or'timeout'.replyis the reply as a message — the same objecton('message')receives.partialstreams this reply as it is written;t.textis the text so far, andt.resultissend()'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 afterabort()or the timeout. The turn's reply is the first assistant message after the attempt that ran.
What a turn used
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:
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' }costMicrosis in millionths of a dollar.source: 'provider-partial'or'none'means some calls reported no cost, and the total counts only those that did. Nousagemeans the turn made no model call.- A reply longer than about 30 s reaches
send()aspending: true, with no usage;space.turn()then reads it from the stored reply. Every assistant message inmessagescarries the server's record asusage(in,out,cached,costMicros,models).
The assistant as a function
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.
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 is readable to the assistant through
data_query. The assistant seesinput, 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, 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.
schemaasks 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 afinal_answertool, the result is checked, andoutputalways fits.- Text and images (
images, up to 4). Files are read through the room, withread_file. - Who pays: your user's own key (a user token), your app's key, or a funded room — 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
Name the tools the assistant may use when you make the room. The ones in immediateTools it calls directly; the rest
become proposals a member approves before they run.
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. Your page's own functions become
tools as a private lane's actions, or for one question in any room (below).
Your page's tools, for one question
send(text, { tools }) offers the assistant your page's own functions for that question only, in the shape a lane's
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.
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
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:
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_sharedis 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
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.
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 defaultsframing 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.
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
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.
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). With neither,start()throwssign_in_requiredbefore 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.shinconnect-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
wb.speak(text) reads text aloud with the Spaces app's Play aloud player, and resolves when it has finished:
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 — a signed-in person or one your server vouched for; with
neither,
speak()rejects withsign_in_requiredbefore 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.shinconnect-srcandblob:inmedia-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_<status>,audio/play(the browser would not play it).
Machine-readable source: sdk-assistant.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)