# 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](#connection-calls)). This page is that round trip. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Proposals, end to end {#proposal-round-trip} 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:** ```js 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 card ``` `showCard` 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:** ```js 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()` and `deny()` throw and the card stays pending. ## Actions your page runs {#actions} 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](./sdk-agents.md#lanes). The limits on actions: [Reference](./sdk-reference.md#limits). ## Deciding by id {#decide} ```js 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 {#who-decides} In a room with [admins](./sdk-people.md#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 {#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](./sdk-accounts.md#account) and [MCP servers](./sdk-accounts.md#mcp-servers).