Witbitz docs HomeTrustAll docs

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.

javascript
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:

javascript
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

javascript
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

The mirror

javascript
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.

One lane, several rooms

javascript
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

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.

A member with two lanes — say one for home and one for work — can let the two assistants ask each other:

javascript
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()

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>.

javascript
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

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.

javascript
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)