Servers, storage, and billing¶
Storage and files¶
Three surfaces show what a host keeps on disk: Server Settings > Storage (scope host), Agent
settings > Files (scope agent) and the chat Files panel (scope conversation). They share
src/renderer/src/features/files/storage-usage.ts, which names the server explicitly, because Server
Settings can be open for a server that is not the selected one.
src/backend/storage-usage.ts owns the scan and has no Electron imports. Sent and generated files
come from the mailbox state in memory, with their chat from paged read-only queries in
database/storage-usage-queries.ts; a file's status comes from lstat, not from
resolveAttachment, which hashes the file. Workspaces, shared files, downloads, caches, logs and
runtimes are measured by a bounded walk: lstat, no symlinks followed, a stop at 100,000 entries,
and a yield between pages, because DatabaseSync and the walk run on the main thread. A result is
cached for 60 seconds per scope, a scan in progress is shared, and a delete, clear or agent delete
drops the cache. Lists stop at STORAGE_LIMITS and set truncated; the breakdown still counts
every byte.
A delete does not change the schema. MailboxStore.deleteStoredFile sets deletedAt on the stored
attachment, persists, and queues the file path, not the transfer folder, in the file-deletion outbox.
It keeps a path that another live record uses, and it deletes only a real path under the Transfers
folder. resolveAttachment then returns null, so a file card shows "File not found" and a generated image
shows its unavailable state. An older app ignores the field. Clear removes the remote-server caches and the logs/remote,
logs/update, logs/providers, logs/agents and logs/main files; it does not enter logs/remote/transfers. Runtimes are read-only.
storage:* IPC reaches the local service or a joined server. The optional storage-v1 capability
exposes POST /v1/storage/usage, /v1/storage/delete-file and /v1/storage/clear with the frozen
codec in team-protocol/storage-v1.ts. The host advertises it only when its storage service
exists. Every member reads usage; delete and clear need an owner or admin (requireAdmin), and the
renderer hides those controls from a member. The wire carries no absolute paths, and workspace and
download files travel only as category totals. A host without the capability reads as null, and the
surface asks for an update; a change is refused before any request.
The optional storage-disk-v1 capability adds POST /v1/storage/disk (team-protocol/storage-disk-v1.ts).
It answers the free and total bytes of the disk that holds the host data, from one statfs call
with no scan, so it is a passive route. Every member can read it. The sidebar storage card
(features/sidebar/storage-card.ts) reads it for an owner or admin of the selected server, at
start, every five minutes, and when Server settings closes. Under 10% free (at most 5 GB) the card
says "Storage almost full" and can be closed until the next level; under 2% free (at most 1 GB) it
is red and cannot be closed. "Free up space" opens Server settings › Storage. A host without the
capability shows no card.
The optional machine-resources-v1 capability (team-protocol/machine-resources-v1.ts) adds the
machine part at the top of Server settings › Storage, for an owner or admin only. POST
/v1/admin/host/machine/metrics answers the disk, memory and CPU use of the machine that runs the
host (src/main/machine-metrics.ts), as the server can use it. The disk is the one that holds the
data folder, counted as df counts it: space that ext4 keeps for root is neither used nor free. In
a Docker container that is the disk of the Docker host, which all its containers share; Docker sets
no disk limit per container. Memory counts what programs can still use: MemAvailable and the
cgroup limit on Linux, Activity Monitor's "Memory Used" from vm_stat on macOS, and free memory on
Windows. CPU is the use since the reading before. When main's cgroup has a CPU limit (cpu.max,
such as docker run --cpus), it is the cgroup's usage_usec against that limit, and the core count
is the limit; otherwise it is the use of all cores of the machine. The panel reads it each minute
while it is open; the read routes are passive. .../machine/alerts and .../alerts/update read and
set one level per metric (50–99%) and the owner or admin who gets the alert, in
openbot-machine-alerts-v1.json (src/main/machine-alerts.ts). With no member set, or when that
member is no longer an admin, the owner gets it. The host checks the machine once a minute. A metric
at or above its level alerts once, and again each day while it stays there; CPU must stay above for
five readings. The alert ends when the use goes 5 points below the level. The host sends the
machine-alert event only to the event sockets of that member, and again to each device of theirs
that connects while the alert is in force; the desktop shows each alert id once, as a notification.
When the host is a desktop app and its owner gets the alert, that app shows it directly. For an
owner or admin of the selected server, the sidebar machine card (features/sidebar/machine-card.ts)
reads the use and the levels at start, every five minutes, and when Server settings closes. It shows
the metric furthest above its level in place of the storage card, which then does not show; far
above the level (95%, or 10 points above a higher level) it is red and cannot be closed. A closed card
stays closed for that metric and level until the use goes below every level.
Hosted sites per server¶
A hosted site belongs to the server that published it (hosted_sites.server_id, D1 0024). The user
stays the accountable owner, for abuse reports, blocks and account deletion. The desktop sends
OpenBot-Host-Id and OpenBot-Host-Token (the machine token of /v2/remote/hosts/register) on each
/v1/sites request when it is a registered server. The Worker checks the hash and that the host owner
is the request user; a wrong token is refused with 401, never counted as unlinked. The active-site
limit comes from the server's plan (siteLimitForPlan: none 1, Starter 3, Standard 10, Pro 50). A
request with no server headers creates only into the account's unlinked bucket (limit 1,
server_id IS NULL); with no ?scope=unlinked, it still lists and deletes every site of the account,
the released meaning of /v1/sites. Replace and delete in a server scope refuse a site of another
bucket with 409 site_other_server. A downgrade deletes nothing: a server above its limit cannot
create a site, but it can replace one, and the extra sites end at their expiry. Removing a server moves its sites to the owner's unlinked bucket, so the owner's desktop can still delete them. A registered server updates a site that it published before registration in the unlinked scope.
hostedSites.list and hostedSites.delete IPC are server-scoped; publish and replace stay local. The
optional hosted-sites-v1 capability exposes POST /v1/hosted-sites/list for every member and
POST /v1/hosted-sites/delete for an owner or admin, with the frozen codec in
team-protocol/hosted-sites-v1.ts. The host answers with its own account and credential, and only with the server's own sites: the owner's unlinked sites never reach a member, and the Team API has no fallback to delete one. Sites are
managed in Server settings > Sites, on the desktop and in the browser.
Leaving a server¶
Leaving a joined server has the same effect as an admin removal: the membership and every session of
it end, on all of the member's devices. On WebRTC, the account service revokes the membership, as it
does for the owner's removal. On HTTP, member-leave-v1 adds a bodyless POST /v1/team/leave, which
runs the steps of the admin DELETE /v1/team/members/:id for the caller; the owner is refused. A host
without the capability answers 404, so the client only logs out: that token stops working, and the
membership stays for an admin to remove. Either way the client removes the server, also when the host
does not answer.
New chat¶
context-reset-v1 adds POST /v1/agent-context/clear with { agentId }. Any member who can see the
agent can send it. The host writes a system message with itemType: "context-reset" to the agent's own
thread and ends that thread's provider sessions. The thread, its messages and the agent do not change.
The next provider session gets a handoff of only the messages after the last marker. The host refuses
the request while a turn runs or a message waits in the queue. A client without the capability shows
the marker as its text. Channel execution threads are not reset.
Workspace folders¶
workspace-directory-v1 adds POST /v1/workspace-directory with { agentId, path }. Any member who
can see the agent can send it, as with GET /v1/workspace-files. The host answers the folder entries
(name, path, kind, size, modification time), at most 500, directories first. The path must stay inside
the agent workspace; a symbolic link that leaves it is not listed. A missing path answers 404 with a
message that names the path and the workspace. A client without the capability shows the file preview
error. The desktop lists a local folder without the containment when the agent has no workspace limit.
Conversation files¶
conversation-file-search-v1 adds POST /v1/conversation-files/search with { query, cursor, limit? }.
Any member can send it. The host answers the files of agent chat messages whose name contains the
query, newest first, with the agent, the message, the time and the attachment summary. An empty query
lists the newest files. A page has at most 100 results (50 when limit is absent), and nextCursor
gets the next page. Channel threads and the files of an agent hidden from the client are not listed.
The capability is advertised only to protocol 7 clients. An older host answers 404, so a client
sends the request only when the host advertises the capability.
Side questions¶
side-question-v1 adds POST /v1/side-questions/ask with { agentId, question, earlier } and answers
{ answer }. Any member who can see the agent can send it. The host answers with the agent's provider
and model, from the agent's profile and the end of its chat, with no tools. The question and the
answer do not go into the chat, and the host keeps nothing, so earlier carries at most 20 earlier
questions and answers of the side question. An earlier answer has at most 32,000 characters. A
failed generation, such as a busy provider or a usage limit, answers 409 with a sentence for the
member. The capability is advertised only to protocol 7
clients. The desktop on a joined server and the web client show the side question only when the host
advertises it.
Agent import from a joined server¶
agent-import-v1 lets any member, not only an owner or admin, import a Grok Bot export into the host.
POST /v1/agent-import/stage takes the raw .zip (at most 100 MB) and answers the preview without
avatars, so the preview stays under the 2 MB WebRTC frame limit. AgentImportService.stageUpload writes
the file to agent-import-uploads/ in the host's user data and keeps it under a token that only the
caller's member id can apply or discard. One member keeps one export, the host keeps four uploads at
most, and an upload nobody applies is released after 30 minutes; the folder is cleared on the first
upload after a restart. POST /v1/agent-import/apply takes { token, keys, channelKeys, timezone } and
answers the new agents by id and name, which the client reads with the agent list. Channels are created
with the member as the actor. A member never revises a skill already in the host library: the agent
gets the existing skill and the result warns. POST /v1/agent-import/discard releases the token. On
desktop, main opens the dialog, reads the file and sends it (agent-import:choose is server-scoped);
the web client uses the browser chooser and ships the export skill in its bundle.
Instance transfer with a joined server¶
instance-transfer-v1 moves a server's agents and server settings to or from an OpenBot transfer file
(see Instance transfer). Only an owner or admin can use it: the file holds
every agent's memories and files, and an import adds server settings. It is protocol 7 only. Every file
that crosses the connection is at most 100 MB, the size of one WebRTC file transfer; a computer's own
server keeps the 2 GB limit. POST /v1/instance-transfer/summary lists the agents with their counts and
no avatar address. POST /v1/instance-transfer/export takes { agentIds, includeFiles, includeSettings },
writes the file to instance-transfer-uploads/ in the host's user data, sends it as an attachment and
removes it. POST /v1/instance-transfer/stage takes the raw .zip and answers the preview without
avatars, under a token that only the caller can apply or discard; an upload nobody applies is removed
after 30 minutes. POST /v1/instance-transfer/apply takes { token, keys, includeSettings, timezone }
and answers the new agents by id and name, which the client reads with the agent list.
POST /v1/instance-transfer/discard releases the token. On desktop, main opens both dialogs and moves
the file (instance-transfer:* is server-scoped). The web client does not show the Transfer section: its
downloads are limited to 10 MB.
After a restart, the first upload or export removes old files from instance-transfer-uploads/,
including unfinished .part files, before it writes a new file. Concurrent uploads and exports wait
for this cleanup. If cleanup fails, the operation fails; the next upload or export retries it.
Later operations keep the current process's files. Local files selected by the user are kept.
Routine flows¶
routine-flows-v1 serves the routine canvas (see Routine flows) to a
joined desktop and to the web client. Its routes are POST /v1/routine-flows/canvas,
/positions/save, /positions/remove, /links/connect, /links/disconnect and /links/update,
with the input in the body. Any member who can see the agents can read and change a canvas, as with
the agent routine routes; it is not an admin capability. A member sees a routine only when every
agent it touches is visible, and the host answers a hidden or unknown agent, routine or link with
404. A webhook trigger's URL goes only to an owner or admin; a member reads it as null. Step input,
output and error cross with at most 4,000 characters each, and the host empties the step texts of the
routines with the oldest runs first when a canvas would pass three quarters of one WebRTC frame. The
optional event routine-flows-changed { agentId } names the canvas that changed; the host sends it
only to a connection with the capability and never for a hidden agent. Webhook config, secret
rotation and webhook test runs stay on events-v1. A client without the capability shows a notice
in the Routines view; the web client shows the view switch only when the host serves it.
Workflows¶
workflows-v1 serves the workflow canvas (see Workflows) to a joined desktop,
to the web client and to the mobile app. Its routes are POST /v1/workflows/<name> with the input in the body:
list, get, create, rename, delete, save-graph, versions, version-graph,
restore-version, start-run, run-step, stop-step, stop-run, retry-step, decide, runs,
run and rotate-webhook-secret. version-graph gives the graph of one version for the preview,
with the same filter as get. The contract header in team-protocol/workflows-v1.ts records the grant:
- A member sees a workflow only when every agent of its agent steps is visible and no model step names a provider that the member's protocol hides (also for the owner on a remote client). An owner or admin also sees a workflow that names a deleted agent, so they can fix or delete it. The host answers any other workflow, and its runs, steps and versions, with 404.
- A run, a single step, a stop, a retry or a decision needs the chat level for each named agent. A retry, a stop and a decision check the agents of the current graph and of the run's graph.
- A change, a rename, a delete, a restore or a new webhook secret needs the manage level for each named agent, before and after the change (403). A restore checks the graph of the version. The desktop shows the canvas read-only when the member does not manage each agent of the graph.
- A workflow that names no agent, such as one with only model and condition steps, needs no agent level. Each member who sees it can change and run it on the host's providers. This is a product decision: model and condition steps are open to each member who can see the workflow.
- Only an owner or admin can add an output that posts to a messaging place, or change its place
(403
adminRequired); a member can keep and run one that is there. - A step of an agent that is hidden now does not cross: the generic hidden-agent filter of the answer drops it.
- A webhook trigger's URL goes only to an owner or admin; a member reads it as null. Only an owner or admin can make a new secret.
- Step output and error cross with at most 32,000 characters each, and a run sends its newest
2,000 steps. When a page of runs would not fit one WebRTC frame, the host empties the step texts
of the oldest runs first, then answers 413
tooLargeToSend.
Node kinds, port types, statuses, run sources, trigger types and version kinds cross as open names,
so a newer host can add one. A client shows a workflow with a node kind or a port type that it does
not know as read-only (needsNewerApp), and keeps the node when it saves.
The optional event workflows-changed { workflowId } names the workflow that changed, so the
client reads it again. A client without the capability shows serverTooOld on the Workflows page.
The desktop app and the mobile app decode the answers with the same module,
packages/team-client/src/workflow-decoding.ts. The host sends workflows-changed for each run
and step change, so the mobile list, run and drawer read again on the event and do not poll.
The mobile app edits a workflow as a list of steps, not as a canvas
(apps/mobile/src/features/workflows/model/workflow-outline.ts). A graph with one trigger, at most
one condition, and one path in and out of each step, except the step where the two condition paths
join, becomes an outline. A change to the outline writes the edges of its steps again, with the
edge ids of the desktop canvas. A new step goes to the right of the step before it, and the steps
after it move right by its width. Other nodes, the notes, the shapes and the drawings keep their
places. Any other graph keeps its edges: the phone changes only the settings of its steps. A save
that a newer revision refused shows the conflict, and the person keeps the draft or loads the saved
workflow. The pins of the mobile drawer stay on the phone, in the workspace preferences of the
account and server (pinnedWorkflows); the host does not know them.
History extent¶
history-extent-v1 lets the chat's scroll rail draw the history that is not loaded on a joined server.
It adds olderCount and oldestAt (at most 64 characters) beside the frozen keys of pageInfo on
GET /v1/agents/:id/conversation-page and GET /v1/direct/conversations/:id/page, and at the top
level of the POST /v1/channels/read page. The host adds them only on protocol 7 and only when the
request advertises the capability, so a released client reads the same bytes as before. A host
without the capability never sends them, so a client keeps them whenever they are present, and a
malformed value fails closed. The WebRTC host peer encodes the local response again with the same
option, so the fields reach the web client and a joined desktop through the data channel too.
UI blocks¶
ui-blocks-v1 carries the interactive block of an ask_ui question prompt (see
packages/contracts/src/ui-blocks.ts). It adds uiBlock beside the frozen keys of a message in
GET /v1/agents/:id/conversation and conversation-page (messages and references), and in the
conversation and conversation-page events. The host adds it only on protocol 7 and only for a
connection that advertises the capability. A released client reads the message as its fallback: a
question prompt that it answers through the released prompt route, and the host reads the block's
answer from those answers. A host without the capability never sends uiBlock, so a client keeps it
whenever it is present, and a block that the contract refuses fails closed. A privileged action (a
danger button, a button with confirm, or the primary button of a danger confirm), and an answer
in words on a block that has one, is for the owner and admins only; the host answers a member with
403. A secret form value goes to the agent in the tool result. The stored block and the prompt
resolution leave it out. The provider transcript of the tool call can keep it, and the agent can
repeat it in a reply or a thought, which the conversation stores as any other message.
agent-task-ui-blocks-v1 (team-protocol/agent-task-ui-blocks-v1.ts) adds the same uiBlock, with
the same normalization and wire budget, to the task conversations of agent-tasks-v1: the response
of POST /v1/agent-tasks/read and the snapshot of the agent-task-conversation event. A client
then shows a question in a side thread as its form, such as the API key card. The host adds it only
for a connection that advertises the capability; the frozen agent-tasks-v1 decoder drops it for
every other client. The HTTP and WebRTC clients accept it whenever it is present, and the WebRTC host
peer encodes the local response and the events again with the same option.
Subagents¶
subagents-v1 carries the subagents that an agent started in one turn (see
packages/contracts/src/ipc-conversation-subagents.ts and team-protocol/subagents-v1.ts). It adds
subagents beside the frozen keys of a message in GET /v1/agents/:id/conversation and
conversation-page (messages and references), and in the conversation and conversation-page
events. The host adds it only on protocol 7 and only for a connection that advertises the capability.
A released client reads the message as its fallback: a subagents message whose text lists the
subagents by title. A host without the capability never sends subagents, so a client keeps it
whenever it is present, and a list that the contract refuses fails closed. The host sends each list
through clampConversationSubagents, so only known keys go out, and the lists of one page share a
wire budget, newest first. The task conversations of agent-tasks-v1 do not carry it. The WebRTC
host peer encodes the local response and the events again with the same option.
Prompt multi-select¶
prompt-multi-select-v1 carries multiSelect: true on an agent question that takes more than one
option, such as a Claude AskUserQuestion question with multiSelect (see
team-protocol/prompt-multi-select-v1.ts). It adds the flag beside the frozen keys of a question in
the questionPrompt of a message in GET /v1/agents/:id/conversation and conversation-page
(messages and references), in the conversation and conversation-page events, in the prompt
event, and in the pendingPrompts of the runtime-snapshot event. The host adds it only on protocol
7 or 8 and only for a connection that advertises the capability. A released client shows the question as a
single choice, and its one answer is valid for the question. A host without the capability never
sends the flag, so a client keeps it whenever it is present, and a value that is not a boolean fails
closed. The WebRTC host peer encodes the local response and the events again with the same option.
The iPhone app advertises it through the shared team client, but it still shows the question as a
single choice.
Context usage¶
context-usage-v1 carries how full the context window of each agent is, so a desktop joined to a
server shows the context meter of that server's agents (see team-protocol/context-usage-v1.ts). It
adds contextStates and threadContextStates beside the frozen keys of the snapshot of the
runtime-snapshot event, with the frozen keys of AgentContextState and the botId spelling of the
rest of that snapshot. The host adds them only on protocol 8 and only for a connection that
advertises the capability, so a released client gets the snapshot that it got before and shows no
meter. The host filters the states as the rest of the snapshot, so a member gets only the agents that
the member can see. It drops a category that the capability does not name, and a state that the v1
shape cannot describe, such as a later compaction status, so a client never refuses the snapshot for
it. A host without the
capability never sends the fields, so a client keeps them whenever they are present, and a value that
the runtime snapshot decoder refuses fails closed. A new socket gets a new snapshot, so the meter
comes back after a reconnect. The WebRTC host peer encodes the events again with the same option.
Compacting the context of a joined server's agent stays local only: the Team API has no route for it.
The iPhone app advertises the capability through the shared team client, but it shows no meter.
Queue window¶
queue-window-v1 keeps the queue snapshot of a long-lived agent inside one WebRTC frame. The
mailbox keeps every finished delivery, and the released GET /v1/agents/:id/queue answers all of
them. After some weeks of use, that answer is larger than the 2 MiB JSON frame, so the host peer
answers error.remote.responseTooLarge and the phone and the web client cannot read the queue.
On protocol 7, when the request advertises the capability, GET /v1/agents/:id/queue and
POST /v1/agents/:id/queue/edit keep every queued, starting and running delivery, the editing
mark included. Then they keep every failed, interrupted and cancelled delivery, newest first, while
it fits in WEBRTC_FRAME_BODY_BUDGET: the phone and the web client show the failure reason of a
failed message, and hide a cancelled message, at any age. Only completed deliveries leave the
window: the newest 100 by createdAt stay, while they fit, for the receipts of recent messages.
The kept deliveries stay in queue order, and the wire shape and keys do not change. A request
without the capability, and every protocol 6 or older request, gets the full list as before.
Every current client sends teamCapabilitiesForProtocol(7), so the phone, the web client and a
joined desktop get the window without other client code. A released phone keeps the full list
until it updates. scripts/queue-window-e2e.ts checks the frame size with and without the
capability.
Browser control and page state¶
The frozen browser-changed event and GET /v1/browser/tabs drop unknown tab keys in released
clients, so two optional capabilities send their tab state on their own event and routes. A client
merges it into the tab list: the desktop main process in remote-server-event-refresh.ts, and the
web client in web-client-context.tsx and web-runtime.ts. A member does not see a private tab in
either one.
browser-user-control-v1 lets any member who can see a tab take it from its agent and give it back:
POST /v1/browser/user-control (state), /take and /release, each with { tabId } except the
state, and each answering with the held tab ids. While a person holds a tab, the owning agent's
browser tool calls on it wait until the release. The host refuses a take while a secret request is
active on the tab, and releases the holds of a session when it ends. Each change sends
browser-user-control-changed { tabIds }.
browser-page-state-v1 adds POST /v1/browser/page-state (empty body) and POST /v1/browser/stop
({ tabId }). Both answer with { tabs: [{ tabId, canGoBack, canGoForward }] }, and each change
sends browser-page-state-changed with the same list. A stop ends the load at once, before the queued
tab operations, and a stop of a tab that does not load changes nothing. A client shows Stop only for a
host that reports the page state; for an older host, Back and Forward stay enabled.
browser-sign-in-v1 adds POST /v1/browser/sign-in with { tabId, cookies }, up to 150 cookies,
and answers with { cookies }, the count that the host set. Some sites refuse a sign-in from a
data-center address, but accept the session that the member's own computer made. The desktop opens a
window on this computer (src/main/local-sign-in-window.ts) with its own session in memory, the
sandbox of the embedded browser, and no downloads or permissions other than a clipboard write. When
the member selects Done, it sends only the cookies of the tab's site: the tab's host, a parent domain
that is not a top-level one, or a subdomain (browserSignInCookieFitsHost, which both sides apply).
It leaves out Cloudflare's address-bound __cf* and cf_* cookies. The host sets the cookies in the
tab's session and loads the tab again. A shared tab uses the shared browser session, so every agent
and member on the server then has the sign-in. The host never sends the values back or records them.
On a server, the agent prompt tells the agent to stop a refused sign-in and call request_takeover
with reason: "sign-in-blocked". The host adds that reason beside the frozen takeover projection, in
the browser-takeover-requested event and in pendingBrowserTakeovers, only for a client with
browser-sign-in. A client decodes a present reason and refuses an unknown one. The takeover card in
the desktop app shows the button for a web tab. With the reason, the card asks only for the sign-in
on this computer and has no "I'm done" button. A sign-in that sets cookies completes the takeover of
that tab.
The window also sends by itself (src/main/browser-sign-in-page.ts): after a page with a sign-in
address or a visible password field, a new HttpOnly cookie of the site, and then a page of the site
that is not a sign-in step. This is a guess, so after that send the desktop reads the server's tab
list. When the tab still shows a sign-in step, it opens the window's page in the tab (with
browser-navigation) and reads the list again. The window closes only when the tab left the sign-in
steps; otherwise it stays open and waits for the next cookie. Done sends without this check.
The desktop discards the window's session after a send that set cookies, on Cancel, and at shutdown.
A failed send keeps it, so Done can try again. The web client has no menu item: a web page cannot
read the cookies of another site.
Skill events¶
skills-events-v1 adds one optional event, skills-changed { agentId }. The host sends it after
each change to the installed skills of an agent: install, update, uninstall, turning a skill on or
off, and a skill an agent creates. SkillMarketplaceService calls its refresh callback after every
write, and that callback emits the event through AgentService, so this computer's windows get it
too. The event carries no skill data: a client reads the list again through installed-skills or
skills-admin-v1. team-protocol/optional-events.ts is the one place where each transport
recognizes the optional events that the frozen base vocabularies reject.
Focus use¶
focus-use-v1 adds POST /v1/client/focus with an empty body. Only a hosted server advertises it
(focusUse in TeamApiOptions), and the capability is v7 only. A client sends it while its app has
focus and the server is online, through createFocusUsePing in packages/team-client: the desktop
main process, the web client and the mobile app each give it their own focus signal and transport.
The host keeps the focus period of each session apart from #sessionUse, so a focused desktop does
not stop the phone's push notifications. A ping moves lastClientUseAt only for 30 minutes after the
later of the last user action of its session and the start of its focus period. The route is in PASSIVE_ROUTES, so the generic client use path
does not count it. See hosted servers step 5.
Admin capabilities¶
An owner or admin of a joined server manages its host through optional POST /v1/admin/... routes.
Each capability has a frozen codec in team-protocol/<name>-v1.ts, registered in
team-protocol/optional-routes.ts, and both transports use it. Every route calls requireAdmin,
except two that also answer a member: /v1/browser/tab-profiles and storage-disk-v1. The
host advertises a capability only when its TeamApiAdmin member exists.
| Capability | Grants | IPC group |
|---|---|---|
agent-admin-v1 |
Agent access and auto-approve | agentAdmin |
agent-host-settings-v1 |
Agent Computer Use, local scripts, and messages while it works; reads the host default | agentAdmin |
agent-backup-models-v1 |
Agent backup models: the order and whether to switch without asking. Unknown providers are refused | agentAdmin |
agent-task-limit-v1 |
Agent task limit: how many tasks run at the same time, 1 to 4, or the host default | agentAdmin |
browser-profiles-v1 |
Named browser profiles of the host: list, create, rename, recolor, clear, delete, and the profile of each agent. One route, /v1/browser/tab-profiles, also answers a member: the profile label of each tab that the member can see. Cookies and site data stay on the host. Advertised when the host has browserProfiles |
agentAdmin |
skills-admin-v1 |
List, install, remove, enable skills by marketplace id | agentAdmin |
shared-tables-v1 |
List and delete shared tables | sharedTables |
agent-install-v1 |
Add an agent from a listing or a shared template, by id | agentAdmin |
agent-update-v1 |
Update an agent added from a listing to the listing's current version, by id | agentAdmin |
providers-v1 |
Code sign-in, provider API keys, managed runtimes, custom endpoints | providerAdmin |
instance-transfer-v1 |
Export and import the server's agents and server settings as a transfer file | instanceTransfer |
providers-v3 |
Code sign-in for Codex, Claude and Grok; send the code a Claude sign-in page shows | providerAdmin |
providers-v4 |
Code or link sign-in and managed runtimes, Cursor and Cline included | providerAdmin |
claude-api-key-v1 |
No routes. The host saves an Anthropic API key for Claude through the providers-v1 key routes |
providerAdmin |
provider-usage-reset-v1 |
Read the usage reset credits of each provider account on the host, and spend one. Only Codex gives credits | providerAdmin |
host-admin-v1 |
Server name and logo | hostAdmin |
host-update-v1 |
Check for, download and restart into an app update; cancel a restart that waits | hostAdmin |
host-timezone-v1 |
Read and set the server timezone | hostAdmin |
agent-memory-limit-v1 |
Read and set how many memories each agent keeps | hostAdmin |
memory-review-v1 |
Read and set how often the host's agents review their memories in the background, or Off | hostAdmin |
machine-resources-v1 |
Read the disk, memory and CPU use of the host machine; read and set the alert levels and the owner or admin who gets the machine-alert event |
storage, hostAdmin |
prompt-cache-ttl-v1 |
Read and set how long Claude keeps the prompt cache of the host's agents | hostAdmin |
provider-default-v1 |
Read and set the provider that a new agent of the host starts on | none: only the mobile app sends it |
agent-access-v1 |
Read and set the roles that give members access to agents (see Roles) | hostAdmin |
discord-access-v1 |
Read and set which Discord roles can use OpenBot in a Discord server; list its roles | messaging |
events-v1 |
Manage webhook routines, their secrets, and activity | events |
routine-recovery-v1 |
Read and set a routine's missed-run policy; read its runs with the missed occurrences | events |
routine-results-v1 |
List the messaging connections and their places; read and set where an agent routine sends its answers. Deliveries stay on the host | none: only the mobile app sends it |
webhook-tests-v1 |
Send one signed test event to a webhook routine | events |
vault-v1 |
The host's Vault: list, add, change and delete logins, cards and contact info; show one saved password, card number or security code; reset a Vault that the host cannot read. No CSV import. A change sends vault-changed with no data, so a client reads the list again |
vault |
vault-v2 |
All of vault-v1, under /v1/admin/vault/v2/, and API keys as a fourth item kind: list each key's variable name and notes, add, change and delete keys, and show one saved key. The host leaves API keys out of every vault-v1 answer, and vault-v1 cannot delete or show one, so an older client keeps a valid list. It uses the vault-changed event of vault-v1 |
vault |
vault-import-v1 |
A CSV password export that an owner or admin sends as text, at most 256 KiB, below the JSON body limit. The host adds its logins as from its own file dialog and answers only the counts. The iPhone app uses it; the phone never calls the vault-v1 reveal or reset |
none: only the mobile app sends it |
These IPC groups take a required server id and route with scopedHandler. A key travels only towards
the host; no response carries one, except the vault-v1 and vault-v2 reveal: it answers the one secret that the
admin asked to show or copy. providers-v1 has no progress event, so the renderer reads runtime
status again every second while a host download runs. Publishing, macOS permissions, the browser
sign-in and folder import stay on the host.
providers-v1 signs in Codex only, with a device code. A host that serves providers-v3 also signs
in Grok (grok login --device-auth) and Claude (claude auth login), for a host with no visible
browser, such as a hosted server. src/backend/agent/cli-code-login.ts reads the link, and for Grok
the code, from the CLI output. The Claude CLI shows its paste prompt only on a terminal, so the host
runs it under the util-linux script, and the admin sends back the code that the Claude page shows
(code-login/submit). This flow runs only on a Linux host: the macOS script refuses a socket for
stdin, and Windows has no script. So only a Linux host advertises providers-v3; a macOS or
Windows host keeps providers-v1, and its clients offer the Codex code sign-in only. The CLI output and the pasted code are secrets; no log line or
error quotes them. How a sign-in ends arrives in the host's agent status, as for Codex.
serverCodeRouteProviders in server-capabilities.ts picks the providers that the Providers list offers
for a code sign-in, from the host's capabilities.
providers-v4 adds Cursor and Cline to the sign-in and to the runtime routes. Every host with
provider admin advertises it, so a client offers the Claude sign-in only when the host also has
providers-v3. Cursor uses a link sign-in: the host runs cursor-agent login with
NO_OPEN_BROWSER=1, which prints the sign-in page and waits until the page signs the CLI in, so
the admin only opens the page. The ACP cursor_login method stops when it cannot open a browser,
so a peer never starts it. Cline uses a device code: the host runs cline auth -p cline, which
prints the code before its page. The parser accepts a link only after its line ends, because a
chunk can stop inside one.
When the account is an owner or admin of the active remote server, the server serves providers-v1,
and the server has no agent, the workspace shows ServerOnboarding before the first-agent form, on
desktop (WorkspaceServerOnboarding) and on the web (WebWorkspace). It shows the host's providers
through hostSetupProviderProps, and Continue stays blocked until a provider is connected. The form
then opens with that provider. The choice stays in memory for the server; it is not written to the
setup file of this computer. A member, the local server, and a host without providers-v1 open the
form directly.
host-timezone-v1 reads and sets the server timezone (openbot-server-timezone-v1.json; null is
automatic, the system zone of the host). When an agent creates a routine, the zone comes from the tool
call first, then from the sender's last message in that turn, then from this setting. The sender's
zone stays in memory only, so after a restart a queued message uses the server timezone. Agent import
and instance transfer use it in place of the system zone. A change does not move existing routines.
agent-memory-limit-v1 reads and sets how many memories each agent of the host can keep. It is the
same value as "Memories per agent" in the host's own Server settings. The snapshot gives the limit and
the values that the host permits; the host refuses other values. A change reloads the threads of each
agent, because their instructions state the limit, and the host's window shows the new value in each
agent's memories (app:agent-memory-limit). A lower limit deletes no memories. The limit does not
change the 8,192-byte budget of essential memories. Server settings → General → Agents is the only
place that sets the limit, for this computer (hostAdmin.setAgentMemoryLimit, local handler) and for a
joined server. App Settings does not show it, so that one name does not have two values.
memory-review-v1 reads and sets how many completed turns of one conversation pass between two
background memory reviews of the host's agents (openbot-memory-review-v1.json; 10, 20 or 40, default
20; 0 is Off). The snapshot gives the value and the values that the host permits; the host refuses
other values. The agent service reads the value at each use, so a change applies from the next turn.
Server settings → General → Agents → Memory review sets it for this computer
(hostAdmin.setMemoryReview, local handler) and for a joined server.
prompt-cache-ttl-v1 reads and sets how long Claude keeps the prompt cache of the host's agents
(openbot-prompt-cache-ttl-v1.json; 5m or 1h, default 5m). With 1h the host starts each
Claude process with ENABLE_PROMPT_CACHING_1H=1. Claude Code reads this flag before its subscription
rules, so subagents and a subscription that uses extra usage also get 1 hour. With 5m, the default, it
sets nothing, so Claude Code uses its own choice: 5 minutes with an API key, 1 hour for the main thread of a
subscription within its limits. A promptCacheTtl
in the Claude settings or CLAUDE_CODE_PROMPT_CACHE_TTL on the host still wins. A change applies to
the next Claude process, not to a running one; an idle thread starts a new process when it resumes.
The Cache section of the Claude page in Server settings → Providers sets it for this computer and
for a joined server.
provider-default-v1 reads and sets the provider, and the model beside it, that a new agent of the
host starts on when its creator chooses none. It is the setup choice of the host (openbot-setup
file, preferredProvider and preferredModel): a set writes the file and calls
AgentService.setPreferredProvider, as the local Settings save does. A read gives the provider that
AgentService.preferredProvider uses now, so a host before its setup choice reads codex. A
provider that the client's protocol does not show reads as none (provider: null), and the host
refuses to set it. The phone's Server settings → Providers sets it. The desktop sets it only for this
computer. A remote set also sends the new setup to each window of the host (app.setupState), so
its Settings show the change at once.
claude-api-key-v1 has no routes. It tells a client that the host reads the key that the released
providers-v1 key routes save for claude. The host keeps the key as a Claude account with the
fixed id CLAUDE_API_KEY_ACCOUNT_ID, which provider-accounts-v1 lists as any other account, so an
older client can select it too. The agents on it start with ANTHROPIC_API_KEY and their own
CLAUDE_CONFIG_DIR, and Anthropic bills each request to the key. The provider's own sign-in first
checks its claude.ai login without the key. Only when it has no login does it use the key; it then
reports the account type claude-api-key, and the host hides the key account row, because each
agent already uses the key. A removed key removes its account and moves its agents to the own sign-in.
A removed account with a saved key comes back at the next account check. An added account never gets
the key. An older host saved the key but did not use it, so a client shows the Claude key only when
the host advertises the capability.
host-update-v1 runs the same update as the host's own Settings. src/main/requested-update.ts
keeps the schedule in memory: who asked, and whether the restart waits until
describeRestartReadiness reports no running work or happens as soon as the update is ready. The
host user can turn the routes off with "Allow updates from server members" (openbot-update-preference-v1.json,
default on) and can cancel a restart that waits. The host still advertises the capability when the
setting is off, so the client can show why. A Host Manager tenant refuses the routes. The client
reads the status again every second while a check, a download or a restart runs. An admin can also
set the host's automatic download and automatic install when idle through the settings route; an
automatic install is a schedule with no requester.
server-update-flow.tsx reads the update status once on each connection of a member with update access.
The sidebar header then shows an update button next to the server name. Each time the user enters a server
that has a new version, the update dialog opens again; it stays closed on a reconnect to the same server. The dialog confirm calls start, which downloads and restarts in one call. The
dialog reads the status while the update runs and follows the connection until the host is back with
the new version. A hidden dialog keeps a notice with the live status, also after a server switch on desktop; entering the server then does not open the dialog again. The web client reads only the connected host, so it stops following the update when the user leaves that server; the header button shows the update again on return. A host that still waits to restart after a connection drop did not restart. All members get the host-restart event
(waiting, restarting, none) from the host's event stream, and the host sends the current state
again when a client declares the capability. Like channelEvent, the event skips the frozen v1-v3
event encoders at each hop (host peer, client transport, remote-peer.ts, SSE stream). A client that
loses the host while a restart waits treats it as the restart: desktop keeps the fast WebRTC retry
instead of the host_unavailable wait for 10 minutes, and web tries again every 5 s for 3 minutes.
host-restart-toast.ts shows the notice until the host is back, for 10 minutes at most.
Roles¶
agent-access-v1 limits the agents that a member can use. Its routes are /v1/agent-access and
/v1/agent-access/set, not /v1/admin/..., because each member reads their own levels there. Each
agent has one of four levels for a member:
| Level | The member can |
|---|---|
none |
Not see the agent. The host treats it as hidden for that member: its list, events, push and Live Activity leave it out, and its routes answer 404. |
view |
Read the agent's conversations and files. Each write answers 403 error.team.agentViewOnly. |
chat |
Also send, react, interrupt, edit the queue, ask a side question, reset the context, use tasks, and answer the agent's prompts, approvals and browser requests. |
manage |
Also change the agent's settings, routines, memories, skills and publication (403 error.team.agentManageDenied below it). Delete stays admin only. |
The Everyone role holds a level for each agent, manage for an agent that it does not list. A
custom role holds a name, a level for each agent (none for an agent that it does not list) and its
member IDs (TeamMemberSummary.id). A member gets the highest level of Everyone and of each role that
names them. An owner or an admin always gets manage. So a server with no roles works as before, and
a new agent is open to every member until an admin closes it.
The host keeps the roles in openbot-agent-access-v1.json, by server ID (src/main/agent-access-store.ts).
It is a preference file, not the database: there is no migration, and an older build ignores it, so a
downgrade keeps the roles. A file that does not decode closes every agent to members until the next
save. Nothing goes to the account service. An owner or admin reads the whole set; a member reads only
mine. A save sends the whole set with the revision that it read, and the host answers 409 when another
admin saved after it; the client then reads the set again. A save changes only the levels of the agents
that the admin can see, and drops the levels of deleted agents. A duplicated agent gets the levels of its
source before the copy shows to clients, so a copy does not open memories that the source closed. The host removes a member from each role when the member leaves, and after
each change it sends agent-access-changed and a new agents-changed to each connection, so the agent
lists of members change without a reload.
Server settings → Roles (ServerRolesSection.tsx, @openbot/ui/features/servers/ServerRoles) edits the
set on desktop and web. The role menu of each member row on the Members page sets the server role
(Member or Admin, through the same confirmation as before) and the custom roles. The tab shows for the
local server and for a joined server whose host advertises the capability to an owner or admin.
Known limits:
- The composer does not show that a
viewagent is read only; the host refuses the send. GET /v1/attachments/:iddoes not check the agent of the attachment.- Channel messages of an agent that a member cannot see stay in the channel. A send to a channel does
not check the levels of the agents in it; only a save that adds an agent checks
chat.
Webhook routines¶
A webhook belongs to its routine. The host keeps the trigger, the encrypted secret, and receipts in
SQLite. D1 keeps only the route ID, the host, and the owner account.
Signal sends each signed request to the connected host without a cloud queue. All events-v1
routes need a host administrator. See Webhook routines and the
webhook guide.
Desktop server notifications¶
Each desktop profile stores muted server IDs in servers.json. RemoteServerStore saves a
mute change before publishing it. These preferences survive restart, re-login, and host-list
reconciliation. The server context menu controls mute for local and remote servers.
renderer-forwarders.ts continues to deliver live events for muted servers, but suppresses
system notifications. Remote notification content uses the source server's agent list. Both
server mute and per-agent notification settings apply. Unread state is unchanged. The iPhone app
has its own level and mute for each server, which the host applies to the notifications it sends to
that phone (see iPhone notifications). Mute
settings are not synchronized between devices.
Billing¶
Billing is per server. One account can pay for several servers; each server has its own Stripe
subscription. The Account Worker (apps/auth-api/src/server/billing-service.ts) talks to the Stripe
REST API with fetch; there is no Stripe SDK. The plan catalog in packages/contracts/src/billing.ts
holds the plan IDs, storage, member and site limits, relative speed and lookup keys, and no amounts. The amounts are six Stripe Prices with the lookup
keys openbot_{plan}_{interval}. bun run api:stripe:bootstrap (scripts/stripe-bootstrap.ts)
creates them and the Customer Portal settings.
- The add server dialog starts a plan. The hosting service
(
apps/auth-api/src/server/hosted-billing.ts) makes the Stripe customer before the Checkout, so two open Checkouts use one customer. The Checkout sets the subscription metadataopenbot_user_idandopenbot_server_id(BILLING_METADATA). The billing service calls theonSubscriptionSyncedhook after each subscription sync; the hosting side uses it to provision, stop and resume servers, so billing does not know about boat. See hosted servers. The webhook links a new Stripe customer to the account fromopenbot_user_id. It never moves a known customer to another account, and it skips a subscription that names no account. - Desktop Settings → Billing and the web Billing dialog render
@openbot/ui/features/billing: one row for each open plan, with the server name, the plan, its price, and its renewal controls. Hosted plans support cancellation that keeps data, Keep server, and explicit deletion now or after the paid period. A stored deletion date and subscription ID drive the cron; a fresh terminal Stripe state and a per-server operation lease guard deletion. See server deletion. The price is the list price of the Stripe Price in the subscription's currency (fromcurrency_optionswhen that is not the Price's base currency), before discounts and tax. The webhook stores it with the subscription. The account button opens the Customer Portal for the payment method and invoices. - Desktop calls the
billingIPC group; the main process gets a Customer Portal URL from the Worker, checks that it is abilling.stripe.compage, and opens it withshell.openExternal. The IPC takes no URL. The web client does the same check beforelocation.assign. Checkout URLs get the same check forcheckout.stripe.com(isStripeCheckoutUrl). - The change and cancel actions open the Portal flow of one subscription. The Worker first checks that the subscription belongs to the account.
- The server name comes only from a
remote_hostsrow that the same account owns. A plan whose server was removed stays in the list, because Stripe bills it until the account cancels it. - The webhook (
/v1/stripe/webhook) checks the Stripe signature, records the event ID to ignore a repeat, and gets the subscription from Stripe again before it writes the D1 row. So the order of events has no effect. - Without
STRIPE_SECRET_KEYbilling is off: the state isavailable: false, and the Portal route and the webhook answer 503.
Rule: plan limits read getServerEntitlement (apps/auth-api/src/server/billing-entitlement.ts)
only. It decides which statuses and grace periods give a server a plan, and it counts a plan only
for a server that the paying account owns. Do not read billing_subscriptions or a Stripe status in
another place.
Commercial license¶
The license is a second Stripe product next to the server plans
(packages/contracts/src/license.ts). One account has at most one open license. Its subscription
quantity is the number of users. There are two tiers, Starter and Team. Each tier has a monthly and a
yearly price, and each of these has a bundle price for an account that pays for a hosted server. That
gives 8 Stripe prices (LICENSE_PLANS):
| Plan | Lookup key | Month (EUR) | Year (EUR) | Users |
|---|---|---|---|---|
| Starter | openbot_license_starter_{month,year} |
10 | 96 | 1 |
| Starter bundle | openbot_license_starter_bundle_{month,year} |
9 | 86.40 | 1 |
| Team | openbot_license_{month,year} |
25 for 2 users, + 15 for each extra user | 240, + 144 | 2–10 |
| Team bundle | openbot_license_bundle_{month,year} |
22.50, + 13.50 | 216, + 129.60 | 2–10 |
A year is 12 months less 20% (LICENSE_YEAR_DISCOUNT_PERCENT). The bundle discount
(LICENSE_BUNDLE_DISCOUNT_PERCENT: 10%) comes off after it. The amounts are
in LICENSE_PLAN_PRICES (EUR is the base currency; USD and PLN are currency_options with their own
amounts). Team prices are graduated; the 1-user prices are flat. stripe-bootstrap.ts creates each
price with the metadata openbot_license_tier, openbot_license_interval and
openbot_license_bundle, and licensePlanFromPrice reads them. A price with no metadata is Team monthly: the license from before
the tiers used the same lookup key. The license terms allow 3 devices for each user
(LICENSE_DEVICES_PER_USER); the app shows this and does not count devices.
- A checkout names
tier: "starter" | "team"andinterval. A request with no tier is Team monthly, as an app before the tiers sends. The Worker sells the bundle price when the account pays for any hosted server plan (accountHasServerPlan: active, trialing, or past due in the grace period). Thelicensesrow storesbundle. After each server plan sync,LicenseService.alignBundlePricemoves an open license to the price of its tier and interval with the bundle that the account now has, withproration: none, so the change starts at the next renewal. It logs a Stripe failure and does not fail the sync. - A server create can order a license (
license: {tier, users}inPOST /v2/hosting/servers). The Worker refuses a Team license on a Starter server, and more Team users than the server plan has members (400invalid_hosted_server_request). An account with an open license gets 409license_exists. Fewer users than the people in use get 409license_users_in_use, as a license checkout does. One Checkout page then has 2 line items: the server price, and the license bundle price for the same interval and currency, with the users as its quantity. The customer pays one invoice. On the first sync of the paid subscription,LicenseService.splitOrdermakes a license subscription with the metadataopenbot_order_subscription(the server subscription), anchored at the server period end withproration_behavior=none, so its first invoice is 0. Then it deletes the license item from the server subscription, also with no proration. Each later sync runs the split again until it is done, and the metadata stops a second license subscription. The sales ledger divides the order invoice into a server fact and a license fact, and ignores the 0 invoice of the split. The Checkout session metadata keeps the order, so "Open the payment page again" makes a page with the same license, unless the account has a license by then. - Promotion: the Worker setting
LICENSE_TRIAL_END(ISO 8601, inwrangler.jsonc; empty turns it off) makes each new license free until that time.LicenseService.trialEnd()gives it while it is at least 49 hours away, because Stripe Checkout needs atrial_end48 hours ahead; after that the license sells at full price.GET /v1/me/licensethen hastrialEnd(ms), and the app shows "Free until" with the date in UTC. A license checkout setssubscription_data[trial_end], so the license istrialingand the first charge is on the trial end. A server order during the promotion has no license item: the server is the only line, and the session and subscription metadata keep the order (openbot_order_license_{tier,users,interval,trial_end}).splitOrderthen makes the license subscription at the bundle price withtrial_end(at least 1 hour ahead, for a late webhook) and removes the order metadata from the server subscription. The 0 trial invoice is afirst_purchasesales fact of 0, and the charge on the trial end is arenewal. Apps before the promotion ignoretrialEnd; Stripe Checkout still shows the trial. - The 1-user tier refuses
POST /v1/me/license/userswith 409license_one_user.POST /v1/me/license/plansells upgrades only: Starter to Team monthly or yearly, and Team monthly to yearly. It bills the difference now (always_invoice,payment_behavior=error_if_incomplete). Any other change is 409license_plan_change: to go down, the owner cancels at the period end and buys again. - The
licensestable storestier,billing_intervalandbundle(migration0041). NULL is Team monthly. - Users are people, not servers or computers: the owner, plus each distinct account that is an
active member (
remote_memberships) of a server that the owner owns. AI agents and chat-app participants do not count. - While the license is open, it is a hard limit.
RemoteControlPlaneaddsLICENSE_SEAT_AVAILABLE_SQLto the member seat check of an invite accept and a reactivation, in the same statement as the server plan limit. A full license refuses a new person with 409license_users_full. A person that the license already counts can join another server of the owner. Members who joined before are never removed. Without an open license, only the server plan limit (getServerEntitlement) applies. apps/auth-api/src/server/license-service.tsowns thelicensesD1 table (migration0038). The billing webhook gives a subscription with the license lookup key toLicenseService.syncand writes nothing tobilling_subscriptions. A Worker before the license sees an unknown plan and skips the event, so an old Worker keeps working.- Routes:
GET /v1/me/license, andPOST /v1/me/license/checkout,/users,/planand/renewal. The state isavailable: falseonly when the Worker has no billing, and the app then shows nothing. When Stripe has no active license price (it is archived to pause sales), checkout returns 503license_unavailable, and the app shows a toast with the sales address. An app on a Worker without these routes gets 404 and also shows nothing. - A Stripe customer pays in one currency, so a new license uses the currency of the account's open
subscriptions, and a server checkout in another currency gets 409
billing_currency. Add server opens in that currency and does not let the user change it. Checkout cannot buy fewer users than are in use. An increase is charged at once (always_invoice). A decrease starts at the next invoice and cannot go below the users in use or below 5. The subscription metadataopenbot_paid_usersandopenbot_paid_period_endkeep the users paid in the current period, so an increase after a decrease charges only the users above that number. - Checkout keeps one open page that sells a license for each account: a license page, or a server
order page with a license. After it makes a page, it closes the older pages of the customer that
sell a license, so two requests at the same time return the same page. When a newer page sells a
license, the new page closes with
license_exists. It then reads the customer's subscriptions in Stripe, not only D1, so a license paid before its webhook, or a paid order before its split, stops the new page withlicense_exists. A second open license can still come from the Stripe Dashboard;synclogs a warning, and a person refunds it. - Refunds are by email only; the app and the Worker have no refund action. A customer emails licenses@openbot.run within 24 hours of the first payment for a refund less a €3 / $3.60 / 13.20 zł fee, once per account. There are no refunds for later changes.
- When the owner of a full license creates an invite, the members section asks to add 1 user and shows the extra price, then adds the user and creates the invite. A 1-user license changes to Team monthly for 5 users in place of the added user. At 25 users it shows "Contact us" and creates no invite. Admins get no step; the join tells the person that the license is full.
- Desktop calls the
licenseIPC group; the web client calls the browser routes. Both render@openbot/ui/features/billing/LicenseSectionin the license slot ofBillingPanel.
macOS Host Manager¶
scripts/macos-tenant-setup.swift is a separate administrator command for new Standard accounts.
It uses OpenDirectory directly, creates only new empty homes, and stores generated credentials
in a new root-only file before account creation. It is not installed or called by the daemon.
The Host PKG installs this as create-tenants, alongside the standalone openbot-host CLI.
openbot-host-service.ts owns setup/verification sequencing; openbot-host-macos.ts owns OS
operations. Passwords cross only the native helper's captured pipe and the administrator's tty,
not the host protocol. The root-only recovery file is removed after successful presentation.
build-host-installer.ts and verify-host-installer.ts own release packaging and the exact
payload manifest. Package installation preserves host registration and state; only the application
is automatically updated. A Host Manager upgrade requires an administrator-installed signed PKG.
The optional standalone root helper (scripts/host-manager.ts) uses the lifecycle in
src/main/host-manager.ts and fixed macOS operations in scripts/host-manager-macos.ts.
src/main/host-update-coordinator.ts is the unprivileged tenant client, not an update leader.
The local protocol types live in packages/contracts/src/host-manager.ts; bounded file parsing
and owner checks live in src/main/host-update-files.ts. Only the helper publishes host control
state or replaces the shared application. It has no dependency on tenant storage services.
The tenant process owns an in-memory activity generation in src/backend/restart-activity.ts.
Backend work and main-process sessions advance it, so work between status polls resets the idle
grace. This counter contains no user data and is never sent to the host. Health and restart
readiness remain false until agent initialization succeeds.
See multi-tenant hosting for installation, permissions, and acceptance.
Hosted servers¶
A hosted server is one boat sandbox for one account. It runs the Linux OpenBot build under Xvfb and is a normal Remote host after its first start. The account Worker owns the sandbox lifecycle: it creates, resumes and deletes sandboxes, and D1 keeps the desired and observed state. A server reports each minute while it is in use, and the Worker stops it after 15 minutes with no report. boat also stops each sandbox at the end of a 2-hour lease that activity extends. When boat stops a server in use, the Worker resumes it, and clients ask the Worker to start a stopped server when a connection fails. No message waits in the Worker while a server is stopped; the client keeps it and connects again. The Worker's boat key cannot read files or run commands in a sandbox. On a hosted server only, main reads the memory of the machine, and the backend holds new turns while it is low and limits the turns that run at the same time. See hosted servers for the flow, the configuration, the memory guards and the template.
A self-hosted server uses the same Linux build, scripts and units on the owner's own computer, with
/opt/OpenBot/hosted/mode set to self. It has no claim: main starts in server mode
(src/main/server-mode.ts, OPENBOT_SERVER=1), and the openbot terminal command signs it in over
a Unix socket in the 0700 runtime directory of the service user. Main publishes the host after each
sign-in. See self-hosted servers.
Remote release checks¶
host-release-v1 adds release status and check routes for all signed-in server members. It does not change
host-update-v1, which still refuses installation requests and checks when updates are managed
or disabled. The new check reads only the official stable release manifest for the host platform
and architecture. It never installs files, restarts the host, or changes update preferences.
The host checks that the manifest contains a compatible asset and returns only version, phase,
and installation method. Feed errors return a safe status and can be retried.
The method is self-update when the host's updater can install, also in a container of
docker/instances, whose host pulls the image on request
(Updates from the app). Other containers report container.
HostReleaseService owns release discovery. RequestedUpdate still owns idle restarts and
UpdateService still owns app updates. Desktop and web use the new capability when available;
older hosts keep their existing update controls. The status poll is passive and does not keep a
hosted server awake. Mobile shares the protocol codecs but has no new update screen.
host-member-update-v1 adds /v1/host/update/status, /check, and /start for all active,
signed-in server members. It reuses the released update snapshot. The start body is empty:
requests always wait for idle time and preserve any existing schedule. Host restrictions still
apply. Cancellation, preferences, and forced restarts stay on the administrator-only
host-update-v1 routes. Desktop and web clients show member controls only after this capability
is negotiated; older hosts keep the administrator-only panel.
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 openbo/openbot