Approvals — nothing effectful runs until a person says yes
The assistant can propose; only a member makes it happen. A tool not in immediateTools, a write to the store, a
private lane's message into the shared room, a call on a connected service: each is parked as a proposal — a card
a member approves or denies — and nothing happens until they do. The one exception is an MCP tool a room was granted to
run on its own (connection calls). This page is that round trip.
Part of the SDK · every call: the reference.
Proposals, end to end
A proposal is { id, kind, action, args, text, approve(opts?), deny(opts?) }. kind is 'action' — a tool call —
or 'message', a private lane's message into its shared room; action is the tool's name, and args the whole parked
call, type included. data_put and data_delete are always proposals.
In a shared room, the server runs the action on approval:
const space = await wb.createSpace({ agent: { name: 'Ops', instructions: 'Track tickets.' }, data: { tickets: { title: 'string' } } })
space.on('proposal', (proposal) => showCard({
title: proposal.action, args: proposal.args,
onApprove: () => proposal.approve({ from: 'Ann' }), // your card's buttons
onDeny: () => proposal.deny({ from: 'Ann' }),
}))
space.on('resolved', (e) => hideCard(e.id))
await space.send('Open a ticket: printer on 3 is down') // the assistant calls data_put: a cardshowCard and hideCard are your page's own interface.
The approved write then arrives as a message (kind 'data') and a 'data' event. 'resolved' carries
{ id, approved, action, result }, where result is what the server did; result.ok === false means it did not go
through (a message the room refused, for one).
In a private lane, your page runs the action on approval:
const lane = await space.privateLane({
agent: { name: 'My assistant' },
actions: [{ name: 'book_room', description: 'Book a meeting room', input: { room: 'string', at: 'string' },
run: async (act) => api.book(act.room, act.at) }], // act = { type: 'book_room', room, at }
})
lane.on('proposal', (proposal) => showCard({
title: proposal.action, args: proposal.args,
onApprove: () => proposal.approve({ from: 'Ann' }), // the server records the approval, then the SDK calls run(act)
onDeny: () => proposal.deny({ from: 'Ann' }),
}))
lane.on('resolved', (e) => console.log(e.result))
await lane.send('Book Kyoto for 3pm')run()'s return value comes back as result, on 'resolved' and in approve()'s answer ({ error } if it threw);
it is not sent to the assistant. A lane's message into the shared room (kind: 'message') is posted when approved.
- Listening starts the check.
on('proposal')starts a check for waiting cards every 1.5 s, in any room; a lane looks at once, as soon as your listener is attached, so a card already waiting when the app reopens arrives straight away. A card proposed during a turn arrives with that turn's reply. - A refusal keeps the card. If the room refuses the decision,
approve()anddeny()throw and the card stays pending.
Actions your page runs
A private lane's actions are your page's own functions, offered to its assistant as tools. The assistant can only
propose them; when the member approves, the runtime records the approval and the SDK runs your run() handler on the
member's device — the runtime never runs it, so the credential and the side effect stay with you. That is the part
that is hard to build safely by hand: the propose → approve → run state machine, the record of who approved what, and
the guarantee that the assistant cannot act alone.
An action can also be a read. Give it mode: 'await' and the assistant may call it during the turn for something
only the device knows; the SDK runs your handler and hands its return value back to the turn (within 25 s). It needs no
approval, because it changes nothing.
How to offer actions for one turn only, or change the standing set: private lanes. The limits on actions: Reference.
Deciding by id
const waiting = await space.pending() // checks now → the waiting cards
await space.approve(waiting[0].id, { from: 'Ann' }) // the same as the card's own approve()
await space.deny(id, { from: 'Ann' })approve(id, { recordResult: false }) is for a connection call whose answer you hand the assistant yourself: the room
records the call — who approved it, what it was, how it went — but not the answer.
Who may decide
In a room with admins, a member who is not one cannot approve or deny a card that would do
something they may not do themselves: an admin-only row, or a connection call that spends the room's connected account.
The room refuses with not_admin, and the card stays pending for an admin. A member's own lane is theirs to decide.
Connection calls
A call through an API-key or OAuth connection is always a proposal a member approves. An MCP tool is too, unless it is in the
connection's auto set — chosen by whoever grants the connection to the room (the member in the Spaces app, or your page
with grant(conn, { auto })); by default it is the tools the server itself marks read-only, a claim the server makes and
Witbitz does not verify. An auto call still goes through the Sentinel: the model never sees the credential, and the
connection's host and scope still apply.
The Sentinel adds the secret and makes the call; see connections and MCP servers.
Machine-readable source: sdk-approvals.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)