People — sign-in, who may enter, who runs the room
A room is open to anyone with its link, or gated: only signed-in people on its list may read or write it. This page is signing people in, gating a room, its admins and owners, direct chats, and changing who is in it.
Part of the SDK · every call: the reference.
Sign in
Your app needs no accounts of its own. The SDK signs people in the way the Spaces app's Email me a code does: a six-digit code to any address, from any origin.
const wb = new Witbitz()
const { ticket } = await wb.signIn({ email }) // a code is emailed
const session = await wb.signIn({ email, code }) // → { email, idToken, exp, sign }
localStorage.setItem('wb:session', JSON.stringify(session))
const renewed = await wb.refreshSession() // before session.exp, with no new code- Keep the session. Pass it back as
new Witbitz({ session }), orwb.useSession(session)on a running client (nulldrops it).wb.signOut()forgets it. - Renew it before it expires (
session.exp) withrefreshSession(), the renewal the Spaces app runs. It throwsrefresh_failedwhen the person has to sign in again, andno_sessionwhen there is nothing to renew. - A sign-in on your site is your app's, not the person's whole Witbitz account. The token names the site that asked for it. Connections, the Siri inbox, outside agents and speech keys made on your page live under an account of your app's own; the person's own Witbitz account — their connections elsewhere, their @username, the account itself — cannot be changed from your page. It still proves the email, so room gates work as for any sign-in. This is the builder's half of the double blind: your page cannot spend or erase what the person did in other apps.
- Your server can check a sign-in on its own: knowing who is signed in.
Gated rooms
A room's gate is chosen when it is made, with wb.createSpace or space.privateLane; a room with none is open to
anyone holding its link.
const space = await wb.createSpace({
agent: { name: 'Warden' },
admission: { emails: ['dana@example.com'], domains: ['keepwarden.com'] },
})- Every operation is gated, reads included. The SDK sends the signed-in identity with each request, from the
session
wb.signIn()returned — so sign in before opening a gated room, or its reads come back refused. - The list is sealed with the rest of the room's settings: the platform can tell that a room is gated, never who is on its list.
- A list takes emails and domains (
acme.com). The Spaces app's own form also admits by @username — see usernames, discovery, and who is in the room. - A list that admits nobody is refused at the call: it would lock you out of your own room.
Reading the gate
A member holds the room key, so your app can read the gate the server enforces instead of keeping a list of its own:
await space.admission()
// → { gated: true, emails: ['lead@keepwarden.com'], domains: ['keepwarden.com'], hidden: 3,
// admins: ['lead@keepwarden.com'], owners: [] }
await space.admits('dana@example.com') // → true / false, against the sealed list- Addresses are hidden by default. Each is stored as a hash salted with the room id, so a member can check an
address but not read the list;
hiddencounts them. To show some to members, list them inadvertisewhen you make the room (admission: { emails, advertise: ['lead@keepwarden.com'] }), or passtruefor all. - "Hidden" hides the list, not the people. A member cannot enumerate who was invited, but a signed-in member's
messages carry their @username (unless the room set
usernames: false), their rows carry their email, and the room's member list shows who has signed in. - The link alone reads nothing. A gated room's sealed settings — the list, the admins, the connection grants — are served only to a signed-in member on the list; anyone else learns only that the room is gated.
- A member admitted only by @username cannot be checked on the device: that match needs the server's key.
- Errors:
config_unavailablewhen the settings cannot be read (the SDK refuses to guess), andsign_in_requiredbefore the member signs in.
Who did what
In a gated room every write needs an allowed sign-in, and the server marks what a signed-in member wrote:
| On | The server sets |
|---|---|
| a message | verified; user, their @username (absent when the room set usernames: false); spk, the key of the device that sent it |
| a row | _verified; _email, their verified email; _user, their @username (absent when the room set usernames: false) |
A message does not carry the email, so its author's address is not readable by every member. from and a row's
_by stay the display name the writer chose; lane.approve(id, { from }) sets the one a lane's message shows.
- A lane's message into the room is verified only when the member who approved it signed in — to the room it lands in or, when that room is open, to the gated lane it came from. A message into an open room in another region carries no mark: that region never sees the lane's sign-in.
- A row's own fields cannot pose as these marks.
idand every name starting with_are the room's:data.putrefuses them (reserved_field), and a row is always read without them. - Copied history keeps its old marks apart. Rows and messages an older client's move carried keep the
marks they had as
carried({ from, verified, user? }), never as this room's ownverified, which was not earned here, and name who carried them (carriedByon a message,_carriedByon a row). A lane's mirror copies messages only (rows as lines, withmirror: { data: true }), with no marks.
Admins
A gated room can name admins, and the room itself — not your app's interface — enforces what only they may do:
const team = await wb.createSpace({
admission: { emails: ['lead@keepwarden.com', 'dana@example.com'], admins: ['lead@keepwarden.com'] },
data: {
tasks: { fields: { title: 'text', done: 'boolean' } }, // every member
budget: { fields: { item: 'text', amount: 'number' }, write: 'admins' }, // admins only
},
})
await team.isAdmin('dana@example.com') // → falseIn a room with admins, the room refuses a member who is not one (not_admin) when they:
- change the assistant (
configure); - write a collection declared
write: 'admins'; - approve or deny a card that would do something they may not do themselves — an admin-only row, or a connection call, which spends the room's connected account. The card stays pending for an admin.
Posting, reading, the other collections and a member's own lane stay open to every member.
- Admins must be admitted by the gate: an address in
emails, or one in a listed domain. - They are sealed in the room's settings, so members can see who they are (
isAdmin,admission().admins) and the platform cannot. - They are set when the room is made and changed in place with
changeMembers(). - A collection declared
write: 'admins'in a room with no admins or owners is refused when declared (admins_required): nobody could be held to it.
Owners
Admins are equal, and any admin can change the room's members — so on its own, any admin could change the list to one without the other admins, or without the person who made the room. Owners are a level above, enforced by the room too:
const team = await wb.createSpace({
admission: { emails: ['founder@keepwarden.com', 'lead@keepwarden.com', 'dana@example.com'],
owners: ['founder@keepwarden.com'], admins: ['lead@keepwarden.com'] },
})- An owner is an admin: whatever an admin may do, an owner may.
- Only an owner can change the owners, or lower or remove an admin. Admins still add and remove members and raise members to admin.
- The server refuses a member change by someone who is not an owner that breaks this rule (
not_owner); the SDK checks before it changes anything. space.isOwner(email),admission().owners. Owners must be admitted by the gate, like admins.
Direct chats
A direct chat is the Spaces app's one-to-one chat: a gated room for exactly you and one other person. Each of you sees it under the other's name, nobody renames it, and the assistant is in it but answers only when @-mentioned.
const chat = await wb.createSpace({ agent: { name: 'Helper' }, direct: 'sam@example.com' }) // needs wb.signIn
// give Sam chat.shareLink; Sam opens it signed in as sam@example.com
await chat.directPeer() // → { email: 'sam@example.com' } (on Sam's side: you)directtakes the other person's email in place ofadmission: the room admits exactly the two of you (both addresses visible to members), you are its owner, anddirect: trueis sealed in its settings.directPeer()reads the other person from the room's sealed list — never from an invite or a link — so it is the name to show. It isnullfor any other room.- You can make several direct chats with the same person. A room for two that you give a name is an ordinary gated
room, made with
admission. - Refusals: no sign-in (
sign_in_required);directtogether withadmission, or with your own address (invalid_argument);rename()(direct_chat).
Changing members in place
A gated room changes who is in it without becoming a new room: the same id, link, history, store and lanes, and, from that moment, a new key that only the people still on the list receive.
const r = await team.changeMembers({ emails: ['lead@keepwarden.com', 'dana@example.com'], admins: ['lead@keepwarden.com'] })
// r → { inPlace: true, epoch, link, removed: ['eve@example.com'], added: [], unreachable: [] }
await team.rotateKey() // the same members, a new key — for a lost phone, or a link sent to the wrong place
team.on('rekeyed', ({ epoch, link }) => saveLink(link))
team.on('removed', () => showGone())
team.on('pending', ({ reason }) => askForTheLink())changeMembers takes the whole new list. Every member's open copy of the room takes the new key by itself, on its next
request: 'rekeyed' gives the new link (null in a vault), 'removed' means this account is no longer on the list,
and 'pending' means it is on the list but has no key this device can open yet.
- How the key reaches each member. When a person signs in, their account publishes an invite key for your app
(
wb.invite) in Witbitz's directory, under your app's own scope — the origin your page signs in from; no other app can write there. A change seals the room's new key to each remaining member's invite key. Their devices fetch it when the old key is refused (stale_key), check it against the room's commitment, and carry on. - Keep
wb.invite.statelike the session. It holds a private key: persist it and pass it back asnew Witbitz({ invite }). With the account backup on, it is kept there too, so a restored device follows. Signing in publishes the key by itself;await wb.invite.enroll()publishes it now and answers the status. - A second device of the same account that has neither the state nor the backup never takes the published key over
— that would cut the first device off.
wb.invite.statusreadsheld_elsewhere, and its rooms saypendingwithreason: 'no_invite_key', until youwb.invite.restore(state). In vault mode the vault keeps and publishes the key:statusreadsvault, andrestoreis refused (vault_unsupported). unreachablelists members with no invite key for your app — they never signed in to it, or only to another app, which has its own scope. Their devices are toldpending; send themlink.- Who may change it, or rotate its key: a signed-in owner or admin (
not_adminotherwise), within the owners' rules. A rotation goes to everyone still on the list. A room you make names you its owner when you are on its list. A room with no owners or admins is changed by its creator, in the Spaces app. - A removed member keeps what they had read, and reads nothing written after: the room refuses their old key.
- Only the roles changed (the same people, new admins or owners): the room keeps its key and nobody's key changes;
the answer says
roles: true. - Members added by @username in the Spaces app are on the list as a tag that no email names. A change keeps them; removing a person drops their @username entry too, once they have signed in to the room.
- A room without a gate whose members the Spaces app changed seals its new key to the app's own member keys, which
the SDK does not hold: its rooms say
pendingwithreason: 'link_changed'. Ask for the link again. plan()answersin_placefor a new list, withcall: 'space.changeMembers(admission)'. A lane is one member's own room:changeMemberson a lane throwslane.- In a vault,
changeMembersandrotateKeygo through the vault, which asks the person in its own screen and makes and keeps the new key; the page sees neither key and gets nolink.
@usernames
await wb.usernames.claim('dana', { publish: true, name: 'Dana' }) // → { username, published }
await wb.usernames.mine() // → { username, published }; throws when signed out
await wb.usernames.resolve('dana') // → { username, exists }; no sign-in needed
await wb.usernames.release()One @username means one person across all of Witbitz, not per app. Verified messages and rows carry it (user,
_user), and the Spaces app can admit a person to a room by it; the SDK's admission takes emails and domains.
claimrefuses a name that is taken (taken) or was released in the last 30 days (held).publish: truealso lists the person in the public directory, under the displaynamewhen you pass one. It is off by default.- Releasing a name does not change what the person already wrote.
Rooms an older client moved
A room changes its members only in place (above). space.fork() makes nothing and throws
use_change_members, and plan() refuses a change that names a history with the same code. For a fresh start, make
a new room with wb.createSpace({ admission }); nothing is carried into it.
An older client (a 2.2.x page) could still move a room to a new one, and the SDK follows such a move:
team.on('moved', ({ room, link }) => switchTo(wb.openSpace(link)))
team.on('moveRefused', ({ reason }) => showWhy(reason))'moved'fires only for a notice the server stamped and sealed to this device's key: in a room with admins, an admin's; in a room with owners, one whose new room keeps exactly the owners and admins it declares. Anything else is'moveRefused', with the reason.- A confidential move is taken into the enclave before its settings are read.
- History such a move carried keeps its marks as
carriedon a message and_carriedon a row:{ from, verified, user? / email? }, the server's marks from the room it was first written in, pluscarriedBy. - In a vault, the vault reads the notice from the room itself and follows it by the same rules; the page gets
'moved'with{ room, link: null }— open it withwb.vault.openRoom(room). Following a confidential room's move is refused there (vault_unsupported).
Machine-readable source: sdk-people.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)