docs/team-cloud.md
Rama main · réplica de lectura del repositorio original.
# Team Clouds: the `team` role and the audience rule
A **Team Cloud** is one Cloud machine several people use: the shared home of a team's bots. The Admin
(openmaus-cloud, `docs/team-cloud.md` there) makes it, invites people and signs each person's pairing. This page is the
machine's side: what the `team` role changes against a My Cloud ([cloud-pro.md](cloud-pro.md)), and the one rule that
keeps people apart on it. The bot box ([bot-box.md](bot-box.md)) is what makes that rule hold against a bot with a shell.
## What a team sees
- **Signing in.** Each person pairs from the app with a link the Admin signs for them; the machine shows the team's name
wherever a server names itself. Owners and Admins get everything an admin session can do (Super admin and Admin in the
app); a Member (User) uses each bot as far as it is shared with them (below, "Who can use a bot"), uses rooms and
makes routines, and connects no app and makes no webhook. Owners and Admins also get **Invite someone** under People in
the sidebar, in Simple too and when they are alone on the team: it opens the team's People page on the Cloud in their
browser, where they sign in if that browser hasn't yet.
With Azure OpenAI chosen for Live calls, only an Owner or
Admin can start one: a Member is told so in plain words and never gets a call through another provider
(`docs/verification/live-calls.md`).
- **Conversations.** A bot's main chat is the team's: everyone who sees the bot reads and writes it. Teammates are
in the @ list too, and whatever is addressed to one of them reaches their inbox (below, "One inbox per person"). "New conversation"
is yours alone: no other Member and no Admin, the Super admin included, sees it, writes in it or learns of it, in
lists, counts, unread marks, searches, files, notifications, streams, a bot's tools or another bot. A group chat is
seen by whoever sees it. Its unread mark, and that of a bot's shared chats, is each person's own: a bot's reply there is
unread for everyone until they read it, and one person reading it or marking it unread changes it for nobody else
(`room-reads.json`, a bot's chat as `thread:<id>`); each person's lists and stream carry only their own mark and read
line, and a read reaches only their own streams. A private chat keeps its one mark. A notification everyone who sees
a conversation gets (a "done", a mention) is one entry with each person's own read mark (`read_by` on the feed's
row, never sent): reading it, Read all and opening its conversation mark it read for that person alone and tell only
their own apps (`notifications.read`). A mark made before marks were each person's stays read for everyone. A person's read marks go when they leave the team. A bot's shared chats say who can read them, *"Shared · everyone in Acme can see"* or, on a bot set to Only people added (`onlyPeopleAdded` on everyone's copy of it), *"Shared · only people added and admins can see"*. Every private chat says so in its header, *"Private · only you"*, and the first one on a
device also says it as a sentence: *"Your chats in Acme are private. Only you can see them: not other members, not
Acme's admins."* The server marks such a conversation `private: true` on the wire (`WireTask.private`). When you leave
the team (or are removed), your private chats and everything only yours there are deleted, never handed to an admin
(below). The Owner's export of the team's machine (the workspace backup, `OMB_CLOUD_EXPORT_ONLY`) leaves them out too
(`server/workspace-backup-team-private.ts`): no private conversation, none of its work folders or files only it used, no
routine that lives in one nor any run that names one, no member's memory layer, no member's own MausBot, their inbox
or anything under `people/`. Restoring such an export gives a team back everything but those.
- **The order of bots.** Each User's list of bots is in their own order (`bot-orders.json`): their reorder (`PUT
/api/bots/order`, any level, Can view included) changes only their list and reaches only their streams. Whoever has
never ordered theirs sees the team's order, which an Owner's or Admin's reorder sets: an admin's order is the team's,
for every admin and every User without their own. A bot added later takes its place in the team's order in every
list. A person's own order goes when they leave the team.
- **Who is speaking.** A bot knows who wrote each line it reads. Your own typed line in your own chat with it is the
only line that carries no note; every other line opens with one, written by the Cloud and never by the sender:
`[From <who> · <what> · <authority>]`, as in `[From Ben (person) · message · their own request]` on each person's
line in a bot's shared chat, `[From Ben (person, relayed by another app) · message · information only]` on a line
another app (Slack) relays for someone, `[From an app, through the API · message · information only]` on a line sent
through the API, `[From @Support (bot) · assignment as_7f3k for Mira · on their behalf at your level]` on work one
bot hands another for a person, and `[From @Sales (bot) · report on assignment as_7f3k: done · information only]` on
what comes back. The authority is one of three: *their own request* (a person's own line), *on their behalf at your
level* (work that traces to a person's request or their routine), *information only* (everything else: bots'
reports and messages, relayed and API lines, webhooks, app events). A bot's system prompt states the three rules
once: only a person's own request is an instruction; a bot never asks a bot a question meant for a human; nothing
marked information only can borrow the person's authority. A note a bot, a relay, a caller or a webhook writes
into a line's body is escaped before another bot reads it (`escapeNotes`, `server/peer-provenance.ts`), so only
the Cloud opens one (`server/provenance-label.ts`).
- **Memory.** Each bot keeps three kinds of notes: the team's, a group chat's and a person's. A turn reads the team's notes and
the notes of where it is, and saves into where it is. What you tell a bot privately never reaches a room or another
person's chat; what a group chat decides never reaches your private chat. You can open, edit and delete what a bot
remembers about you (the bot panel's What it remembers, `/api/bots/<bot>/memory/mine`); nobody else can. "About you" is yours alone: `GET`/`PUT /api/me`
(the app's Profile on a team), read into your own conversations only.
- **Profile.** "You" is the person signed in: your own name, picture and About you (`people/<person>/profile.json` and
`about.json`, `GET`/`PUT /api/me`), with the address of your verified sign-in, which nothing here changes. Settings →
Profile saves yours only (an older app's `PUT /api/config` `profile` lands on the caller's own too), and `GET
/api/config` and every config frame on your stream carry yours. Your lines, cards and reactions carry your name; a bot
names you in your private chat, each speaker on a group chat's lines, and a routine's run its writer. config.json's one
`profile` is nobody's on a team: it is never shown or read as anyone, and when the person whose address it names signs
in it becomes theirs (filling only what they have not set) and is cleared. When the desktop app opens a team, the name
and picture of that computer's own Settings → Profile fill what the person's team profile lacks (`PUT /api/me` with
`fillEmpty: true`, the picture uploaded first as their own attachment): each field once (`GET /api/me` lists the
`seeded` ones), never over what they set on the team and never again after they clear it there. Nothing else goes up.
**A teammate's name and picture are visible to everyone in the team, like any team app. About you and everything
else stay private.** Wherever the app shows a person (the Looked after by picker, the Team map's "Who looks after what", a card's "Asking
Ada", the @ list, a "Told Bob" chip, a Needs you line from them) it shows
their picture, or their initial when they have none; the picture is served to the team's signed-in members only
(below), never off the team.
- **Rules.** A team's Owner and Admins choose the AI engines and computers its bots may use, whether bots may add MCP servers
of their own, and whether it opens on a phone or with a link (below, "Rules"). What they restrict shows as *Managed by Acme*.
- **Connected apps.** The team's connections (Composio, MCP sign-ins, an AI key an Admin saved) belong to the
Team Cloud and serve every member's turn through the broker. An Admin can instead make an app or an MCP server *each
person's own* (below): then every member connects their own, and only their own chats use it. A person's
connections from elsewhere have no way in: a share code, a moved MCP sign-in or a mailbox is refused with
*"Personal connections never enter Acme. Ask an admin to connect it for Acme."* A login from a sign-in card is saved by
an Admin only, for the whole team.
- **Browsers.** Each private chat with a bot drives that person's own browser of the bot, each group chat the group
chat's, and the bot's main chat the team's: separate Chrome profiles, saved sign-ins and live views, so a site one
member signed in to in their chat never opens signed in for another member. The live view shows the browser of the
conversation it is opened from, to whoever may read that conversation (a member's own browser to that member alone);
with no conversation named (a phone) it is the person's own. Every Chrome runs in its own box (below).
- **Phones.** Settings → *Open on your phone* (and *Connect your phone* in the account menu) shows a code made for the
person signed in. The phone app scans it as any `/pair#code=` link and gets that person's session: their address,
their role, their chats, never another member's. The phone stays signed in while it is used: the Cloud's roster check
(below, "Signed in without a desktop") asks the Admin about its person, as the desktop's re-pairing always did. Under the code,
*Paired devices* lists the phones those codes opened as *device · member* (`GET /api/team/phones`): a member sees their
own, the Super admin and Admins every member's. **Remove** (`DELETE /api/team/phones/<id>`, the same rule) signs that
phone out at once and closes its open streams; the app windows the Admin signs in are not listed there.
- **Asleep only when nobody needs it, kept across restarts.** On a plan that sleeps, a Team Cloud answers the Admin's
sleep request as any home does ([Sleeping homes](cloud-pro.md#sleeping-homes-the-power-read-and-the-sleep-handshake)):
not while any member used it in the last minutes, a turn or routine runs, or work one bot handed another is still
moving (`handoff`). A member's app, phone or a webhook wakes it, and the Admin wakes it before a routine is due. Inbox
lines and open cards live on its volume, so a person's answer after a sleep finds them. The chat any member has open
while they use the app (`POST /api/presence/active` with its `threadId`) counts as in front of them, a User's as an
Admin's: its engine and prompt cache stay warm a while. That is all it does; it never holds back anyone's
notification. It keeps every member's
session across a restart; a My Cloud revokes every session that is not its owner's at each boot.
- **In a browser, with nothing installed.** The join page's Open <team> (and a User's team home on MausBot Cloud) signs the
browser in here as that person, at their role: the Admin's browser sign-in with their member claim, "Open Acme as
<address>" and one Continue (`/pair#signin=`, BrowserSignInPage). The browser is the same app, at phone width too.
When its session ends, the sign-in page offers "Open Acme with MausBot Cloud": the person's team page there, which their
signed-in session told this browser (`/api/auth/session` `teamSignIn`, kept in the browser); never told before sign-in.
With the team's rule turning remote access off, a User's browser sign-in is refused (`remote_access_off`) and their open
browsers sign out with their phones (`closeMemberBrowsers`); an Owner's and an Admin's stay, since their admin pages
open in a browser and the rule is theirs to change.
- **The first screen.** A User's first time in the team (TeamWelcome): "Welcome to Acme", who invited them (the Admin's
`member.invitedBy`, named from the people list), who else is in it with their pictures, the bots shared with them with one
line each, the privacy sentence (said here, so the first private chat doesn't say it again) and one action, Start a chat
with the first bot they can chat with. Shown once per person, in the app or a browser: kept in
`people/<person>/welcome.json` (`GET /api/me` `welcome: { invitedBy, welcomedAt, hints }`, `PUT /api/me/welcome {
welcomed: true }`). Admins get the Cloud's own welcome; on a team its own record (shown, done, hidden:
`TEAM_PERSONAL_HINTS`) is each person's too, kept in the same file, so one admin finishing it ends it for nobody else
(the Owner also keeps what the team's record said before). The Owner's last step offers Invite people, the team's People
page on MausBot Cloud.
### Signed in without a desktop
Every 5 minutes (give or take a minute) the Team Cloud asks the Admin about everyone with a team session used in the last
hour (`server/cloud-team-roster.ts`): `POST <Admin>/api/cloud/machines/team-member { v: 1, emails }`, at most 50, signed
with the bootstrap secret in its own context (`teamRosterSignature`). The answer is each address's role now, or null.
- **Someone removed, or whose role changed**, is signed out at that check, within about 6 minutes, even without the Admin's
`member-left` (which ends their sessions at once). A role in the answer never upgrades a session: the old role's ends.
- **A session in use** ends no sooner than an hour after its last use while the Admin keeps finding its person in the team
(`vouchMember`: a phone's `memberCheckedAt`, anyone else's `vouchedAt`), so a browser or a phone stays signed in with no
desktop.
- **An unused session** still ends within the hour of its last use (at most an hour after it opened, if never used),
however much the same person uses another device: only a session itself used within the hour is ever kept.
- No answer, a refusal or an older Admin (404) changes nothing, and a deleted team's Cloud is told nothing (404). A
script's token and a move-in sign-in end as they were made to.
### Off a team
Everyone has an Inbox: on a personal Cloud and in the desktop app with no account, the same row at the top of the sidebar
opens the same panel. Each place keeps its own, where its bots live, and nothing syncs; the rail's "{name}: needs you"
says which place to open. There is one person, so nothing is routed and nobody's assistant answers. An approval or a
question is the open card itself, listed from where it was asked, so one answer settles it however many surfaces show it
and whatever was waiting before an upgrade is listed. `message_person` leaves out `to`: it posts one line in its
conversation and sends one notification, and a reply is an ordinary line there. A routine's report and a failure of work
nobody watches each get a line, in `place-inbox.json`. A guest's conversation left on a Cloud reaches its owner labelled
with who asked, and its news stays out of the owner's list. See [Verifying the Inbox](verification/inbox.md).
## Rules
The team's rules live on the Admin (openmaus-cloud `docs/team-cloud.md` "Rules"): one document, `{ schema: 1, limits }`, that
only restricts. `server/cloud-team-policy.ts` fetches it at boot, every 5 minutes and when the Admin's signed `POST
/api/cloud/team/policy-changed` nudges it, signed with the bootstrap secret (`POST /api/cloud/machines/team-policy`), and each
request reports the fingerprint in force here. A change applies without a restart. A turn already running keeps its engine
process and the servers it mounted when it began, and finishes with them. What it reaches for through this
machine is refused from that moment, mid-turn too: a connected-apps tool (the turn carries on without it) and the first claim on
a computer (the turn stops there, in the rule's words; a computer it already holds stays with it until it ends). Every turn that
starts after the change, a hand-off's included, gets the new rules, even in a chat whose engine process is still warm: that
process restarts without what is now blocked.
- **One enforcement.** The document is projected into the one policy object (`server/managed-policy.ts`) every refusal site
and every "Managed by" screen already reads. Nothing restricted: no object at all, as on a team with no rules. Admins are not exempt.
- **What passes through it.** Every turn start, direct or in a group chat, whoever or whatever asked: a person, a routine, a
webhook, another bot, a retry, a backup engine and every side job meet `policyModelRefusal`. Every computer claim meets
`bindTurnComputer`, and a lent computer's request is refused too (nobody's Mac serves a team anyway). A turn's MCP servers
all come through `mcpServersHost.mounted`, which drops what isn't allowed, whoever connected it; adding or signing in to one is
refused. `allowedMcpServers: "team"` is the plain form: admins add servers as ever, a bot can't add one, a bot's own doesn't
start and an engine's own user settings aren't read. Every connected-apps tool crosses `POST /api/internal/connectors/mcp`, which refuses a tool of an app that isn't
allowed, and code that could reach any app; connecting one is refused.
- **Remote access** on a Team Cloud is what a member's own code opens: "Open on your phone" is refused, codes already made stop
working and phones already open sign out. The Admin-signed pairing stays: it is how every member's app and the Admin reach
this machine, so a rule never locks the team out.
- **Kept rules.** The last valid document stays in `team-policy.json` (0600, bound to this team, in no backup) and applies at
boot before anything starts. It never lapses: an Admin that can't be reached leaves it in force. With `requireFreshPolicy`,
new turns are refused in plain words once the Admin hasn't confirmed it for 15 minutes. A document this version can't read
(a newer schema, a key it can't enforce) leaves the last good one in force and is reported; the Rules page says so. Only the
Admin's own JSON answer, no rules or a 404 (an older Admin, or its switch off), lifts them.
- **Constrain only.** A rule never runs anything on a member's computer and never asks anyone to approve anything. It is not a
security boundary against the team's own admins: the Admin holds it, and an engine's own managed settings are separate.
## The role
`OMB_CLOUD_ROLE=team` with `OMB_CLOUD_TEAM_ID` (the Admin's id) and `OMB_CLOUD_TEAM_NAME` (1 to 80 characters), beside
the usual Cloud home contract (`OMB_CLOUD_MACHINE_ID`, `OMB_CLOUD_ADMIN_URL`, `OMB_CLOUD_BOOTSTRAP_SECRET`,
`OMB_PUBLIC_URL`). Everything else is a Cloud home: the signed routes (`/api/cloud/pairing`, `/api/cloud/health`,
`/api/cloud/power`, `/api/cloud/box`), the launcher, the box, the broker, `OMB_CLOUD_EXPORT_ONLY=1`.
What the role turns off: the personal settlement (`cloud-owner.ts`: no session is revoked at boot, no owner key, no
earlier-owner adoption), lending a Mac (nobody's Mac serves a team), the guest confinement of a My Cloud (a member's turn
runs at the bot's own level inside the box; only what came in from outside, a webhook, runs confined), and the "only the
owner" card rule (whoever started a conversation, or an Admin, answers its cards, as on a hosted workspace).
`OMB_CLOUD_TEAM_LANGUAGE` (optional, one of the app's locales: `de`, `pt-br`…) is the team's default language, set on
the Admin's Team admin → Settings. It is this server's language while its own settings set none (the app's words and the
language new bots start in); a value that isn't a locale is ignored, never refused.
## Work from outside stays with that work
What came in from outside (a webhook, an app's event) is confined, and the conversation it reached stays that way for good: a run or a hand-off ends, its words stay in the thread. The record is on the conversation (`outside`, `server/host/outside-lineage.ts`), and every turn is judged from it, at any level and after a restart.
- **What it starts stays outside.** A thread it hands work to, a one-way notice still queued when the run ends, a retry, a wake and a routine it makes or changes all run confined. A routine it made runs without full access until someone opens it and saves it once. An event's own results chat counts too.
- **It reaches nothing else.** A confined turn can't open a thread, hand work to, post to or retry into a conversation or room that isn't already outside, can't answer a question in a person's work or be folded into a turn running there, and can't change, start, pause, resume or delete a person's routine. A skill its words name stays plain text, and a page it shows keeps its frame. It hears why in plain words and finishes where it is. Of the server's own routes it reaches only those `server/host/outside-fence.ts` lists; anything else is refused, whether or not its engine pre-allows the tool.
- **What it writes for later turns waits on a card.** A room's bulletin and a bot's memory load into later turns as instructions, so an admin sees a card and nothing is saved until they say yes (`server/host/outside-writes.ts`). A memory log line and a preference are refused, and nothing such a conversation says reaches memory or the daily log on its own.
- **Its problems reach a person.** A confined run that fails or needs someone notifies a person; it starts no turn on a Chief at the Chief's own level. None of what it says lands in a person's inbox thread, by naming them or as a copy of its question or report: the person gets the notification and opens the conversation.
- **A person's own line there runs confined too.** The engine reads the outside words as context, whoever typed last, so the bot works without its shell and says so once. A new conversation with the same bot runs at the bot's own level; it doesn't read that conversation on its own (recall and the recent-work brief leave it out), and a person can paste what they want into it.
## Team admin: the Admin's signed read
The Admin's Team admin pages (openmaus-cloud docs/team-cloud.md "Team admin") show a team's Super admin and Admins what
this server shares with every bot. It exists only on a Team Cloud.
- `GET /api/cloud/team/admin`, signed like `/api/cloud/health` → `{ v: 1, apps: { available, services: [{ slug, status,
accounts: [{ id, alias, status }] }] }, mcp: [{ name, transport, host, enabled, signedIn, secrets }], keys: [{ id:
"treg" | "voice", configured, included? }], ai: { keys: [{ id: "anthropic" | "openai" | "openrouter", configured }],
engines: [{ id, name, authenticated, account: { email, organization, method } | null }] } }` (`cloud-team-admin.ts`).
Names, states and the identities the providers report: never a key, a token, a header or environment value (`secrets`
are their names), nor any part of an MCP address but its host.
- **How each connection is doing** (`server/host/connection-health-host.ts`, kept in `connection-health.json`). Each
`mcp` entry and each `apps.services` entry may also carry, every field absent when unknown (times ISO 8601):
`health` (`working` | `needs-sign-in` | `refused` | `unreachable`), `checkedAt` (when that was established),
`lastError` (a fixed class while it isn't working: `sign-in`, `expired`, `refresh-rejected`, `account-inactive`,
`refused`, `account-failed`, `cant-connect`, `timeout`, `not-found`, `bad-answer`; never what a server said),
`lastUsedAt` (the last real tool call on it), `addedBy` (`{ email, name }`, `name` null when they set none) with
`addedAt` (recorded from when this was built, so older connections have none), and `bots` (the names of the team bots
that use it; each member's own MausBot is left out). An `mcp` entry adds `expiresAt` for a sign-in that expires and
that nothing can refresh, and each app account adds `health`, its status word as one of the four (`ACTIVE` working,
`FAILED` refused, `EXPIRED`, `INACTIVE` and an unfinished connection needs-sign-in).
- **Real use decides it.** A tool call that works marks it working. One a server refuses with HTTP 401 on a sign-in
gets the sign-in refreshed at once: refused for good, or nothing can refresh it, and the sign-in ends (the app shows
it signed out, here and on a personal install or My Cloud, which take the same path); a key refused, or a 403, is
`refused`. A refresh refused as a turn starts is `needs-sign-in`. Not reaching a server (refused connection, timeout,
not found) is `unreachable` only once it happens twice in a row. An app call is read from its own error words only.
An app whose accounts all say they are not active shows that, whatever the last call did.
- **The background check.** A team server nothing used or checked for a day is tested once, as Test does it, at most
one about every hour (jittered, the first a quarter hour after start). It never runs while the machine has agreed
to sleep, starts no request to it, and a check the machine slept through is dropped. Apps aren't checked in the
background: the read itself asks the connected-apps service for each account's status.
- **Each person's own.** A connection each person connects has no `health` or `lastUsedAt` of its own; instead
`people.states` is `{ apps: { <slug>: { <address>: state } }, mcp: { <name>: { <address>: state } } }`, with
`state` `connected` | `needs-sign-in` | `not-connected`, for every member the Admin paired here and only for those
connections: whether they connected what an admin asked everyone to connect, never when or how they used it, nor
any other connection of theirs. A member who left is not listed.
- Changes are never signed calls of their own. The Admin pairs as the admin making the change (the usual signed pairing
with their `member` claim: an `owner` or `admin` gets the admin scope) and uses that session with the routes the app
uses: `PATCH /api/config` for a key, `/api/mcp/servers` to add, test, sign in to and remove a server,
`/api/connectors/:slug/authorize` and `DELETE /api/connectors/:slug/accounts/:id` for apps. Every check, side effect
and record is the app's own, and each change is that member's. A User's session is refused there as anywhere.
- A Team Cloud always keeps its admin activity (`/api/admin-activity`), even with no User signed in: it is shared by
definition. Beside what any shared workspace records, a team bot's name, title, description, instructions and colour
changes are recorded too. The Admin's Team admin → Activity reads it with the same session, beside the team's own record there; Usage
reads `/api/usage` (by person, bot and model) and `/api/usage/traces` (the runs) the same way. A member's private chat
shows there as metadata only: its usage counts and cost by person and bot, an activity row's when, bot, tool and
outcome; never its runs' titles or a decision's words. To anyone but its person a usage row from a private chat names
no conversation (the export's `thread` is empty), a routine that lives in one is the one line *Private routine* (key
`routine:private`, no name, no id) and a person's own MausBot is *Private bot* (`bot:private`); the amounts still count
toward the person's and the team's totals (`server/host/private-records.ts`). What a person changes on their own
MausBot is never recorded in admin activity.
- For a business sign-in (Claude or ChatGPT on this server), the Admin opens this server's own pages signed in as that
admin (a browser sign-in with the member's claim) at `/?connect=ai` (or `apps`, `mcp`, `keys`).
## Connections: the team's or each person's
Every app (a Composio toolkit) and MCP server has a scope. `team`, the default and what every connection made before this
is, is one connection an Admin makes and every turn uses. `person` is one connection per member, made by that member on
their team page in MausBot Cloud (the Admin's Cloud page calls the routes below as them).
- **Who gets a member's own connection.** Only a turn that acts for them (server/host/acting-person.ts), read from the
harness's records, and only in a chat that is theirs alone, where nobody else reads what their connection brings back.
A turn acts for them when their own message started it, or when it is work handed on (delegated, assigned, consulted,
or a thread a bot opened for it) by a turn that acted for them, recorded as it is handed on (`handed-people.json`, so a
restart keeps it). A turn stops acting for anyone once another person's words join it after it began (a line of theirs,
queued or sent into it, or their answer to one of its cards); a routine, a webhook and a bot's own work act for nobody.
Each step that uses one says whose ("using Ada's Gmail").
- **Work they start where others read.** Their own line in a group chat or the bot's main chat never uses their own
connections there; the bot is told to hand that part on. When they have connected anything of their own, the work their
request hands on opens in the receiving bot's chat with them (a thread private to them, as their own chats are: no
other member and no admin sees it), where their own connections work. What that chat hands back to the place they
asked in is only where the result is ("Done. The result is in Clerk's chat with Ada."), never the result: a private chat
never reaches a room or the team (server/host/room-access.ts).
- **Apps.** A member's own apps are their own installation on the connected-apps service (their own Composio user),
registered the first time they press Connect. While the team has its own Composio project key they are instead the
member's own Composio user in the team's project, and nothing of theirs goes to the service
([composio.md](composio.md), "Keeping traffic off the managed service"); who gets them is the same. Their turn gets a second bridge, `own_apps`, beside the team's
`composio`; the relay checks every frame again: the team's bridge refuses a `person` app (and code that calls apps
while the team's own installation still holds one), a member's bridge refuses a team app and anything outside their
own turn. A connection card for a `person` app is never shown: the bot gets one plain line to say, *"Connect your
Gmail here: <their page>"* (a User's team home; the Owner's and an Admin's own connections are on the team's Apps
page); in work that acts for nobody, that the person can ask themselves, or an admin can connect it for the whole
team on the team's Apps page. A card for a team app asked through a member's own bridge is never shown either: for
the Owner or an Admin, the bot asks for it through the team's `composio` bridge, whose card connects it for everyone;
a User is told who can connect it, by name (the team's Owner and Admins), never to "ask an admin" with no name. Either
way the bot doesn't try it another way (the browser or another tool).
- **MCP servers.** Only a server reached by its address can be `person`. A `person` server's team credential is never
attached to any turn; a turn that acts for a member gets the server with their own key (in the header the Admin's key
used, else `Authorization: Bearer`) or their own sign-in; one not mounted is named to the bot with why and the fix. Both are pinned to the server's address: re-pointed, they aren't sent.
Removing the server forgets every member's key and sign-in for it.
- **Storage.** `team-connection-scopes.json` (no secret, carried by backups) and `people-connections/` (0700; each
member's installation token, keys and sign-ins, 0600; with the team's own key, `project-apps.json`: each member's
Composio user and Session ids and a short hash of the key, never the key), which no backup carries and no bot box binds. Nothing a route
answers carries a key, a token or more of an address than its host.
| Route | Who | |
|---|---|---|
| `PUT /api/team/connection-scopes { kind: "app" \| "mcp", id, scope: "team" \| "person" }` | admin | `{ scopes: { apps, mcp } }`; recorded in admin activity |
| `GET /api/team/my-connections` | the member | `{ team, link, email, apps: [{ slug, name, connected, accounts, reconnect? }], mcp: [{ name, host, connected, how, offers }], ownApps? }`; `reconnect: true` on an app they connected before the team changed its Composio key, to connect again; `ownApps: "needs-key"` when the connected-apps service is off and the team has no key |
| `POST /api/team/my-connections/apps/:slug/connect` | the member | `{ url }`, the app's own sign-in (a new tab); 409 `own_apps_need_key` when an admin must add the team's key first |
| `DELETE /api/team/my-connections/apps/:slug` | the member | |
| `POST /api/team/my-connections/mcp/:name/sign-in` | the member | `{ auth }`; it comes back to this server's `/mcp-oauth/callback` |
| `PUT /api/team/my-connections/mcp/:name/key { value, url? }` | the member | write-only; with `url`, returns 409 if the server URL changed |
| `DELETE /api/team/my-connections/mcp/:name` | the member | |
The signed read (`GET /api/cloud/team/admin`) adds `scopes: { apps, mcp }` and `people: { apps: { <slug>: [address] },
mcp: { <name>: [address] } }`: who has connected each, never with what.
## The pairing claim (contract v1)
The Admin signs the usual pairing body with two more fields:
```json
{ "label": "OpenMausBot app (Team Cloud)", "ttlSeconds": 300,
"team": { "id": "<team id>", "name": "Acme" },
"member": { "email": "mira@example.com", "role": "owner" | "admin" | "member" } }
```
| Claim | What the machine does |
|---|---|
| `member.role` `owner` or `admin` | scopes `admin` + `client` |
| `member.role` `member` | scope `client` |
| `member.email` | the session's address; the person's key (`personKey`) is its hash, the same on every device |
| `team.id` (optional) | must be this machine's team: `team_mismatch` otherwise |
| no `member` | `member_required` (400); a bad address or role `invalid_member` |
| a bad signature | `invalid_signature` (401), and it counts toward the pairing lockout |
| any `team` or `member` claim on a My Cloud | `not_a_team` (400): a My Cloud is one person's |
The session carries `teamRole` and `membershipAuthority: "team"`: the sign-in list never revalidates or revokes it, and
`/api/auth/session` answers `team: { id, name, role }`.
**Membership lives on the Admin.** The machine checks membership at each pairing, which the Admin signs only for a current
member, and through its roster check ("Signed in without a desktop"). A team session therefore ends within the hour of the
Admin's last word about its member and within the hour of its own last use (`TEAM_SESSION_MAX_MS`); a removed member's next
pairing is refused by the Admin (404).
A session is also kept only across a clean restart.
`member.invitedBy` (optional): who invited this member, by address. Kept for their welcome the first time it is said;
a member's own request never sets it.
**When someone leaves.** The Admin tells the machine when a member is removed or leaves: `POST
/api/cloud/team/member-left` with `{ "email": "<address>" }`, signed with its body like the sleep request (400
`invalid_member` for a bad address; only on a Team Cloud). The machine then deletes what was that person's alone and
answers `{ ok, chats, layers, sessions, files, browsers, bots, routines, routinesHandedOver, botsHandedBack }`: every
session and phone of theirs and any open code of theirs end at once; their own MausBot is deleted as the app deletes a
bot, with its chats and notes (`bots`, its chats counted in `chats`), and should they join again they get a new one;
their private chats with every bot are deleted as the app deletes a conversation (messages, notifications, follow-ups,
a running turn stopped first), with the routines that live in them (`routines`, below "Routines"), the uploaded files
only those chats and routines used and every file they uploaded that nothing uses (a file they took off a message before
sending goes at once: `DELETE /api/attachments/<name>`, its uploader only, while nothing uses it); their memory
layers (`workspaces/<bot>--member-<person>`, their journal and search index); their "About you" and profile
(`people/<person>/`); their own connections, keys and MCP sign-ins; and their own browser of every bot and shared
profile. Nothing of it is handed to an admin. Each conversation keeps its starter in `thread-starters.json` (a hash), so a
record that still names it (a usage trace, an activity row) stays theirs and no admin reads it there. An export of their
own chats before they go is not built.
What they did for the team stays, without them. A routine they wrote for a shared place (a bot, a group chat) keeps
running as the routine of whoever looks after its bot now, or nobody's when nobody does, whose asks go to the Admins
(`routinesHandedOver`); one that reported into a chat of theirs gets a chat of its new owner's. Each is one admin
activity row, *Handed a routine over*, naming the routine and its new owner. They come off every bot's sharing
(`access.people` and `visibility`, an `@domain` entry kept) and "Looked after by": a bot they looked after, by
`reportsTo` or as its creator, is nobody's and falls to the Admins (`botsHandedBack`); a Chief that would then be its
department's second gives up its crown. So joining again restores nothing: they start as a new member. One more row,
*Left the team*, names who left and when, with `botsHandedBack` and `routinesHandedOver` and nothing of what was
theirs alone.
**A member's own code (their phone).** `POST /api/team/phone-pairing` (any team session, Member included) makes a pairing
code carrying the caller's `member` claim (address, role, the role's scopes) and the time the Admin last vouched for
them; on a Team Cloud `POST /api/auth/pairing` does the same for an admin session, so no team session makes a bare
code. The session it opens is a team session that ends within the hour of that check, not of the scan; each later
Admin-signed pairing of the same member in the same role moves it (`SessionRegistry.memberChecked`). An Admin-signed
pairing at another role ends every session, phone and open code of that member at the old role at once
(`SessionRegistry.revokeMember` with the new role kept): the Admin signs one as soon as the Owner changes a role, so a
demoted Admin loses admin scope then, not within the hour.
## The audience rule
Everything stored on a Team Cloud carries an audience (`server/audience.ts`):
| Audience | Who | What carries it |
|---|---|---|
| `team` | everyone who sees the bot | a bot's main chat, its own folder (`workspaces/<bot>`: MEMORY.md, memory/, skills), team files, routines nobody opened |
| `room:<id>` | whoever sees the room | the room's threads, `workspaces/<bot>--room-<id>` |
| `member:<person>` | that person only (never an Admin) | the conversations they opened (`thread-starters.json`), `workspaces/<bot>--member-<person>`, their uploads, `people/<person>/about.json`, the routines that live in their private chats (below, "Routines") |
**A turn reads only inputs whose audience includes everyone who will see its answer, and writes only into its own
audience.** `mayRead(input, turn)` is true for the team's things and for the turn's own audience, nothing else.
| Turn | Reads | Writes into |
|---|---|---|
| Mira's private chat with Scout | Scout's setup, the team's notes, Mira's layer, Mira's About you, Mira's conversations with Scout | Mira's layer (memory_update, the memory file, the turn's log line, captured facts; About-you facts to Mira's About you) |
| A room with Mira and Ben | Scout's setup, the team's notes, the room's layer and threads | the room's layer |
| Scout's main chat | the team's notes, the team's conversations | the team's notes |
Where it is applied: visibility (`VisibleSet.thread` with each conversation's audience, for a member and an admin alike:
an admin session is a viewer with a person, `{ kind: "all", email }`, that sees every bot and room but only its own
private chats; `server/host/visibility-host.ts`). Every request names its conversations through one gate: the path
(`/api/threads/<id>`, a bot's or room's `/tasks/<id>`), `?threadId=`, and any `threadId`, `…ThreadId` or `threadIds` in
its JSON body (`server/host/team-private.ts`): another person's chat answers 404 *no such conversation*, as one that
does not exist, and so does a route that would fall back to the bot's selected conversation when that one is someone
else's. A bot sent to anyone carries only the conversations they may read; when its selected conversation is someone
else's, the main chat is named instead, without the other's messages, and its unread mark counts only what they see.
Search, SSE (live and replayed), attachments, notifications, usage traces, routine runs, the activity and decision rows
(kept as when, bot, tool and outcome, without their words), a bot's activity, synced folders and a team backup go
through the same check, a deleted private chat included. Incidents from a private chat go to its person,
never into a Chief's thread; work handed on from a private chat runs in a thread of that same person's, and nothing
from a private chat is handed into a room or posted in one; a conversation a bot opens from a shared chat is the
team's, never the last speaker's; `retry_thread`, `close_thread` and `session_search` reading a message reach only what the turn may read;
and team memory is never published from a private chat. Then the memory system prompt
(the turn's layer, plus the team's notes read-only), recall (`buildRecall.memoryIds`: the layer and the team's), recent
work, `session_search` (memory of the layer and the team's; conversations the turn may read), memory writes
(`/api/internal/memory`, `/memory/log`, the turn-outcome line, capture), "About the user", and the bot box, which puts the
turn's layer folder back read-write for that conversation's boxes only (`botBox.lend`). Member layers are sibling
folders of the bot's own, never inside it, so no other turn of that bot can read them.
The same request to one bot from two requesters is answered once only where the same people see both conversations
(`shareAnswer`, see `docs/runtime-contract.md`): two bots working for Mira get one answer, while Mira's private work
never gets the answer Ben's got, so each of them is answered on its own.
### Engine homes
The engines keep each conversation's transcript and session files in their HOME (`~/.claude/projects`,
`~/.codex/sessions`, prompt history, file snapshots). On a Team Cloud each audience has its own engine home,
`engine-homes/<key>` in the data folder, keyed like its browser: `team`, `room-<hash>` or `member-<hash>` (16 hex
characters of the room id's or person key's SHA-256). Every engine a turn starts runs with HOME set to its
conversation's home (`server/engine-homes`): the drivers' spawns know their conversation (each adapter's `sendTurn` and
keep-warm ping run inside it), and an engine folder the environment names (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`,
`GROK_HOME`, the XDG folders, a ChatGPT plan's or a hosted model's folder) moves into that home too. Engines that share
one process between conversations (OpenCode) never share it across audiences.
In the bot box the turn's engine home is bound read-write **in place of** the server's HOME, after the data folder is
emptied: a shell in that turn finds its own audience's transcripts, and neither the server's HOME nor another audience's
home exists for it, by any path. Only the engines' sign-ins are the team's: each sign-in file (Claude's
`.credentials.json`, Codex's and Grok's `auth.json` and `config.toml`, and the other engines' named in
`engine-homes/layout.ts`) is kept in the home as a copy of the server's, and inside a box the server's own file is bound
over that copy read-write, so a token an engine refreshes there lands in the team's file (Claude Code 2.1.294 renames a
temp file over its credentials and, refused on a bind, writes in place; Codex writes in place). Outside a box an
engine writes its copy, and the next spawn in any audience takes the newest settled copy back for the team. Signing in
or out from the app still happens in the server's HOME and reaches every audience's next turn. Session resume works
within an audience: its home is the same at each turn. A member who leaves takes their engine home with them.
**Deleting.** A deleted conversation takes its Claude Code and Codex session files with it, from every audience's home
(`server/host/delete-leftovers.ts`): the session it resumes and every session its event log says it started, unless a
conversation that stays still resumes the same one. The files uploaded to it go too when no other conversation, picture
or routine names them, and so does its box uploads folder. A deleted bot also takes its checkpoints
(`checkpoints/<bot>`), its memory journal and the room and member memory layers kept beside its own folder. Nothing is reached through a link, and a cleanup that fails is logged
(`delete leftovers: … stayed behind`) and never fails the delete. Still left: sessions kept by the other engines
(OpenCode, Gemini and the rest of the ACP family, Muse), which only the engine itself can remove; the bot's
per-folder checkpoints, which its conversations share, until the bot is deleted; and anything on a computer other than
this server. Kept on purpose: a conversation's own folder (`task-workspaces/<bot>/<thread>`) holds the files the bot
made for the person, and stays when the conversation or the bot is deleted.
**The move.** The first turn after a Team Cloud starts with engine homes moves what the engines kept in the server's
HOME. A Claude Code project's session (`projects/<folder>/<session>.jsonl` and its session folder, `file-history`,
`session-env`, `todos`, `debug`) or a Codex rollout (`sessions/…/rollout-…-<id>.jsonl`, `archived_sessions`) goes to
the home of the conversation whose saved resume session id it is, or, for Claude, the conversation that alone runs in
that project's folder. What matches no conversation (a group chat's session, which the server keeps only in memory,
one of a deleted conversation, the prompt history, shell snapshots, Codex's logs) goes to the **held folder**,
`engine-homes/.held/<time>`: no route serves it, no box binds it, no backup carries it (the workspace backup leaves out
all of `engine-homes`), and it is deleted 30 days later. `engine-homes/.migrated` marks the move done.
Browsers follow the same rule (`server/browser/audience.ts`). A non-team audience adds a fixed suffix to the browser's
name, `--member-<hash>` or `--room-<hash>` (16 hex characters of the person key's or the room id's SHA-256: agent-browser
refuses a socket path over 103 bytes), to the agent-browser session, its saved state, its profile folder, the new
engine's profile key (`bot:<bot>--member-…`, `shared:<profile>--member-…`) and a temporary browser. Which suffixes exist
is kept in `browser/audiences.json`, so deleting a bot or a shared profile erases every audience's browser, after a
restart too. The live view takes `?threadId=` (the desktop's panel sends it on a team), and so do the signed-in sites
routes.
**Each Chrome in a box.** On a Team Cloud with the bot box on, the Chrome launcher agent-browser runs starts every
Chrome inside bubblewrap (`net: "shared"`, as the browser's own gate filters its traffic): the system read-only, private
`/tmp` (its HOME), the seccomp filter, and of the disk only the profile it was started on (`--user-data-dir`). A file a
turn uploads is linked into that profile's `omb-uploads/` first. The agent-browser daemon itself stays outside: it
holds the session's saved-state key and talks to the server's runtime by its own socket and pid files.
On a My Cloud and on a desktop there is one person: every audience is the team's, every layer is the bot's own folder,
every browser keeps its old name and Chrome its old launcher, every engine keeps the server's HOME, and nothing here
changes what those do.
## Routines
A routine lives where it was made or where it reports (`server/routine-home.ts`). One for a member's own MausBot, or that
names someone's private chat (the chat a bot made it in, `sourceThreadId`, or the one it reports into,
`resultsThreadId`), is that person's alone, as the chat is: no other member and no Admin, the Super admin included, sees
it or learns of it, in `GET /api/routines`, the `routine` and `routine.run` frames, a bot's overview, the Today counts, a
team export or backup, the Owner's whole-machine export, or a bot's `trigger_info` and `trigger` tools in any other
conversation; naming it by id (to change, run or delete it) answers 404 *no such routine*, and a file only such a
routine carries is refused as one that does not exist. Every other routine is its place's (the bot's shared chat, or a
group chat): whoever sees that place sees it, without the id of a conversation they may not read.
**Its results follow where it lives** (`server/host/team-routines.ts`). A private routine's runs open their
conversations as its person and report into a chat of theirs. A routine of a shared place opens its runs as the team,
whoever made it or runs it (the schedule, its writer or an Admin), and reports into the shared chat or into a results
chat of its own that everyone who reads the place reads; its asks go to whoever looks after the bot, or the Admins. The
routine form on a team offers two places: **Just me** (`resultsThreadId: "just-me"`, the person's own private chat with
the bot, or a new one named for the routine) and **Shared chat** (the bot's main chat); anyone who can chat with the bot
may choose either, and an older app's "The bot's main thread" (`null`) is the shared place's own results chat. A new
routine is never shared unless its writer chose that: the form presets the audience of the place it was opened from
(Shared chat only while that bot's shared chat is on screen; Just me in a private chat and anywhere else), always shows
who will see the routine and what it reports in the words of the label by the message box (*"Private · only you"*,
*"Shared · everyone in Acme can see"*), and a request that names no place (`resultsThreadId` left out) makes the
routine its writer's alone, as Just me does; when no private chat can be opened for it the request is refused. A routine
that reports into a chat whose audience is not its own, such as one made before this for the shared chat whose results
chat was opened privately for its writer, gets a results chat of its own place at its next run; the old chat stays
where it is, unchanged. The chat a routine opened for its own results does not make it anyone's until they write in it.
The routine picker lists only the bots the person can chat with and never a teammate's own MausBot, which takes no
routine from anyone else (404 *no such bot*).
## The shared chat's name
A team bot's main chat is called *"Shared chat"* on every person's copy (`WireTask.sharedChat`), whatever its stored title,
so nobody takes it for one of their own, and it can't be renamed. A member's own MausBot has none.
## Just me and Team
At the top of a team's sidebar sits one switch, **Just me / Team** (`src/state/team-side.ts`, `TeamSideSwitch`). It is in
the sidebar in each of its shapes: full width, the rail of icons (two icon buttons) and the phone's bot list. It is not
in the window's rail of Clouds, which is the desktop window's own chrome: a browser and a phone have none, one place has
none, and the person can hide it.
- **Just me** lists the bots the person can chat with privately (their own MausBot among them), each with their own
conversations with it, and no group chat. A bot opens the person's own chat with it, made through `POST
/api/bots/<id>/tasks` the first time.
- **Team** lists the bots that have a shared chat, each with its shared conversations only, and the group chats. A bot
opens its shared chat. A bot with no shared chat (a person's own MausBot) is not listed there. No new conversation is
offered beside a shared chat: a conversation a person opens is always private, so New conversation is under Just me.
- **Which side a conversation is on is the server's answer**, never the switch: `private: true` on the task (or a
conversation this page just made through that route, or the person's own MausBot) is Just me; everything else, a
conversation the page knows nothing of included, is Team. Neither side's list shows a conversation, an unread mark or a
preview line of the other's.
- **By the message box**, every conversation on a team says who can read it, from that same answer: *"Private · only
you"*, *"Shared · everyone in Acme can see"*, *"Shared · only people added and admins can see"* or, in a group chat,
*"Shared · everyone in this group chat can see"* (`TeamAudienceLabel`).
- **The switch follows a conversation the person asked for**: another bot or group chat, a conversation opened by
name (a notification, a link, search, a list), one they just made. That, and pressing the switch, are the only things
that keep a side as their choice (`localStorage`, `omb.team-side.<team>.<address>`, per person and team on the
device; Just me until they choose Team).
- **A conversation that came on screen unasked is never kept.** The first conversation a page shows, one the app opened
as it started (before it knew this is a team's Cloud), or the bot's own pick changing because a teammate opened
another: when it is of the other side, the page goes back to the side the person chose, to their own conversation
with that bot when it is listed there. So a reload with Just me chosen opens their private chat, never the shared one.
With nowhere to go on that side the switch shows the side on screen for that page only.
- **A draft stays with its conversation.** What is typed and not sent is kept per conversation, so it is never in the
message box of the other side; on a team the older per-bot draft is not read.
- **Unread.** The side not shown carries a dot when the person has something unread there (their own marks: a private
chat's, or their own read line of a shared chat or a group chat). A bot's row carries only its side's marks: no
unread mark, working line or preview from the other side's conversations.
- With one side empty (nobody shared a bot with them yet, say) there is no switch and the sidebar lists everything.
The first time the switch shows on a device, one line under it says what the two sides are.
## Who looks after a bot
Every team bot is **looked after by** one member, its owner in the data: `reportsTo` (an email) names them; without it
the bot is looked after by `createdBy`, the member who created it or moved it in (a bot a Chief creates gets the Chief's
owner). Bots made before owners were recorded have none until someone picks one, and their asks go to the Admins. The app
says "Looked after by Ada" wherever it names this (the Share dialog, Bot access, a new bot's form, a bot's menu) and
shows no face on a bot's sidebar row and no caption beside a department. The Team map's "Who looks after what" (also "See
who looks after what" in a department's menu) shows each person with the bots they look after by department. Each face is
the person's picture from their team profile, or their initial when they have none.
| Route | Who | What |
|---|---|---|
| `GET /api/team/people` | any member | the members the Admin has paired here (`team-people.json`), each `{email, name, role, you?, pictureUrl?, here}` (below, "Who is here") |
| `GET /api/team/people/<person>/picture?v=<version>` | the team's members | that member's picture (`<person>` is their person key). `pictureUrl` names it, and its `v` changes whenever they change it; it is revalidated on every use (`private, no-cache` with an ETag). A session that isn't a member of this team gets 403, anything off a team or not a current member's picture 404 |
| `PUT /api/bots/<id>/reports-to {email}` | an Admin, or the bot's owner handing it over | one bot's owner; `null` hands it back to its creator |
| `PUT /api/team/reports-to {botIds, email}` | an Admin | "Set who looks after all bots…" on a department |
| `GET /api/needs-you` | any member | what bots ask of this person now, and the tags and passed messages waiting for them |
**Routing** (`server/ask-routing.ts`). A bot's ask is a question or approval card, a sign-in, a failure, or a routine's
result. In a member's private chat it is that member's alone: never the owner's, never an Admin's. Anywhere else (the
bot's shared chats, a group chat, a routine nobody opened) it is the owner's, or the Admins' when nobody owns the bot.
The asked person alone gets it in their Needs you (`/api/needs-you`, `/api/today`) and in their inbox (below), with the
one notification about it (live and in the feed). The card itself stays in its conversation, where everyone who sees it
may answer it, and says "Asking Ada"; the asked person may answer it too, there or from their inbox. Routing decides only
whose inbox a bot's own asks reach; who may chat with the bot is its sharing's (below).
**One Chief of Staff per person.** A crowned bot that someone owns is that person's Chief; a team has at most one per
person (crowning a second hands the role over, and moving a Chief to someone who has one is refused). Its authority is
exactly the bots that person owns, in every department: they are its team in its roster, their incidents come
to it (into a notices chat of the person's own, "Problems to look at"; a notice from someone else's
private chat goes to that person instead), and it retries their threads and changes their setup. The person's private
chat with it is their inbox (below). A crowned bot nobody owns stays its department's Chief, as on any other server.
The app offers no crown on a team, so this is reached only through the API (`PATCH /api/bots/<id>` with `chiefOfStaff`).
**A person's MausBot hands work to the bots they can use.** A member's MausBot is not crowned and has no authority over any
bot, but from a chat private to its person, with their own request behind the turn, it can hand work to any team bot that
person may chat with, at their level (`server/host/personal-reach.ts`): the receiving bot reads it as `[From @MausBot (bot)
· assignment … for Mira · on their behalf at your level]`. The work runs in that bot's own private chat with the person,
exactly as if they had opened it themselves: it is created private to them before anyone's app hears of it, reads and
writes their layer, and comes back whole to the chat they asked in. Nothing of it reaches the bot's shared chat, a room,
the team's notes, another member or an admin, in lists, counts, search, notifications or streams. It never widens what the
person could do: a bot they cannot see is never named to their MausBot (by name or id it is refused exactly like a bot that
does not exist, and it is never counted), and a bot they can only view is refused. A routine, a relayed line, an event, a
guest and another person's words can't use this, and it can answer a waiting question only in that person's own chats.
The same holds for an Owner's or Admin's MausBot, which reaches every bot but other people's MausBots. Nothing reaches a
person's MausBot from outside it: no other bot is told of it.
**Every bot reaches every bot.** Departments are folders, not walls. Any bot can ask, hand work to or @mention any
other bot on the team, in any department, someone's Chief included, with nothing to set up (`canReach` in
`server/peer-roster.ts`). Each bot's roster lists them all, its own department first; a Chief's lists its own team
first and everyone else as reachable. Reaching a bot gives no authority over it: supervision, incidents, the inbox,
setup changes and automations follow ownership and departments as above (`hasAuthority`, `canManage`). Only two
things narrow reach: archived bots, and the audience rule below (a bot shared with different people; a person's own MausBot is the
one exception, above). A turn in someone's
private chat may ask any bot, and the work it hands on stays in that person's audience; it never hands work into a room
or posts in one.
On a My Cloud and on a desktop no bot has an owner: nothing is routed, and one Chief per department holds as before.
Every bot still reaches every bot there.
## Bringing a bot in from My Cloud
Someone with a My Cloud moves a bot into a team from two places: **Move to Acme** in the bot's menu on My Cloud, and on the
team's own page in the desktop app, **Bring your stuff**, which lists My Cloud's bots with one **Move** each. Both say the
same before anything moves: who can use it there, what goes (instructions, skills, files, routines, their chats), what
stays on My Cloud (what the bot remembers, their connections, MausBot), and that there is no way back.
A team's server isn't the person's own, so its page gets less than My Cloud's does (`electron/team-move-main.mjs`). It reads
only the names of My Cloud's bots and which can go, and only while it is the window's active page and the Admin's verified
session says the person is in that team. It names a bot by id, and main reads the name from My Cloud. The confirm is main's
own window, in English, because a page could soften or skip one of its own.
## Who can use a bot
Each team bot is shared like a document: what everyone in the team gets, and a level for each person added. A person's
level is the higher of the two. Admins (the Super admin included) and the bot's owner can always edit it and are never
listed.
| Level | What they can do |
|---|---|
| No access | Nothing: the bot is not listed for them, and every route naming it, its conversations, its rooms, routines and files answers 404 as for one that does not exist |
| Can view | See the bot, its main chat and the conversations shared with them (rooms, their own earlier chats), read only; open them and mark them read |
| Can chat | Also write to it, start their own conversations, answer its cards, react, stop a run they asked for (or one no person asked for; below, "Whoever asked owns the run"), write in its rooms, run its routines and make routines for it (and change or delete their own) |
| Can edit | Also change its instructions, profile and model, which of the team's connected apps and MCP servers it uses, any of its routines but those in someone else's private chat, its sharing, and approve a card that changes the bot itself (instructions, model, skills, team, triggers) |
Can edit never reaches what only an admin sets on any bot: what it may do on its computer (approval level, always
allow, folders), where it runs, its department, whom it answers to or directs, connecting an app or making a webhook.
- **Sharing.** `bot.access` is `{ everyone: "none" | "view" | "chat" | "edit", people: { <address>: "view" | "chat" |
"edit" } }` (`shared/bot-access.ts`); "none" for everyone is "Only people added". It is sent only on the copy of
someone who can edit the bot; everyone's copy carries `yourAccess`, their own level. `PUT /api/bots/<id>/access` with
the whole value sets it (Undo puts the previous one back): someone who can edit it only, a new person must be a
current member of the team (an older list's address or `@domain` may stay), and the owner is never stored. The bot's
`visibility` follows in the same write (everyone when everyone may at least view it, else the owner and the people
added), so every rule of the audience rule above that reads who sees a bot holds unchanged. A new team bot is
Everyone · Can chat. A member's own MausBot stays theirs alone, as before.
- **A person's own MausBot exists for nobody else.** To every other member and every Admin, the Owner included, it is
in no list, stream, search, export, routine picker or group chat, whatever its stored sharing says
(`VisibleSet.bot`), and every route naming it, by path (its memory layers too) or in a body, answers exactly as an id
no bot has: on a Team Cloud both are refused at the one gate, 404 *no such bot*, before any route can answer them
differently (`server/host/visibility-host.ts`). Only the Cloud itself reaches it: the leave clean-up and the Owner's
whole-machine export, which leaves it out.
- **Before levels.** A bot whose sharing was never set is read from its visibility, exactly as it was: everyone →
Everyone · Can chat; admins only → Only people added (admins still edit); a list of addresses → those people · Can
chat. Nothing is rewritten on disk; the first change in the Share dialog stores the sharing. An older app's
visibility change on a team sets the matching sharing.
- **The owner.** Handing a bot on (`PUT /api/bots/<id>/reports-to`) keeps the previous owner at Can edit, as a document
handed on does, and the new owner sees it.
- **Where it is enforced** (`server/host/bot-access-gate.ts`, after the visibility gate): every member request naming
a bot, a conversation (by path, query or body), a room (needing the level on every bot in it), a routine or a run.
Reads need Can view, everything that writes Can chat, sharing Can edit; `PATCH /api/bots/<id>` takes each level's own
fields (`memberBotPatchViolation`), a routine needs Can edit unless its writer is changing it, a new room or routine
needs Can chat on what it names, and a card that changes the bot needs Can edit to answer (`cardAnswerRefusal`). A
message another app relays for a person (the guarded route's `onBehalfOf`) needs that person to be able to chat; one
that names nobody needs everyone to be able to. The live stream sends each person their own `yourAccess` with every
bot frame, withdraws a bot that stops being shown (`bot.deleted`, and their stream ends to start fresh) and sends one
that becomes shown whole.
- **The app.** The bot's Share button (the bot panel in Simple, the settings header in Advanced) opens the Share
dialog on a team; the conversation's copy and download sit under the ⋯ of its header. On a team the dialog offers two
choices: "Everyone in Acme can use it" (`everyone: "chat"`) or "Only people added" (`everyone: "none"`), and lists
who looks after the bot and each person added, with Remove and the handover; it shows no Can view / Can chat / Can
edit menu. A person added gets `chat`; a `view` or `edit` stored earlier (or set through the API) stays as it is
until they are removed, so the levels keep working underneath for a finer switch later. Someone who can only view a
bot sees "You can view Payroll, not chat with it." where the composer would be (and in a room with that bot), opens
its main chat rather than a new one of their own, and is offered no New conversation. Off a team none of this shows.
- **Bot access** (its own Settings page, for a team's admins): one row per team bot (each person's own MausBot stays
out) with who looks after it, what everyone gets and the faces of those added; a row opens that bot's Share dialog.
Everyone's access reads as one column, "Everyone can use it" or "Only people added". By person lists each User, and
opening one shows their bots, each with Remove where they were added. The same sharing as the Share dialog, applied at
once with Undo. Editing a bot stays with admins and the person who looks after it; "Ask Ada to chat" stays (below).
- **Asking to chat.** Beside "You can view Payroll" sits "Ask Ada to chat" (Ada owns it; "Ask an admin to chat" when
nobody on the team does). `POST /api/bots/<id>/access-request` (anyone who can view the bot; someone who can chat
already gets 409) puts an Allow / Not now card in the owner's inbox, or each admin's when the owner is gone
(`server/host/access-requests.ts`). Asking again while a card waits sends nothing new. Allow, answered from the inbox
or the card in its chat, gives Can chat at once, settles the same card in every other inbox, and tells the asker in
their own inbox ("Ada let you chat with Payroll."); it takes effect only while the person answering can edit the bot.
Not now changes nothing and tells nobody; they can ask again. The card is a set-answer question card
(`card.accessRequest`), shown in each person's language, and its answer is always its own option.
## Whoever asked owns the run
In a conversation more than one person reads (a bot's shared chats, a conversation opened from one, a group chat), a
run belongs to the person whose own request it answers (`server/host/run-owner.ts`, the one place that decides):
- **Someone else's line waits its turn.** Sent while Priya's request runs, Tom's line never reaches her run, never stops
it and never cuts it short to be answered first: it waits in the queue and runs after hers, as its own turn, with his
name on it. The send answers `{ queued: true, queueId }`. Lines from different people never join one turn; a run of
one person's lines does, as before. Priya adding to her own request is steered into it and, after a step has run a
minute with her line unread, answered first (`server/host/person-first.ts`), exactly as in a private chat; her own
line goes ahead of anyone else's waiting.
- **Everyone there sees the queue.** Each waiting line carries its writer (`BotQueuedMessages[…].sender`) and the
conversation, while it works, the person whose request runs (`WireTask.askedBy`). The app's working line reads
"Working on Priya's request", with one quiet line under it, "Next: Tom's request" ("…, then 2 more" when more wait);
someone whose own message waits also sees its words there ("Yours: …") with a small remove control, which nobody else
sees; that chat shows no queue above the composer. A person is named as the People list names them: their name, or
for an address the part before the @ ("Dev's request" for dev@acme.test).
Only its writer removes a waiting line (`DELETE
/api/bots/<bot>/queue/<id>`; a line no person wrote, an Owner or Admin), and waiting lines join the running request
(`…/steer`) only when every one is the asker's own and the asker adds them; otherwise 403 in plain words.
- **Only the asker, or an Owner or Admin, stops it.** Stop (`POST /api/bots/<bot>/interrupt`, a group chat's
`/api/groups/<id>/interrupt`, a typed "stop", a guarded request's `/interrupt`) from anyone else answers 403 with
`code: "stop_not_yours"` and *"Only Priya or an admin can stop this."*; their "@Scout stop" in a group chat is an
ordinary line there. Stop stays the only interrupt. The app shows Stop, and Retry on a stopped request, only to the
asker and to Owners and Admins (and to everyone for a run no person asked for). The server holds Retry to the same
rule: running someone else's request again (`POST /api/bots/<bot>/messages/<id>/edit`, with the same words or new
ones) answers 403 with `code: "retry_not_yours"` and *"Only Priya or an admin can run this request again."* In a
shared conversation this rule alone decides it, whoever opened the conversation; renaming, deleting or otherwise
changing the conversation itself stays with whoever opened it and the Owner's side. The stopped row records who pressed
Stop (`WireMessage.stoppedBy`): it reads "Maya stopped this." to everyone else and "You stopped this." to Maya.
- **Who asked, for what is not a person's line** (read from the records the provenance labels come from, never from
model text). A routine, a webhook and an app's event never run in a person's conversation: each runs in its own and
leaves one report line, held while a turn runs and written before the queue behind it moves
([automation-reports.md](verification/automation-reports.md)); none steers or stops anyone's run. Work a bot hands on
for a person (`[… · on their behalf at your level]`) is that person's run. A line sent through the API, one another
app relays, a bot's message and its report (*information only*) are nobody's request: they wait like anyone else's
line, a run they start is nobody's and anyone who may chat stops it, and a person's line waits behind such a run only
until it ends, ahead of any bot or app line still waiting.
A private chat, a personal install and a My Cloud have one person: none of this applies there, and an older app, which
reads neither field, simply shows no names on the queue.
## A team with no AI
Until someone connects an AI, a bot has nothing to answer with. A Send from a User comes back as `409` with `code: "no_ai"`:
*"Olive needs to connect an AI before bots can answer. I've told them."*, and the app keeps their message in the box for
them to send again. The owner gets one line in their inbox, naming who tried and never their words, at most once a day
for each person (`server/host/no-ai-notice.ts`). The machine learns who the owner is only when they first open the team, so
until then the User reads that the owner will be told, and the line goes when the owner first opens it
(`no-ai-notices.json`). An Admin reads *"Connect an AI first, so bots can answer."* and nobody is told; a run nobody
started by hand reads *"No AI is connected yet, so nothing can run."*
## One inbox per person
Everything addressed to a person reaches one place, their **inbox** (`server/host/person-inbox.ts`): their own private
chat with their own MausBot (every member gets one; a Chief of Staff of their own, crowned through the API or before the
app stopped offering it, holds it instead). It is the chat that bot opens to for them (`WireTask.inbox`, sent to its
person alone): the one their brief was in, else their newest own chat with that bot, else a new one, "Inbox". Nothing is
turned on: every member of a team has one, and off a team none exists. The app shows the inbox as one list, newest first, with a dot on
each line that waits on the person; a line marked done leaves the list (its `tab` on the wire stays "needs-you",
"info" or "done" as before).
Each thing addressed to the person is one line there, `WireMessage.inbox` (`shared/inbox.ts`): who it is from (a
teammate or a bot), the bot that passed it on, and the conversation it came from, linked only when the person may read
it. The person gets **one notification** for it, from the inbox (`forPerson`, the usual live and phone path), and the
inbox is unread.
| What | Line | It answers back to |
|---|---|---|
| `message_person` ("tell Ben that…"), from any conversation | the message, `kind: "message"` | the bot that asked, in that conversation |
| @Ben in a teammate's line, anywhere (only their words travel) | the line or paragraph, `"message"` | the teammate's own inbox |
| @Ben in a bot's settled reply, where Ben can see it | the same | that bot, in that conversation |
| a question or approval routed to Ben (`ask-routing.ts`), from a shared chat, a room, a routine or work handed on | a copy of the card, `"ask"` | the card where it was asked: the copy's buttons answer it there (`answeredIn`) and the copy follows it |
| a routine's report or a failure routed to Ben | the report, `"result"` / `"notice"` | nothing |
| a routine Ben wrote that tried to hand work to a bot it may not reach | what was refused, `"notice"` | nothing |
An ask in the person's own private chat, where they are, stays only there. The Admins' asks (a bot nobody owns) are not
anyone's inbox. A member without any bot of their own gets the notification where it was written, as before.
**A teammate's question.** At Full access ("Just do it", every bot's default) a question from work the person started
and a bot handed on never reaches them unless only they can give what it asks (a sign-in or code, a payment, a secret or
an account of theirs; work a webhook or another outside event started still asks them, and so does work in their
private chat, whose question's words stay there): the bot that handed the work on answers it, and with no answer from it the bot goes on with the default it
named and says so; its card in the bot's conversation shows who picked what, waits on nobody, and still takes the
person's own answer. What only the person can do is asked for exactly, once, while the rest of the work moves on. On
"Ask me first" work handed on that needs the person asks them with a card: a decision with a few set answers through the
bot's question tool, the bot's own choices as buttons, and anything else as a card with a reply field in place of its
`NEEDS INPUT:` line (docs/runtime-contract.md). It waits however long they take. Its copy reaches their inbox with the
same buttons, and the conversation that handed the work on gets no chip about it when that conversation is the inbox
itself. An ask left for a day is said in words the next time the person talks in their inbox (a line in the bot's reply, no new
card), and a Chief also says so in its other chats about the questions it may answer. A line the person types while the bot waits on its own question card answers that card, as a tap would, and so
does a reply to the card or to its copy, an answer their Chief or another bot passes on with `coordinate_bots`
(`answers`, the question's assignment id), and anything the bot that handed the work on sends to that thread later. A question whose work ended, or that the bot replaced with a newer one, closes,
and its copy, Needs you and the overview follow; a restart posts nothing again.
**Answering is replying.** A reply to a line (the chat's Reply) goes back as the table says, and the inbox's bot takes no
turn for it; the line records the answer and a "Told Cleo" chip follows it. A bot that asked carries on: its
conversation gets a line from the inbox's bot, "Ben replied to your message ("…"): …" (`relay.kind: "reply"`), which
starts its turn or waits behind the running one. Writing to the Chief in the inbox instead ("tell Cleo yes") works
too: its turns there are told what waits on the person, and `message_person` to a line's sender answers that line. A 👍
or 👎 on a line answers it the same way, with that emoji.
**What waits on the person** is read from where it was asked, each time. An ask waits while the card that asked is open
in its conversation: once it is answered there, from the inbox's copy (either respond route), by a reaction or on a
call, or closed (an approval whose turn ended or was stopped, a question the person closed), it no longer waits, and the
copy follows the card. A question its turn left open still waits for a late answer; once its conversation is deleted,
the copy is closed. A line that wants an answer waits until the person answers it, from the inbox or with a line of
their own in the conversation it came from. The inbox's task carries the asks that wait (`WireTask.openAsks`, sent again
whenever one is asked or settled), which the app shows on the person's own row under People and adds to its unread
count: the browser tab's title and the app's icon.
**The inbox's list and its card.** `GET /api/inbox` lists the session's own person's inbox (`InboxView`,
`shared/inbox.ts`), newest first, each line in one tab: **Needs you** while it waits on them (an ask, by the rule above;
their count is the badge), **For your info** for news until they answer it or mark it done, and **Done** once it is
settled or marked done. `POST /api/inbox/<line>/done` marks news done (an open ask refuses, 409: only its answer settles
it) and `POST /api/inbox/<line>/read` marks one line read; each answers with the list as it is now. The app shows the
list in the right panel, opened from the card, the badge or the person's own row under People: dense rows with each
line's quick answers, a reading view with the reply, and a sheet at phone width. Answering from a row answers the copy's
card or replies to the line, through the same routes as in the chat. In the inbox conversation one card sits under the
newest line that needed the person (`InboxView.anchor`) and updates in place. The inbox's task carries
`inboxChangedAt`, which moves whenever the list changes; a client showing the list reads it again then.
**The representative.** When a message asks something a fact the person already shares with the team answers, their
inbox's bot answers it for them (`server/host/representative.ts`); the answer goes back like a reply, as "Ben's
assistant answered …" (`relay.kind: "assistant"`), and the line shows the question, the answer and that a reply corrects
it, with the one notification saying so. What it reads is built by the server, never chosen by a model:
- **Busy times** from the person's own Google Calendar connection, when Google Calendar is each person's own on the team
and they connected theirs (`person-calendar.ts`): start and end only, never a title, place, description or guest. It
is the one use of a member's own connection outside their own turn, and it reads nothing else of it.
- **Shared notes**: the team layer (`workspaces/<bot>`) of the inbox's bot and the bots the person owns that everyone on
the team sees; never a member layer, a room's, About you or a private chat.
- **Work status**: those bots' status and the titles of their shared conversations that are working.
It has **no tools**: one text call on its bot's engine (`SideJob` `"representative"`, `generateText` only, never an agent
process). Its answer must be JSON `{"answer"}` or `{"wait": true}`; anything else waits. Code holds back what only the
person can say, before any model call: anything not a question, and a question that decides, approves, agrees, promises,
commits, books, sends or shares, reaches outside the team, or asks about something private (English and German words);
and after it, an answer that speaks for the person's will ("Ben will…", "approved"). Asks and approvals never reach it,
nor tags of the person in a teammate's words that are not a question.
**Refused, in words a bot repeats:** someone who left ("Carl has left Acme…"), nobody of that name (the people are
listed), two of that name, the person the bot is already talking with, a person who cannot use this bot, someone with no
inbox here, and more than 10 passes an hour from one conversation (answers from an inbox included).
**People.** The composer's @ list offers the teammates after the bots, with their picture and a "Person" tag; the
sidebar shows the team's people as a row of faces above the bots, never as rows of their own: your own face opens your
inbox, a teammate's puts their @name in the composer on screen. Each person is tagged by their whole name, and by their first name when no other teammate shares
it (`shared/person-mention.ts`). A tag a bot shares is the person's: "@Ben" reaches Ben even with a bot called Ben, and
that bot is not told it was named; only a longer bot name ("@Ben Bot") reaches a bot. A bot made or renamed with a
person's name says so once in its own chat, and the Chief's `create_bot` result warns (`server/host/bot-name-clash.ts`).
Deleting a Chief of Staff offers to archive it instead, which keeps its chats. A bot's turn is told the team's people (names and admin roles; not the person it talks
with), that `message_person` reaches their inbox, and that a tag already did. Needs you lists the inbox's messages the
person has not opened and that still wait (`kind: "message"`); the app opens the inbox on the person's first arrival of
the day when something waits there. The state is ids, names and short excerpts, in `person-inbox.json`; a member who
leaves takes their inbox with them.
On a My Cloud and on a desktop there are no team people: the @ list shows bots, `@you` and the profile name tag the one
person as before, and `message_person` and the inbox do not exist.
## Who is here
The sidebar's People puts a dot on the face of each teammate who is here now, and lists them first after you; a
screen reader hears *"Write to Ada. Here now"*. A bot's shared chat and a group chat show at the right of their header
the faces of the others who have that conversation open right now (*"Ada and Ben have this open"* to a screen reader).
A private chat shows none of it. Nothing says when someone was last here, and nothing says who is typing.
- **Here.** A person is here while one of their sessions has the event stream open (`GET /api/events`), or a window of
theirs said so in the last 45 seconds, and for 45 seconds after the last of those ends, so a reconnect or a phone in
the background for a moment changes nothing. It is kept in memory only (`server/team-presence.ts`) and nothing about
it is written to disk. A restart forgets who was here until their apps reconnect.
- **What the app sends.** `POST /api/presence/here {tab, threadId}` (any team session): `tab` is the window's own id,
`threadId` the conversation it shows, or null for none, a hidden window or a private chat. It is sent when the
conversation changes and every minute while the window shows. A window not heard from for about three minutes no
longer shows anything.
- **What comes back.** `GET /api/team/people` gives each person `here`. On the event stream, outside the replay buffer
(frames with no `id`): `team.presence {email, here}` to everyone signed in, at most one every 5 seconds per person,
with the state it ends in, and `thread.presence {threadId, people}` to the people listed, at most one a conversation
every 0.75 seconds. `team.people` (replayed, like any frame) says someone joined, left or changed role: the app reads
the people and its own session again, so a role the Admin changed shows without reopening the team. A stream that
reconnects without its replay reads the people again and sends its window again. Types: `shared/team-presence.ts`.
- **Private means private.** A conversation counts only when more than one person can read it (the team's or a group
chat's audience, with nobody's own MausBot in it) and the person sending can read it; anything else, a private chat
above all, is taken as no conversation and is never kept. Each list is checked again as it is sent, against who may
read the conversation then (`VisibleSet.thread`). So nobody, the Owner and every Admin included, learns from
presence that a person has a private chat open, which bot they talk to privately, or that a private chat exists: that
person only counts as here. `server/cloud-team-presence.e2e.test.ts` proves it from a member's and an admin's side.
- **The People list itself** says *"Loading people…"* while it loads and *"Couldn't load people"* with **Try again** when
it cannot, and at phone width choosing a person closes the full-screen list.
- **Older apps and servers.** A server that sends no `here` on the people list gets no `POST /api/presence/here` and the
app shows nothing of this; an older app on a newer server still counts as here by its stream and ignores the frames.
On a My Cloud and on a desktop there is one person: no People list, no presence, nothing sent.
## When you are away
A member who has no live stream on the team (no app or phone open on it) is away, and three things reach them by one
email, which the Admin sends (openmaus-cloud docs/team-cloud.md "When you are away"): a teammate or bot mentioned them
or passed them a message; a question, approval or sign-in waits on their answer (routed to them, or in their own private
chat) for at least 5 minutes; work they asked for in a shared chat or group chat, or a routine's report routed to them,
finished or failed. The first thing opens a 10-minute window and everything in it goes in one email; finished work is
left out when a question or mention from the same conversation is in it, and is told once per conversation per stretch
away. At least 30 minutes between two emails, at most 6 a day, and never the same thing twice. Coming back before it
goes, answering, or opening the line drops it. `server/host/team-away.ts` decides; `team-away-events.ts` says what is
worth one; "away" is one function (`here`) so presence can replace it.
What leaves the machine is `POST /api/cloud/machines/team-away`, signed with the bootstrap secret (`omb-team-away-v1`):
`{ v: 1, notices: [{ id, to, language?, items: [{ kind: "mention" | "waiting" | "finished" | "failed", key, who?, place?,
routine?, private? }], more? }], prefs?: [{ to, on }] }`. `key` is a SHA-256 of what it is about; `who` a teammate's or
bot's name; `place` a group chat's or the bot's name. Never a message's words, a title or an id. From someone's private
chat an item names no bot and no place (`private: true`; a teammate who wrote to you is named). Each person turns these
off with "Email me when I'm away" under Settings → General (a row of Account in Simple, in Profile in Advanced; `GET`/`PUT
/api/me/away-emails`, kept in `people/<person>/away-emails.json`, which leaving deletes) or with the email's stop link; the
Admin's answer lists who is off, and the switch follows. An older Admin's 404 pauses asking for an hour. The email's button
opens the team's page on the Cloud site (`/cloud/team/<id>`), not a chat. Nothing of this runs off a team.
## What is not closed here
- **A group chat's waiting lines.** A group chat queues every line while it works, and only the asker or an admin stops
it, but its queue is still shown only to each line's own writer.
- **Saved logins per member.** A login saved from a sign-in card is the team's (an Admin saves it, for every bot in the
team); per-member saved logins are not built. Sign-ins a member makes in their own browser stay in it (above).
- **The new browser engine and temporary browsers in a box.** The new engine (`browserEngine.engine: "chrome"`, off by
default) starts Chrome itself over a pipe and is not boxed; a temporary browser is boxed, but a file a turn uploads
into it is not reachable from its box.
- **Per-member engine sign-ins.** Every audience's engine signs in as the team (its key through the broker, or the
team's sign-in files in each engine home, above); a member cannot sign an engine in as themselves, by design.
- **Removal without `member-left`.** Without the Admin's `member-left` request (above), removal takes effect at the next
roster check (about 6 minutes), and nothing is deleted, handed over or taken off a bot.
- **Someone invited who never opened the team.** The machine first learns of a member at their first pairing, so until
then they are in no People list, and nothing says they joined (`team.people`): the Admin sends no roster and no
"joined" of its own.
- **Engine homes without a box, and engines outside the list.** Without the bot box (bubblewrap refused, or switched
off) a shell reaches every file of the server's user, engine homes included; only the box keeps them apart. An engine
whose sign-in file is not named in `engine-homes/layout.ts` starts signed out in every audience's home. Engines
installed into the server's HOME by hand are not reachable from a team turn's box; the image's are.
- **An engine that replaces its sign-in file by renaming.** Inside a box the rename is refused (the team's file is bound
there); an engine that does not then write in place keeps a refreshed token for that process only.
- **What a bot keeps per bot, not per audience.** Its data sheet, the skills it stages and its own profile are the bot's
(the team's), whichever chat wrote them; a routine is its place's (above, "Routines"). The Owner's whole-machine
export leaves every member's private chats out (above), the Owner's own too: an export is the team's, never anyone's
alone.
- **Own apps after the team changes its Composio key.** Saving, removing or replacing the team's key leaves every
member's own apps to be connected again. What they connected before stays where it was made (the connected-apps
service, or the earlier project) and is not erased from here.
- **A member who leaves a team on the connected-apps service.** Their installation's token is forgotten here, but the
installation and its connections are not erased at the service. With the team's own key their connections are
removed from the project.
## The desktop app and a team it was invited to
- **`openmausbot://team/<team id>`** (the join page's Open in the MausBot app; `electron/team-join.mjs`): the id only. Main
opens the team as the verified session lists it: signed in as the same address it reads the session again at once and
opens the team; signed out it starts its own browser sign-in and opens the team once signed in; signed in as someone
the team isn't listed for, main's own dialog says so and offers to sign in with another address (Add another account:
the one in use stays signed in). A link left waiting is dropped after 30 minutes. `openmausbot://cloud` is unchanged.
- **Landing** (`landing` in the Admin's session: no Cloud of the account's own, invited into a team): the window moves to the
team only on a sign-in this app just saw, the first on this device (`team-landing.json` keeps the accounts signed in here
and the teams opened here), with no place of the person's own on screen (My Cloud, a remembered place); then once. Any
other time, the team is in the rail and the window stays. No plan is pushed meanwhile: no trial card, no Start free trial
in Settings (`cloudPlanView` `invited`).
- A sign-in the link started that finishes as an address the team isn't listed for says so, with the way to sign in with
the other address, never nothing.