# 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:`); 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 · · ]`, 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//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//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/`, 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 (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
" 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//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 /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: { : {
: state } }, mcp: { : {
: 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: "* (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: { : [address] }, mcp: { : [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": "", "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": "
" }`, 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/`, its uploader only, while nothing uses it); their memory layers (`workspaces/--member-`, their journal and search index); their "About you" and profile (`people//`); 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/`: MEMORY.md, memory/, skills), team files, routines nobody opened | | `room:` | whoever sees the room | the room's threads, `workspaces/--room-` | | `member:` | that person only (never an Admin) | the conversations they opened (`thread-starters.json`), `workspaces/--member-`, their uploads, `people//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/`, a bot's or room's `/tasks/`), `?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/` in the data folder, keyed like its browser: `team`, `room-` or `member-` (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/`), 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//`) 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//.jsonl` and its session folder, `file-history`, `session-env`, `todos`, `debug`) or a Codex rollout (`sessions/…/rollout-…-.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/