Team API compatibility

Team API compatibility boundary

Current remote connections use Team API protocol v9 over three ordered WebRTC DataChannels: rpc, events, and files. A sandboxed hidden Chromium page owns each RTCPeerConnection. Electron main uses a MessagePort and transfers binary data as ArrayBuffer. Signal carries SDP and ICE only, except the Slack requests of an agent's Slack app (see Messaging connections). OpenBot Mobile uses the same ticket, authentication transcript, framing, RPC codec, and event stream. In Expo Go, an Expo DOM component owns the browser RTCPeerConnection inside a hidden WebView and passes only serializable, validated commands and events to the native React UI; no native WebRTC module or development build is required. The host's network route preference (openbot-remote-network-route-v1.json) applies to every remote connection to the computer. With private, the host gives WebRTC no ICE servers, and the hidden page removes each local, remote, and SDP candidate that is not a host candidate on a private address (RFC 1918, 100.64.0.0/10, fc00::/7, or .local). A change to private closes the connected peers, so each one connects again without the relay. The Remote Desktop runtime reads its ICE servers from main for each stream and sends them to the viewer, so its media also has no relay. Its candidates are not filtered; a direct public IPv6 path stays possible. Mobile server labels in the drawer and connection settings describe the authenticated application connection, not membership or Signal presence. Each membership has its own transport and recovery controller while mobile is active. On a return to the app, the selected server connects first; the others become active when it is online, or after 3 s. Switching servers changes the visible workspace without closing other connections. iOS inactive transitions leave connections alone. Backgrounding retains the last connection status and pauses recovery; transport failures received in the background are retained for resume. Only the paired desktop is Local; other servers are Remote regardless of the account role. Connecting becomes Online after compatibility succeeds and the agents, the read state and the sections load. These three reads run together. Cached conversations load again after Online, the open conversation first; a chat that did not load then loads when it opens. Transport failures and reconnect attempts show Offline, and protocol failures show Connection error. The existing RTC connection updates and recovery controller are the source of truth; the indicator adds no polling or health requests. Foreground resume reuses a healthy connection without showing a reconnect. When the connection from before the background stays (kept on the connect result) and no read stopped at background entry, the first attempt only checks the path with the compatibility read and reads the open chats again; the event stream delivered every event, or an event-reset asks for a full load. A new connection, a stopped read, a refresh and a failed check load the workspace again. The time away decides what happens to the connection: after less than 30 s the path is kept. After 30 s the host stopped using the path (ICE consent), so the peer renews its Signal socket and restarts ICE; a host that dropped the connection answers with a new fingerprint, and the peer fails at once instead of after a read timeout. A connection that is lost while a return checks it is replaced at once, with no countdown, as when the check fails. Signal closes the socket of a suspended phone after its 120 s idle timeout and drops the connection after the reconnect grace: 10 minutes for a host that multiplexes, 30 s for an older host or an older Signal (reconnectGraceMs in ready, SIGNAL_CLIENT_RECONNECT_GRACE_MS). After the idle timeout, the grace and 10 s more, the peer is replaced on the same session at once, without a resume hello, and the first attempt connects. A move to another kind of network, such as Wi-Fi to mobile data (expo-network, apps/mobile/src/shared/lib/network-path.ts), ends the connection, and the recovery makes a new one on the same session at once (networkMoved); the WebView's online event does not fire for such a move. In support logs, an ICE restart after a network move did not bring the path back within 6 s, and a new connection was up in 1.5–3 s. In other cases, a probe of the path waits up to 6 s after the start of an ICE restart (ICE_RESTART_PROBE_MS) before it judges the path dead. The desktop host closes a connection at its 11th ICE restart, so the peer counts its restart offers and, after 10, makes a new connection on the same session instead of an 11th. A new connection to a known host opens its Signal socket while the ticket request runs, and, with ICE servers less than 30 minutes old, gathers candidates before ready; its first offer holds the candidates gathered so far. A server that was Online when the app left keeps its Online look during the first attempt after the return, for up to 5 s (QUIET_RESUME_MS in server-connection.tsx): the server is resuming, showsOnline is true, and no reconnect status, faded avatars or faded chat show. A workspace request to a resuming server waits for Online instead of failing; it fails when an attempt fails, when the app leaves, or when the server goes. The outbox, the queue, history reloads and the browser view still read the live state. A failed attempt, a cooldown, a protocol stop or the end of the 5 s shows the normal reconnect status. A server that was not Online when the app left shows its status at once. Canceled reads and event resets request data synchronization without showing a reconnect on a healthy connection. Invite selection and manual refresh reuse the same controller. A transient RTC disconnected state has a 15-second recovery window, including on resume; failed or closed states drive recovery immediately. Signal's rate_limited, or a close with code 1008 and no error frame, makes the next Signal socket wait 60 s; another refusal with code 1008, such as an invalid ticket, reconnects at once. Backgrounding pauses the grace timer and cancels pending reads so they cannot block resume. Explicit refresh bypasses the retry cooldown without overlapping a pending connection attempt. The native/DOM mailbox carries concurrent commands by ID. Switching or disconnecting cancels pending callers immediately; peer generations reject late callbacks from a superseded host. The persisted hosting preference is restored on startup in both the normal desktop and the development host. Starting the development HTTP API alone does not publish WebRTC; Mobile Connect needs the published host. The separate development test-client role never auto-publishes. Mobile Connect tickets and QR codes bind the started host ID and SHA-256 public-key fingerprint. Mobile verifies that binding at redemption and against the directory, pins the key, and selects that host rather than the first account-owned desktop. Legacy unbound QR codes require regeneration. A permanent Mobile Connect link uses the same URL and redemption route, so released phone apps accept it. It has the same host binding and redeems more than once. D1 keeps only its hash. It stops working when the desktop replaces or revokes it, or when the desktop session that made it ends. The desktop keeps the link in its encrypted session file and deletes it at sign-out. mobile_auth_sessions.connect_link_host_id records the host whose link signed in a phone. A revoke signs out those phones, also when the link row went with its desktop session, and the device list shows each phone's method. DELETE /v1/mobile-auth/ticket?host= spends the unused one-time codes of that host when the desktop cancels the code that it shows. A phone session is account-wide: the phone opens each host of the account directory, not only the host of its code. GET /v1/mobile-auth/devices?host= lists the account's phones only when that host is in the directory (an active membership and a device key), so they can open it. With no host, the route returns every phone, as before. The desktop Connected devices list sends its started host and shows no phones when it has no host identity. Account sessions still list and revoke each phone. A phone can also sign in with an email code. It requests the code through /v1/auth/email/start and verifies it through /v1/mobile-auth/email/verify, which writes the auth_sessions row and its mobile_auth_sessions row in one batch; /v1/auth/email/verify would make a desktop session. This session has no host binding: the phone stores signIn: "email" with it, connects to every host in the account directory and pins each host key on first use, as the web client does. A stored session with no host and no marker is still a legacy QR session and is revoked. The development LAN allow list keeps both email routes on loopback, because a development code follows from its challenge ID. Desktop invitation QR codes contain the same one-use link as Copy link. The signed-in mobile scanner validates that link and opens the invitation review before acceptance. These codes join one server; Mobile Connect codes sign in to the desktop account. Mobile keeps the paired host as the default server above the drawer divider until the user changes it, and opens on the first server. Both clients read account-wide membership from D1's indexed remote_memberships / remote_hosts join. An offline paired desktop does not remove other memberships, and mobile connects directly to each host independently. A cold launch refreshes the account directory. Both clients check again every 15 minutes while active. Concurrent directory reads coalesce; inactive clients do not poll. Mobile background entry stops the timer, and foreground entry starts a new 15-minute interval. iOS inactive transitions, including Notification Center, do not trigger a check or reset the timer. Session revocation, explicit refresh, and completed invitation acceptance can refresh sooner. A lost healthy connection also requests membership reconciliation; a transport failure alone never removes a server. Mobile member controls use the same account endpoints: owners and admins invite, change another member's role, remove access and end another member's session. Nobody can change or remove the owner, and only the owner ends the owner's sessions. canAdministerServer in packages/contracts/src/ipc-team-host.ts is the one role check for the clients and the host. On a host with an account directory, the Team API member PATCH and DELETE write to D1 through HostService, so the next directory sync does not undo them; each sync closes the host sessions of a member that D1 removed, disabled or gave another role. D1 retains revoked membership records and invalidates affected sessions; both clients exclude inactive members from the active list and count. On legacy HTTP(S) hosts, desktop exposes inactive records separately for removal before a new invitation; it does not restore the pause/restore controls or change the released invitation rules. Mobile separates shareable links from email invitations. Email mode creates an address-bound invitation and sends it through the same delivery endpoint as desktop; failed delivery attempts revoke the new invitation. Released restore endpoints remain compatible with older clients. Member and invitation lists refresh after changes or on explicit request. Conversation read cursors belong to a team member and are shared across that member's devices. Moving a cursor emits a conversation invalidation without the reader's identity or cursor, only to the connections of that member; other members get no event, because their read state did not change. The member's clients reload their own read state even when the conversation content revision is unchanged. Mobile acknowledges rendered replies only in the foreground, focused chat at the latest messages. Mobile attachments use the shared desktop filename allowlist in packages/contracts/src/attachment-files.ts. The native document and photo pickers and the in-chat camera panel prepare local drafts. The existing Team file protocol uploads them to the host before one message commits the ordered draft IDs. Mobile limits each file to 10 MB because the native/DOM bridge copies Base64 data. Downloads use the same authenticated file channel, validate size, chunk order, and SHA-256, and pass verified bytes back through the command bridge. Mobile queues downloads to limit concurrent copies. Image previews preserve aspect ratio. Audio and video play in the chat. Other files take the desktop preview kind from filePreviewKindForFile: Markdown, text, HTML, PDF and .xlsx files open on the preview screen. The .xlsx parser in packages/contracts/src/spreadsheet-workbook.ts runs in a DOM component, because it needs DOMParser. iOS shows a PDF in a native web view with no scripts; Android draws its pages with pdf.js in a DOM component. A visual reply shows its page in the chat, with no scripts. Files with no preview use the system share sheet through a temporary cache file.

Mobile chat loads the latest 50 messages through conversation-page and loads older pages by cursor. Its Legend List (KeyboardAwareLegendList from @legendapp/list/keyboard) renders only the rows near the viewport, gets every loaded message, and retains the visible position when older pages are added. A row renders again only when its message changes; other row state comes from a React context. Reply references travel with each page. A page with no overlap replaces the cached window so a reconnect cannot leave an invisible gap. Older-page responses do not advance the live revision. The in-memory conversation store notifies subscribers per agent and combines streamed text once per animation frame. Windows above 50 messages are released when their last subscriber leaves; the complete history remains in the host database. Connection recovery prioritizes observed chats. Mobile chat keeps viewport and composer measurements in its motion controller. The last user message is the list's anchoredEndSpace anchor: the list adds a blank-space inset under it, and streamed replies consume that inset without autoscrolling. Initial history positioning and the first-send/first-response animation are separate states. Pending message bubbles reconcile through the host receipt ID, not message text. Mobile replies use the existing replyToMessageId field and retain their source after delivery. Agent bubbles support swipe-to-reply and a long-press action sheet with haptic feedback. The sheet has one nested native stack for actions and text selection; message text stays in memory, outside route parameters. Failed sends restore the reply target, and the composer can cancel it. Selected mobile attachments use the existing WebRTC file frames followed by the attachment upload endpoint; the native/DOM bridge limits each file to 10 MB and cancels transfers when its connection is replaced. The optional conversation-unread capability adds a separate POST /v1/agents/:id/conversation/unread operation. Ordinary read acknowledgements remain monotonic; explicit unread resets persist in the host's SQLite and emit the same invalidation. Older hosts disable only this optional action. That route marks the whole chat. The optional conversation-unread-from-v1 capability (protocol 9 only, packages/contracts/src/team-protocol/conversation-unread-from-v1.ts, host route src/main/team-api/route-conversation-unread-from.ts) adds POST /v1/conversations/unread-from with { agentId, fromMessageId }: the member's read mark moves to the message shown before it, and the answer is the new read state. It needs view on the agent, as the other read routes do. A message the chat does not show is not found. The phone's message sheet and the desktop message menu on a joined server use it for Mark unread. After it, the open chat does not read itself again until it opens again. The chat shows a NEW line above the first unread message. The line keeps the message that the host had as first unread when the chat opened, because the open chat then reads itself. Mobile external links enter through Expo Router's +native-intent and the links feature. Invitation and Mobile Connect tokens stay in a bounded memory store; navigation carries only a local request ID. Invitations wait through sign-in and show a verified host preview before an explicit join. Mobile Connect links require confirmation and cannot replace a signed-in account. The one exception is development builds: bun run dev:mobile opens the link with simctl openurl, and a __DEV__ build redeems it without confirmation only when its account service is a loopback or private-network http: origin. Release builds always use the confirmed flow. Plugin links open their validated public page in the in-app browser. Unsupported links show a safe fallback. On iOS the share extension sends a share by itself, without the app: it shows the agents, and a hidden web view runs share-sender.html, a build of the Team client (apps/mobile/src/features/share/extension/share-sender.ts) that connects, uploads the files and sends the message. The extension makes every account service request itself and refuses any other origin. It reads the servers in the app's order, the agents with their avatar pictures, the accent and the localized text that ShareExtensionSync writes to the App Group, and the session from a keychain group that only the app and the extension share. The app donates INSendMessageIntent for each agent it sends to, with its avatar, so the share sheet offers those agents first. With no data, the app lock on, a share that one message cannot hold, or a failed send that the user gives to the app, the extension opens openbot://expo-sharing as before. Android always uses that path. The share waits in native storage through sign-in. The share sheet moves its files into the app cache, the user chooses an agent, and that chat's composer gets the text and files, which follow the attachment rules above. The phone reads the response headers of a shared link; a link to an image of at most 10 MB is downloaded and comes as a file instead of the link. Permanent invitation metadata comes from the shared Team client; revocation stops new joins without removing existing members. iOS associates https://openbot.run/join and https://openbot.run/agents/* with run.openbot.mobile. Changes to associated domains require a new native app build and deployment of the website association file. Android continues to open HTTPS invitations in the browser, whose button opens openbot://join. Enabling verified Android App Links requires the release app-signing certificate's SHA-256 fingerprint, /.well-known/assetlinks.json, and a matching verified /join intent filter. No certificate fingerprint is stored in this repository yet.

Mobile Settings uses one native form sheet with stable detents and a nested Expo Router stack. Inner pages push within the sheet and use native back navigation; standalone forms remain fit-to-content sheets. Both reuse SheetScrollView. General, Profile, Connections and About use HeroUI typography and shared form fields; the appearance picker remains a native Expo UI control. Appearance is device-local in SecureStore; Uniwind, navigation and native form hosts share the selected light/dark/system theme. Profile changes and account-session management use the existing account endpoints, with profile writes conditional on the stored credential still matching the initiating session. The mobile client uses the same /v1/me/profile, /v1/me/avatar and /v1/mobile-auth/devices?includeDesktop=true endpoints as desktop; the last route's historical name does not restrict it to phones. Avatar uploads send validated binary bytes directly through Expo fetch, without constructing a React Native Blob from a typed array. Profile reads, writes and their UI-state application are serialized together. A read queued after an edit can apply a newer remote profile; a read before a later edit cannot overwrite that edit. Results apply only to the initiating login. Account-session queries are scoped to each login without including credentials in query keys, cancel when abandoned, and are removed on account transitions. An HTTP 401 clears only its initiating credential; transport failures retain the session for retry. Account profile writes enqueue an account-profile-changed invalidation in the existing signed account-to-Signal outbox before returning. Worker waitUntil delivers notifications outside the profile-save response path, with a five-second timeout per request and outbox retries. Signal forwards the optional frame only to authenticated sockets for that user; the frame contains no profile or credential. Desktop and mobile fetch the profile through the account API on notification, cold launch, and every 15 minutes while active. Membership writes enqueue an account-servers-changed invalidation the same way, addressed to the account rather than to a host: accepting an invitation, changing a membership, and registering a host this account did not have all queue one for the member whose server list changed. Republishing an existing host rotates its credential without changing a list, and queues nothing. Signal forwards it to every authenticated socket that account holds, and the frame names no server. That is how a server joined on one device reaches the other devices of the same account. Desktop window focus does not trigger an automatic account or directory check; a device holding no Signal socket finds the change at its next 15-minute check. Mobile uses one shared lifecycle subscription and a refresh controller per account endpoint. A foreground return checks absolute freshness: successful account and directory responses stay fresh for 15 minutes, and background time counts toward that deadline. Failed mobile checks retry after one minute while foregrounded. Concurrent requests share one Effect result through a Deferred; invalidations received during a request cause one follow-up after success. iOS inactive alone does not reset these deadlines. Stored mobile sessions become available before startup validation completes; network failures retain them, and validation results apply only to the initiating login. Explicit profile invalidations trigger an earlier check and are deferred while mobile is in the background. Signal readiness does not trigger a profile check; the account timer remains independent of transport recovery. Failed desktop automatic checks use the same interval. Each mobile server has one connection recovery owner. It reloads workspace reads on foreground return without changing a healthy server to connecting or disabling its actions. These reads reuse the existing WebRTC peer and do not request new account sessions or tickets. The first replacement starts immediately after actual connection loss. The required compatibility read has a three-second timeout to detect stale open channels. Delays apply after failed replacements and survive app switches. The peer owns Signal socket recovery, not full connection retries. Ordinary transport loss does not invalidate the account directory. Older Signal clients ignore this optional event. API and Signal both need the event support for push; the periodic check remains the fallback when Signal is unavailable. Unchanged responses do not publish a new identity. Desktop ignores reads overtaken by a local edit, sign-out or shutdown; its central-auth change event updates the renderer and host identity. The mobile drawer and Settings both display the session's name and resolve avatar paths against its account API.

Mobile hidden/pinned chat preferences are device-local, persisted in SecureStore per account API, account ID and host ID; they are not part of the shared sidebar layout or conversation read state. Account/device and logical remote sessions deliberately have no time-based expiration; a finite maximum Date deadline preserves existing numeric wire contracts. Pairing codes, connection tickets, and Signal resume credentials remain short-lived. Each logical remote session is bound to its originating account credential. An atomic D1 trigger ends that credential's remote sessions and queues disconnects on logout/device revoke; other phones stay connected. Legacy unbound sessions are ended account-wide on revocation because their originating credential is unknown. Mobile sign-out keeps the encrypted credential and local session until the account API confirms revocation. If the DELETE response fails, mobile validates that same token: a 401 confirms it is no longer active and completes sign-out immediately. A successful session check or an inconclusive network/service error keeps the credential for retry; a late result cannot clear a newer login. The desktop keeps remote connection errors visible in the workspace during retries. A successful connection clears the error. A new connection sequence or a return to online reloads the active workspace without remounting its providers, so failed refreshes retain cached data. Server switches still dispose the old scope; load generations and scope guards reject late responses.

Hosts opt in with the additive Signal hello multiplex flag; legacy desktops keep their one-peer limit so a second phone cannot replace an existing client's connection. Signal multiplexes connections by logical session, and the hidden desktop renderer owns a separate RTC peer for each device. Main keeps authentication, RPC caches, file staging and event streams separate per peer. Transport cleanup closes local access without revoking a replacement connection. Settings → Profile → Account sessions lists and revokes both desktop and mobile credentials. The account API's existing mobile-device routes expose these via includeDesktop=true; their default mobile-only behavior is unchanged. Responses contain session IDs and activity metadata, never tokens or token hashes, and all operations are scoped to the authenticated account. Cloudflare issues short ES256 connection tickets and stores the logical session. Signal issues a 10-minute resume token, so a short Signal update does not end an active WebRTC connection. Signal validates a trusted, non-expired resume token locally. After a Signal restart, the first use of a token checks the durable control plane once. An expired token also needs one durable check before Signal issues a replacement. Later reconnects use the in-memory trust cache. This is not a heartbeat. Session endings and access changes use a durable D1 outbox. Cloudflare sends each revocation to Signal immediately and retries failed deliveries from a scheduled task. This keeps reconnect validation local without losing revocations when Signal is temporarily unavailable.

Protocol v1 remains frozen for compatibility fixtures, but its public HTTP, WebSocket, and Cloudflare Tunnel transport is retired. The old public endpoints return host_update_required.

The desktop client starts each remote connection with GET /v1/compatibility. The response contains the host application version, the minimum and maximum Team API protocol versions, and host capabilities. The client selects the highest protocol in the shared range. Application SemVer does not select or reject a protocol.

The first released Team API protocol is 1. All later HTTP requests include OpenBot-Protocol-Version and OpenBot-App-Version. The event socket uses the openbot-team-v1 WebSocket subprotocol. A host without the compatibility endpoint is treated as an old host and is blocked. A request without the required protocol headers is treated as an old client and is blocked.

Each protocol has a frozen codec and adapter in packages/contracts/src/team-protocol. The v1 HTTP codec owns the fixed route registry and validates JSON requests and responses before the adapter converts current values. Uploads, downloads, and other binary routes use the same negotiated headers and error envelope. The host does not write current service or IPC values directly to the network. Breaking or semantic changes add a new protocol directory and registry entry. A released adapter keeps its original meaning.

From protocol 8, provider ids and auth kinds are open values. A new provider is a new value, not a new protocol. A client keeps an agent of a provider that it does not know, and shows it disabled with an update prompt. See Providers for the frozen lists that hide new providers from peers below 8.

Protocol 9 is the protocol 8 wire with capability lists of up to 256 names (v9.ts). Protocols 7 and 8 read at most 128 capabilities, and their lists are full. A capability added after them is in TEAM_PROTOCOL_V9_ONLY_CAPABILITIES (current.ts): a host leaves it out of the compatibility list of a peer below 9, and a client sends it only to a host that negotiated 9. On WebRTC, where no protocol header is sent, the marker long-capability-lists-v1 says that a peer reads 256. It is in the bootstrap list, so the first compatibility read of a protocol 9 host is already complete.

Capabilities describe additive behavior. The client sends its capability list when it sets the event scope. The host sends optional events only when the client declared the related capability. A missing capability disables only that feature. An unknown optional event is ignored. A malformed known event closes the connection as protocol_error because the client cannot safely apply it.

The optional agent-memories-v1 capability adds three POST routes. /v1/agent-memories/page returns up to 256 memories. /v1/agent-memories/selection returns up to 256 selection records, the agent ID, and essential-memory byte usage. Both use ascending memory IDs and an after cursor (null for the first page). Clients validate owner and cursor order. Edits do not move IDs between pages. Reads across several pages are not an atomic snapshot; the next refresh sees concurrent changes. /v1/agent-memories/inclusion changes up to 25 selections with expected revisions and returns an empty acknowledgement; the client then reads selection pages again. Each route requires authentication and a visible agent. Selection writes use the same member access as existing memory writes. User choices override automatic selection.

The released full-list GET route, memory shapes, and 2 MiB frame limit stay unchanged. The memory routes leave out the site of a memory (see browser). A new client connected to an older host keeps text editing and hides selection controls. An older client can edit text on a new host without changing inclusion. Storage choices remain 64, 128, 256, and 512. Lowering the setting never deletes existing entries.

Only essential memories enter startup instructions, in a block limited to 8,192 UTF-8 bytes. Claude gets that block with a user turn instead: the full block in the first turn and after a compaction, and only the change after a memory edit. A memory change then keeps the cached system prompt and does not restart the Claude query. A profile change still restarts it. Saved memories stay on the host in SQLite. Agents can use bounded local search and ID-paged listing to recall other entries. FTS5 uses unicode61 and BM25. Queries match any literal term. unicode61 has no stemming, so a term of 6 or more characters also matches as a prefix of its first characters, all but the last 3 and at least 4: "closing" also finds "closes". Equal scores use the newest update, then the memory ID. Search returns at most 10 entries, and maintenance pages return at most 25. Both responses are limited to 8,192 UTF-8 bytes. The search index stays local. Essential prompt text and requested tool results go to the active provider through the existing secret-redaction path.

Selection metadata is separate from the released memory shape. Migration 32 preserves memory text, IDs, and dates, and selects recent manual entries before recent automatic entries. The same serializer checks the complete prompt block during migration, mutation, and prompt generation. Automatic text and selection changes stay pending until the turn completes successfully. Commit checks ownership, revisions, user choices, and capacity again. Agent tools cannot change or delete a user-controlled selection. The user can release that control without changing its inclusion.

The optional message-delete-v1 capability (protocol 9 only) adds three POST routes that delete one message and return an empty acknowledgement: /v1/messages/delete (agentId, messageId), /v1/messages/delete-task (agentId, taskId, messageId) and /v1/channels/messages/delete (channelId, messageId). The host always advertises it to a protocol 9 peer, because every host that has the routes can delete. src/main/team-api/route-message-delete.ts owns the authorization. In an agent chat, the agent must be visible to the member (else 404), and the member needs chat on the agent to delete their own messages; manage lets them delete any message. In a channel, each member deletes their own messages, and owners and admins delete any message. A host without channels answers the channel route with 400 channelsUnsupported, as it answers each channel route. A refusal from the service gets 403 (not the member's message), 404 (no shown message with that ID) or 409 (the message is in a running turn), with the host's sentence. A delete removes the message from the host's history, search and attachments; the provider session can still remember it. The host sends its usual change events (conversation-invalidated and channels-changed), so other members refresh.

The optional chat-history-v1 capability (protocol 9 only) adds four POST routes with an empty acknowledgement: /v1/agent-history/delete (agentId), /v1/channels/context/clear, /v1/channels/context/compact and /v1/channels/history/delete (each channelId). They do on a joined server what "Delete history" of an agent chat, and "New conversation", "Compact now" and "Delete history" of a channel do on this computer. The host always advertises it to a protocol 9 peer. src/main/team-api/route-chat-history.ts owns the authorization. An agent history delete needs a visible agent (else 404) and manage on it (else 403). A member who sees a channel starts a new conversation or compacts it; only owners and admins delete its history (else 403 membersCannotDeleteChannelHistory). A host without channels answers the channel routes with 400 channelsUnsupported. Work that still runs or waits, an archived channel, or a compaction that no member session can do, gets 409 with the host's sentence.

The optional agent-import-v2 capability (protocol 9 only) sends a Grok Bot export of any size in parts: /v1/agent-import/v2/open, /v1/agent-import/v2/chunk (raw body, with upload and offset in the query) and /v1/agent-import/v2/stage. A host that advertises it also serves agent-import-v1, whose apply and discard routes take the staged token. See Agent import from a joined server.

The optional agent-welcome-v1 capability (protocol 9 only) changes no route shape. A client that sends it says that the initialMessage of POST /v1/agents is a setup message for the new agent. The host stores that message with welcome: true in its mailbox JSON and the item type agent_welcome. No conversation page, live snapshot, read state, search result, queue, runtime work item or preview shows it, for any member. While it waits or runs, the host's local queue snapshot has welcome: true, so the host's own window shows that the agent works and not the empty chat. GET /v1/agents/:id/queue keeps its released keys and does not send the flag: a remote member sees the empty chat until the welcome turn starts. The agent answers it, and that answer shows as usual. A client sends a setup message only to a host that advertises the capability; an older host still gets the released role text, which stays a visible user message there.

The optional message-pins-v1 capability (protocol 9 only) adds POST /v1/message-pins/list (agentId) and POST /v1/message-pins/set (agentId, taskId as a string or null for the agent's own chat, messageId, pinned). The list holds the pins of all chats of the agent (its own chat and its side threads, archived ones too), at most 200, the newest pin first. Each pin has its side thread and title, the first 1,000 characters of its message, and who pinned it: a person (null for the user of the host computer) or the agent. The agent pins with its pin_message tool. Both routes answer the list after the request. A member needs the chat level on the agent. A hidden or unknown agent gets 404, a message that is not in the chat gets 404, and a pin over the limit of 50 for one chat gets 409. All members see the same pins. The host sends message-pins-changed with the agent id after each change, and the client reads the list again. A client opens a pin of another chat in the tab of that chat, or in the side panel when the thread is open there. When the thread view does not hold the message, POST /v1/message-pins/read-thread (agentId, taskId, messageId, limit 1 to 100) reads one part of the thread around it, oldest first, in the agent-tasks-v1 conversation shape, with hasOlder and hasNewer. A message that the thread does not hold gives no messages. While the view holds a part with hasNewer, it skips live deltas and read marks, and shows the way to the latest message; a send reads the latest messages first. A host without the capability gets no part read: the client opens the newest messages. Channels have no pins. src/main/team-api/route-message-pins.ts owns the routes.

The optional agent-waits-v1 capability (protocol 9 only) adds POST /v1/agent-waits/list (agentId) and POST /v1/agent-waits/cancel (agentId, waitId). They show the open waits of an agent in the composer of a joined server, with Cancel. src/main/team-api/route-agent-waits.ts owns the authorization. A hidden or missing agent gets 404; a cancel needs chat on the agent (else 403). The host cuts each request text to 2,000 characters. A wait that is already closed gets 409 with the host's sentence. The host sends no event when a wait changes, so a client reads the list again after a cancel and when a turn of the agent starts or ends.

The optional pending-approval-v1 capability (protocol 9 only) adds POST /v1/agent-approvals/read (agentId, requestId). It returns { approval }, the whole copy of one approval that the agent waits on, as the approval event carries it. A runtime snapshot cuts each text of an approval to 240 characters and sets truncated, so a client that did not get the event (a phone that connects later, a desktop after a restart) reads the whole copy here before it offers Allow. Each client keeps the whole copy when the next snapshot comes (keepWholeApprovals in packages/team-client/src/runtime-attention.ts), and shows a cut copy with Deny only. src/main/team-api/route-pending-approval.ts owns the authorization: a hidden or missing agent gets 404, a member needs chat on the agent (else 403), and a request that no longer waits, or a request of another agent, gets 409.

The optional agent-queue-task-v1 capability (protocol 9 only) adds POST /v1/agent-tasks/from-queued-message (agentId, deliveryId, no other keys). It is "Run as a new thread" on a message that waits in the queue of the agent's own chat. The host advertises it only when it has agent-tasks-v1, and the answer is the frozen task of an agent-tasks-v1 start. src/main/team-api/route-agent-queue-task.ts owns the authorization: a visible agent (else 404) and chat on it (else 403). A message that left the queue, or a message of a channel, gets 409 with the host's sentence. The host then sends agent-tasks-changed and the queue change.

The optional background-processes-v1 capability (protocol 9 only) adds POST /v1/admin/background-processes/list and POST /v1/admin/background-processes/stop (id). They show, in Server Settings > Storage, the programs that agents left running on the host, such as a dev server, with Stop. Only owners and admins can use them (else 403). The host redacts each command line before it leaves src/main/background-processes.ts, cuts it to 500 characters, sends at most 200 programs and 64 ports each, and never sends the environment of a program. processes is null on a Windows host, which cannot find these programs. Stop signals only a program that is in the list at that time; an unknown id does nothing. This computer shows its own programs in the same place.

The optional provider-use-v1 capability (protocol 9 only) adds POST /v1/admin/provider-use/list and POST /v1/admin/provider-use/set (provider, on). They are the on/off switch of the host's built-in providers in Server Settings > Providers. Only owners and admins can use them (else 403). Both routes answer off, the ids of the providers that are off after the call, at most 64. The released status routes leave out off, so a client reads it from this list. The host refuses the custom agent provider (acp) with 400, and a provider that an agent uses or that is busy with 409 and the host's message. Without the capability a client hides the switch for that host.

The optional routines-once-v1 capability (protocol 9 only) adds POST /v1/routines-once/list (owner), POST /v1/routines-once/create (owner and the routine, with a once schedule) and POST /v1/routines-once/update (owner, id and the changed fields). owner is { kind: "agent" | "channel", id }. They carry one-time routines (the once schedule), which the frozen routine, channel, event and run-place codecs cannot carry. Those routes still hide and refuse once routines. The list gives only the once routines of the owner, so a client adds them to the released list. The update changes any schedule routine of the owner, so a client with the capability sends each routine update of an agent or a channel there. An agent routine carries runIn on these routes. Run, test and delete stay on the released routes, which take the id of a once routine. src/main/team-api/route-routines-once.ts owns the authorization, which is that of the released routes: a visible agent (else 404), and manage on it for a save (else 403); a channel that the member sees, on a host with channel-chats-v1. An unknown routine or a date that has passed gets 400. Without the capability the routes answer 400, and a client hides the once option for that host.

The optional routine-run-command-v1 capability (protocol 9 only) adds POST /v1/admin/routines/run-command (agentId, routineId). It answers command, the command that runs that routine from a script on the host, as Routines > Copy run command gives it on this computer. Only owners and admins can use it (else 403): the same people who turn on Local scripts for an agent. A member gets no shell access. The command reads the address and the token of the host's automation server from their files when it runs, so the token is not in the answer, and the command works only in a shell on the host. The host refuses an agent without Local scripts and a routine that is gone with 409 and the host's message. src/main/team-api/route-routine-run-command.ts owns it. Without the capability a client hides Copy run command for that host.

The optional claude-user-setup-v1 capability (protocol 9 only) adds POST /v1/admin/host/claude-user-setup and POST /v1/admin/host/claude-user-setup/update (enabled). They are "Use my Claude Code setup" of the host on the Claude page of Server Settings > Providers. On, the Claude agents of the server load the Claude Code setup of the host's user (~/.claude and ~/.agents/skills on the host). Only owners and admins can use them (else 403). The routes carry only the switch, never a file of that setup. A change applies to the Claude processes that start after it, and the host sends the provider status event, so open settings pages read it again. src/main/team-api/route-claude-user-setup.ts owns them. Without the capability a client hides the switch for that host.

The optional provider-account-models-v1 capability (protocol 9 only) adds POST /v1/admin/providers/accounts/models (empty request). It gives, for each provider, the accounts after the first that serve models the provider's own sign-in does not, with those models (at most 1,000 for each account; no credential, usage or agent). provider-accounts-v1 carries no model list, so a client reads both and puts the models on their account: the model picker of that account then shows them, as on the host. Only owners and admins can use it (else 403). A client that fails to read it keeps the accounts without these models. src/main/team-api/route-provider-accounts.ts owns it.

The optional claude-compact-window-v1 capability (protocol 9 only) adds POST /v1/admin/host/claude-compact-window and POST /v1/admin/host/claude-compact-window/update (tokens: a whole number from 100,000 to 1,000,000, or null for Claude Code's own size). They are "Compact chats at" of the host on the Claude page of Server Settings > Providers. Only owners and admins can use them (else 403); the wire codec refuses a size outside the range with 400. A change applies to the Claude processes that start after it, and the host sends the provider status event, so open settings pages read it again. src/main/team-api/route-claude-compact-window.ts owns them. Without the capability a client hides the row for that host.

The optional agent-thread-queue-v1 capability (protocol 9 only) adds POST /v1/agent-thread-queue/list (agentId) and POST /v1/agent-thread-queue/reorder (agentId, taskId, deliveryIds). The list gives, in full, the waiting messages of each open side thread of the agent that has one, as the queue panel of a thread shows them on this computer. The released agent-tasks-v1 list gives only the start of each message. The reorder route changes the order of the waiting messages of one thread. Cancel and Send now use the released queue routes of the agent, by delivery id. A member who can send the agent a message can use them: a visible agent (else 404) and chat on it (else 403). The host sends no preview URL of a file, because it is an address on the host. A stale order and an order with channel work get 409 and the host's message. A change sends the released agent-tasks-changed event. That event has no full queue, so a client reads the list again when the waiting messages of a thread change. src/main/team-api/route-agent-thread-queue.ts owns them. Without the capability a client shows the waiting messages of a thread without their actions.

The optional agent-usage-limit-v1 capability (protocol 9 only) adds POST /v1/agent-usage-limit/list (empty body) and POST /v1/agent-usage-limit/switch (agentId, provider, model, reasoningEffort, optional providerAccountId). The released runtime-snapshot event does not carry the usage limits. The list gives the agents whose queued message a provider usage limit holds, with the reset time, and the models and accounts that a usage limit or a provider error holds now. It leaves out a hidden agent, an agent that the member cannot see, and a model of a provider that the peer's protocol does not know. The switch route moves the agent to another model and sends the held message there, as the usage-limit card on the host's computer does. It needs a visible agent (else 404) and manage on it (else 403). A move to a named provider account that is not the agent's account needs an owner or admin (else 403). A change of a hold sends the released runtime-snapshot event, and the client then reads the list again; the desktop main process adds the list to the snapshot that it forwards. src/main/team-api/route-agent-usage-limit.ts owns them. Without the capability a client does not show the usage-limit card for that host. Only an owner or admin sees "Next time, switch without asking" on the card, because the backup models use agent-backup-models-v1.

The optional agent-pending-runtime-v1 capability (protocol 9 only) adds POST /v1/agent-pending-runtime/update (agentId, and one or more of provider, model, reasoningEffort and providerAccountId, where default is the provider's own sign-in). It changes the runtime as the pickers on the host's computer do: while the agent works, the host keeps the change as pendingRuntime and applies it before the next turn, where the released PATCH /v1/agents/:agentId refuses a provider change. It needs a visible agent (else 404) and manage on it (else 403); another account needs an owner or admin (else 403), except the new provider's own sign-in on a provider change. For a peer with the capability, the host also adds pendingRuntime to each agent of GET /v1/agents and of the bots-changed event, so the "After this turn" badge shows on the client. src/main/team-api/route-agent-pending-runtime.ts owns the route. The desktop with a joined server and the web client send a runtime change alone on it when the host has the capability; mobile still uses the released route. Without the capability a provider change during work is refused as before.

The optional hermes-import-v1 capability (protocol 9 only) adds POST /v1/hermes-import/read (empty body), POST /v1/hermes-import/apply (token, profiles with key and allow) and POST /v1/hermes-import/discard (token). They import the Hermes Agent profiles of the Hermes folder of the host, as Import > Hermes does on this computer. The host reads only the folder that Hermes uses on the host: the client cannot name a folder (the wire codec drops a folder field). The read answers the profiles under a token, or null when the host has no Hermes profiles; a new read closes the earlier one. The answer shows the home folder of the host as ~ and carries no value from .env or auth.json. Only owners and admins can use the routes (else 403), because an import adds agents, skills and MCP servers to the whole server. A stale token gets 409 and the host's message. src/main/team-api/route-hermes-import.ts owns them. Without the capability a client shows no Hermes source for that host. The folder dialog stays on this computer: it shows the files of this computer, and the host cannot read them; to import Hermes from this computer to a server, export a .zip file.

The optional provider-detection-v1 capability (protocol 9 only) adds five routes under /v1/admin/provider-detection/: settings (empty body), settings/update (enabled, addresses, folders, hiddenIds), scan-model-servers (empty body), scan-agents (empty body) and discover-models (baseUrl, apiKey, headers, optional savedProviderId). They find the local model servers and the ACP agent commands of the host, as Server Settings > Providers does on this computer. The host makes each request from the host. A scan sends no key. A model list request can carry a key and headers towards the host; for a saved endpoint, the host uses its stored values only for the same origin. No answer carries a key or a header value. Only owners and admins can use the routes (else 403), because a model list request makes the host connect to an address that the client names, as a custom endpoint does. A refused entry gets 400. The client adds a found row with the released custom endpoint and custom agent routes, so it shows the rows only when it also manages those on the host. src/main/team-api/route-provider-detection.ts owns them. Without the capability a client shows no found rows for that host.

The optional channel-picture-v1 capability (protocol 9 only) adds icon and imageUrl to the channel side routes, icon to a save draft, and /v1/channels/image?channelId=<id>: GET with &v=<version> answers the image bytes of the current version (else 404), PUT with a raw PNG, JPEG, GIF or WebP body of at most 512 KiB sets the image, and DELETE removes it. PUT and DELETE answer the channel. Each request needs channel-chats-v1 and a channel that the member sees (else 404); owners and admins see all channels. src/main/team-api/route-channels.ts owns the route. The host adds and reads these keys only for a request that advertises the capability, so released peers see the same channels-v1 wire. The host's imageUrl is an openbot-avatar://channel/<id>?v=&type= URL. The desktop changes it to openbot-remote-avatar://<server>/channel/<id>?v=, the scheme of remote agent avatars, so the CSP does not change; the main process reads that URL with GET /v1/channels/image. The web client reads the bytes into a data URL. Over WebRTC, a PUT body and a GET answer go as file transfers, as for agent avatars. Without the capability a client hides the picture control for that host, and the channel shows its member faces.

The optional channel-browser-v1 capability (protocol 9 only) adds browser to the channel side routes and to a save draft (#4135): { kind: "members" }, { kind: "none" } or { kind: "member", agentId }. Absent on a draft keeps the stored value; absent on a record is every member. A present value that a client cannot read reads as none. The host adds and reads the key only for a request that advertises the capability, so released peers see the same channels-v1 wire, and a save from such a peer keeps the setting. Without the capability a client hides the Browser select for that host.

Team API failures use a JSON error envelope with error and a stable code. Compatibility codes are client_update_required, host_update_required, and protocol_error. Authentication and network failures are projected to authentication_required and network_unavailable in the desktop connection state. A confirmed compatibility or protocol error stops data-plane requests and automatic reconnect until the user selects Retry, restarts, or updates.

Protocol support has no fixed time or release limit. Removal is an exceptional architecture decision. It requires a security issue, data-loss risk, semantics that cannot be kept, or technical cost that cannot be contained in an adapter. The decision must also include a changelog entry, update instructions, tests for old-client/new-host and new-client/old-host directions, and clear blocking UI.