# Private assistants and outside agents Each member can have a private assistant beside the shared room; a member's assistants can ask each other; outside agents can reach a member's assistant over A2A or MCP; and a Space can sit behind any AG-UI front end. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Private lanes {#lanes} A **private lane** is a member's own room beside a shared one, with its own assistant. That assistant reads the shared room's messages — and its [store](./sdk-store.md), read-only, through `shared_data_query` — and works with the member in private. It can post into the shared room only as a message the member approves, and it can only **propose** the lane's `actions`, which run on the member's device when approved ([actions](./sdk-approvals.md#actions)). It never holds a credential. ```js const shared = await wb.createSpace({ agent: { name: 'Team' } }) const lane = await shared.privateLane({ agent: { name: 'Ops assistant', instructions: 'Help me run the store. Propose refunds and emails; I approve.' }, actions: [ { name: 'issue_refund', description: 'Refund a customer', input: { type: 'object', properties: { customer: { type: 'string' }, amount: { type: 'number' } }, required: ['customer', 'amount'] }, run: (a) => myBackend.refund(a.customer, a.amount) }, // runs only after the member approves ], }) lane.on('message', renderPrivateThread) lane.on('proposal', (p) => showApproveDeny(p)) await lane.send('Refund Jane $40 for the late order, and email her an apology') ``` A `Lane` is a `Space` — its private thread arrives on `message` — with what this section adds. Its cards arrive on `proposal`: `kind: 'message'` is a drafted message to the shared room, `kind: 'action'` one of your actions, and nothing happens until `approve()` or `deny()` ([approvals](./sdk-approvals.md)). **A lane is the member's to keep.** Store its link, and on the next page load reopen it rather than making a new one: ```js localStorage.setItem('lane:' + shared.room, lane.shareLink) // a later page load: const lane = await wb.openLane(localStorage.getItem('lane:' + shared.room), { space: shared, actions, resume: saved }) await lane.ready // the private thread is loaded: show it, not an empty list ``` ### Actions for one turn {#lane-turn-tools} ```js await lane.send('Plan the offsite', { tools: [proposePlan] }) // offered beside the standing actions await lane.send('Just the plan, please', { tools: [proposePlan], replace: true }) // only these, this turn lane.setActions([issueRefund, sendEmail]) // the standing set, from now on ``` - **`tools`** offers extra actions — the same shape as `actions` — for this turn only, for actions that depend on what the member is talking about. **`replace: true`** makes them the whole set for the turn; an empty list means none. - **`setActions`** replaces the standing actions for every later turn; handlers for names you keep are kept. - **What a turn actually proposed.** `send()`'s answer carries `proposed: [{ id, action }]`, the cards this turn created (`[]` when none). The platform knows this for certain and the reply's words do not, so a page waiting for a card should wait only when `proposed` names one. - A call that leaves out a required argument is never proposed: the model is told what is missing and calls again, so no empty card reaches a member. ### The mirror {#mirror} ```js const lane = await shared.privateLane({ agent, mirror: true }) // or wb.openLane(link, { space, mirror: true }) await lane.mirrorNow() // copy what is new right away ``` With `mirror: true`, this device copies the shared room's messages into the lane as they arrive, so the lane's own history holds the whole shared conversation and its assistant reads it like any other. Without it, a lane's turn sees only the newest 20 shared messages. - **Telling copies apart.** A copy arrives on the lane with `source: 'shared'` and an `sh:` id. `isLaneCopy(m)`, exported by the SDK, is the Spaces app's own test: filter on it to show a lane's private messages only. Do not filter on `source` alone — a recreated room's own history is copied in with `source: 'shared'` too. - Attachments are copied as bytes and stored again under the lane's key. - It runs while both handles are open. `snapshot()` keeps its position as `mirrored`, so a lane reopened with `resume` catches up without copying anything twice. - Messages only, unless you pass `mirror: { data: true }`, which also copies the shared room's row writes, as lines. Off by default. ### One lane, several rooms {#lane-rooms} ```js lane.setLinks([teamA, { space: teamB, label: 'Team B' }]) // every turn await lane.send('What did both teams decide?', { links: [teamA, teamB] }) // this turn only ``` The lane's assistant also reads the latest messages of each linked room, read-only and labelled; it can still post only into the lane's own shared room. Up to 16 rooms. Each linked room keeps **its own gate**: a gated room is read only if the signed-in member is on its list. ### Across tiers {#lane-tiers} A lane of the other tier — a confidential lane beside a standard room, or the reverse, as the Spaces app makes them — opens with `openLane`, and `privateLane({ tier: 'enclave' })` makes one beside a standard Space. Your device copies the room's messages into it (the [mirror](#mirror), whether or not you asked), and its turns carry no link to the shared room on the server: a confidential room's key never goes to the standard tier, and neither tier reads the other's store. So that assistant does not read or post into the room on the server. A lane's linked rooms must be on its own tier (`tier_mismatch` otherwise), because a linked room's key goes with each turn. Which lane you get, by the Space it belongs to: | The Space | `privateLane()` | `privateLane({ tier: 'enclave' })` | `privateLane({ tier: 'standard' })` | `openLane(link, { space })` | |---|---|---|---|---| | Standard | a standard lane, bridged to the room | a confidential lane: mirrored by this device, no bridge (since 3.6.1) | a standard lane | either tier; the other tier is mirrored, no bridge | | Confidential | a confidential lane (it inherits), bridged | the same | refused: `tier_mismatch` | either tier; the other tier is mirrored, no bridge | | Requires an attested app (owner policy) | refused: `attested` | refused: `attested` | refused: `attested` | — | | In a [vault](./sdk-keys.md#vault-mode) | the vault makes it, on the Space's tier | only on a confidential Space, else `tier_mismatch` | only on a standard Space, else `tier_mismatch` | the lane's room id from `wb.vault.rooms()`; the other tier is mirrored | `setLinks` takes rooms of the lane's own tier only; [`lane.link(otherLane)`](#lane-links) takes a lane of either tier. ## Assistants that ask each other {#lane-links} A member with two lanes — say one for home and one for work — can let the two assistants ask each other: ```js const link = homeLane.link(workLane, { to: 'Work', as: 'Home' }) // what each assistant calls the other // "Ask my work assistant when the Q3 deadline is": Home's assistant calls tell_lane, Work's assistant answers, // and the answer comes back into Home, whose assistant tells the member. localStorage.setItem('link-state', JSON.stringify(link.state)) // pass back as link(other, { resume }) link.unlink() ``` - **Asynchronous.** `tell_lane` sends the question and returns at once. The answer arrives in the asking lane later, as a new message, and wakes its assistant, which passes it on. Neither assistant waits inside a turn. - **On this device, while both lanes are open.** The device holds both keys and carries the messages; the server never joins the two rooms. With [`wb.account`](./sdk-accounts.md#account-index) on, the link is listed on the member's other devices (`wb.account.links`), and a device that opens both lanes links them again. - **Bounded.** `cap` (default 3; `0` for none) limits how many times a chain of assistants asking each other may cross. Past it, `tell_lane` answers "too many lanes" and the other lane is not woken. - **Any tier.** A confidential lane and a standard one may be linked: your device carries the words between them, and neither room's key goes to the other's tier. - **Refusals:** `not_a_lane`, `self_link`, `already_linked`. ## Outside agents, over A2A and MCP {#a2a} Other people's agents — a booking agent, a colleague's assistant, Claude Code — can reach a member's **private lane** without a Witbitz account and without seeing anything else of the member, over [A2A](https://a2a-protocol.org/latest/specification/), the open agent-to-agent protocol, or as a custom MCP server. The door is `https://agents.witbitz.chat/a2a/`. ```js const wb = new Witbitz({ session, a2a: saved }) // wb.a2a.state holds the lanes' inbox private keys wb.a2a.onState = (s) => save(s) const { cardUrl } = await lane.a2a.enable({ name: 'Dana’s assistant' }) const { token, mcpUrl } = await lane.a2a.addPeer('Booking agent') // shown once: give it the token and cardUrl or mcpUrl await lane.a2a.addPeer('Helper', { reply: 'auto' }) // this one may be answered without asking await lane.a2a.addPeer('Grok', { job: true }) // takes jobs: its reports are told to the member, never answered lane.on('a2a', ({ peer, text, draft, approve, askBack, decline }) => showForApproval(peer, text, draft)) await lane.a2a.collect() // bring waiting messages into the lane ``` - **Witbitz cannot read it.** TLS ends inside the attested standing Sentinel, where a peer's message is sealed to the lane's inbox key; only the member's devices open it. Witbitz stores a hash of each token, never the token. - **The member decides.** Each message lands in the lane as the outside agent's words, and the lane's assistant drafts an answer. By default (`reply: 'ask'`) nothing goes back until the member calls `approve()` (with edited text, if they like), `askBack(question)` or `decline()`. `setReply(peerId, 'auto')` lets the assistant answer that agent itself. `lane.a2a.reply(taskId, text, { ask?, reject? })` answers a task directly, without the `'a2a'` event's card (`ask: true` asks back, `reject: true` declines). - **A reload keeps what waits.** A collected message is gone from the Sentinel, so the tasks waiting for a draft or for the member ride the lane's `snapshot()` (as `a2a`): resume the lane with it and each waiting draft comes back as an `'a2a'` event — held until you listen for one, so an `on('a2a')` after `openLane` resolves still gets it — and one still coming is caught when it arrives. - **An agent that takes jobs** (`job: true` — Grok Bot, Claude Code) runs a job and reports; it reads no answer, so its messages are told to the member and nothing is drafted or sent back, whatever its `reply`. A follow-up is a new job. - **One token per agent.** `removePeer(id)` revokes it and drops its waiting messages; `lane.a2a.peers` lists them. `disable()` closes the lane's door. - **The account's side.** `wb.a2a.status()` reads what the Sentinel holds for the account — its lanes, its peers by name, open tasks; `wb.a2a.revoke()` turns it off everywhere, so every card and token stops working; `wb.a2a.restore(state)` loads a saved `state` into a running client. - **Over MCP.** `lane.a2a.mcpUrl` is the same inbox for agents that take a custom MCP server, with tools `message_member` and `get_reply` and the same token. Both doors, and agents that take jobs from a member's assistant: [Outside agents](./outside-agents.md). - **For the agent:** a standard A2A v1.0 JSON-RPC endpoint (`SendMessage`, `GetTask`, `CancelTask`; the v0.3 names work too) with bearer auth. Answers can take a while: a task stays `SUBMITTED` until the member's device collects it, and an unanswered one is cancelled after 7 days. Text only; a file part is refused. ## AG-UI: a Space behind any AG-UI front end {#ag-ui} [AG-UI](https://docs.ag-ui.com) is a standard stream of events between an agent and a user interface, which CopilotKit's components and other AG-UI clients read. `aguiAgent` makes a Space an AG-UI 1.0 agent **in your page**: the room is decrypted on the device as always, and the adapter turns what it does into AG-UI events, so nothing of the room leaves the device to reach the interface. ```js import { Witbitz, aguiAgent } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js' import { AbstractAgent } from '@ag-ui/client' import { Observable } from 'rxjs' const space = await wb.openSpace(link) const agent = aguiAgent({ AbstractAgent, Observable }, space) // its messages start as the room's history agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: 'Can we do Friday at 8?' }) await agent.runAgent({}, { onTextMessageContentEvent: ({ event }) => render(event.delta) }) ``` The SDK has no AG-UI or rxjs dependency: you pass your own `AbstractAgent` and `Observable`. Without them, `aguiRun(space, input)` is the same run as a plain async iterator of events, and `aguiHistory(space)` is the room as a `MESSAGES_SNAPSHOT`. | AG-UI | The Space | |---|---| | the last `user` message of the input | `space.send(text)` — sent once, though AG-UI clients resend the whole history | | `input.context` | that turn's `instructions`, never stored in the room | | `TEXT_MESSAGE_START` / `CONTENT` / `END` | the reply as it is written (`partial`), closed by the reply itself | | `TOOL_CALL_*` and `RUN_FINISHED` with an `interrupt` outcome | a **proposal** waiting for a person (a data write, a lane's message, a connection call) | | `input.resume` `resolved` / `cancelled` | `space.approve(id)` / `space.deny(id)`, then `TOOL_CALL_RESULT` | | `RUN_ERROR` `busy` / `timeout` / `no_input` | the room was answering an earlier message / no reply in 4 minutes / nothing new to send | Options: `aguiAgent(deps, space, { from, mode, timeoutMs, threadId, agentConfig })`; `mode` defaults to `'always'`, since an AG-UI run expects an answer. Not mapped yet: AG-UI front-end tools (`input.tools`) and `STATE_*` events — a Space's own tools and approvals cover the room, and its data has its own `data` events.