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 · every call: the reference.
Private 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, 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). It never
holds a credential.
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).
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:
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 listActions for one turn
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 ontoolsoffers extra actions — the same shape asactions— for this turn only, for actions that depend on what the member is talking about.replace: truemakes them the whole set for the turn; an empty list means none.setActionsreplaces the standing actions for every later turn; handlers for names you keep are kept.- What a turn actually proposed.
send()'s answer carriesproposed: [{ 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 whenproposednames 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
const lane = await shared.privateLane({ agent, mirror: true }) // or wb.openLane(link, { space, mirror: true })
await lane.mirrorNow() // copy what is new right awayWith 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 ansh: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 onsourcealone — a recreated room's own history is copied in withsource: '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 asmirrored, so a lane reopened withresumecatches 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.setLinks([teamA, { space: teamB, label: 'Team B' }]) // every turn
await lane.send('What did both teams decide?', { links: [teamA, teamB] }) // this turn onlyThe 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
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, 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 | 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) takes a lane of either tier.
Assistants that ask each other
A member with two lanes — say one for home and one for work — can let the two assistants ask each other:
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_lanesends 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.accounton, 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;0for none) limits how many times a chain of assistants asking each other may cross. Past it,tell_laneanswers "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
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,
the open agent-to-agent protocol, or as a custom MCP server. The door is https://agents.witbitz.chat/a2a/<handle>.
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 callsapprove()(with edited text, if they like),askBack(question)ordecline().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: trueasks back,reject: truedeclines). - 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()(asa2a): resume the lane with it and each waiting draft comes back as an'a2a'event — held until you listen for one, so anon('a2a')afteropenLaneresolves 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 itsreply. A follow-up is a new job. - One token per agent.
removePeer(id)revokes it and drops its waiting messages;lane.a2a.peerslists 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 savedstateinto a running client. - Over MCP.
lane.a2a.mcpUrlis the same inbox for agents that take a custom MCP server, with toolsmessage_memberandget_replyand the same token. Both doors, and agents that take jobs from a member's assistant: Outside agents. - 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 staysSUBMITTEDuntil 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 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.
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.
Machine-readable source: sdk-agents.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)