Workspaces and isolation
What is a workspace?
Section titled “What is a workspace?”A workspace is the top-level isolation tenant in Control Center. It groups agents, repositories, spaces, tickets, memory, pipelines and members into a bounded context. Everything that happens inside a workspace stays inside it.
A typical workspace maps to one project or one codebase. You might have separate workspaces for “api-service”, “frontend-v2” and “infrastructure”.
Workspace-scoped data
Section titled “Workspace-scoped data”A workspace’s rows live in that workspace’s own SQLite file, at <dataDir>/<workspaceId>/workspace.db. Each workspace gets a directory rather than a bare file, because a workspace accumulates more than a database — per-conversation worktrees, the agent and skill files, chat bot credentials — and giving it a folder means all of that lives and is deleted, together.
| Entity | Scope |
|---|---|
| Agents | Belong to exactly one workspace |
| Repositories | Registered into one workspace (see below) |
| Spaces, conversations and messages | Workspace-scoped |
| Tickets, projects, plans, orchestrations | Workspace-scoped |
| Memory facts, policies, domains, access grants | Workspace-scoped |
| Pipeline runs, templates, triggers | Workspace-scoped |
| Pull request state, review spaces, review cohorts | Workspace-scoped |
| Agent run logs and working memory | Workspace-scoped |
| Members, invites, per-repo grants | Workspace-scoped |
| Code graph symbols and edges | Scoped by repoId within the workspace file |
Most of these rows still carry a workspaceId column, but that column is no longer what keeps workspaces apart — see the isolation invariant below.
Repos are workspace-scoped; identity across workspaces is by path
Section titled “Repos are workspace-scoped; identity across workspaces is by path”A repo row lives in its workspace’s own database file — the file is the scope, so the table has no workspaceId column and no join table. The old server-global repos table and its workspace_repos many-to-many collapsed into one table when the database was split, because within a single workspace a repo is linked exactly zero or one times and the join row carried no information.
There is therefore no “link an existing global repo” step. You add a repo into a workspace, at Settings → Workspace → Repositories. Registering the same checkout in two workspaces creates two independent rows with two ids; repo identity across workspaces is by filesystem path (or GitHub owner/name), never by id.
Registration is strict: the path must be inside a git work tree, it must have an origin remote and that remote must point at a supported forge (github.com, gitlab.com or bitbucket.org). The forge is read from the remote and stored on the repo, so one workspace can hold repos from all three. Anything else is rejected. The folder browser you pick from lists the server host’s filesystem, confined to the server’s --repo-roots allow-list (which defaults to the server user’s home directory).
Removing a repository from a workspace deletes the row outright — there is no shared global repo left to unlink from — and cascades its code symbols, edges, files, index checkpoints and per-member grants. The checkout on disk is untouched.
The code graph lives in the same file, keyed by repoId, because different workspaces may have the same repository on different branches with different code.
The isolation invariant
Section titled “The isolation invariant”Cross-workspace data leaks are treated as bugs. Isolation used to be a convention — every query had to remember WHERE workspace_id = ?, policed by tests that could only pattern-match SQL. It is now structural, enforced at several levels:
- Database layer. Each workspace has its own SQLite file, handed out by
WorkspaceDatabaseManager.of(workspaceId). A workspace database does not declareusers,workspaces, or any other workspace’s tables, so a cross-workspace read is not a bug you can write — it does not compile. Repositories resolve their DAO per call rather than caching one and because every repository method takes a requiredworkspaceId, the workspace can never be inferred or defaulted. - The workspace id is a path segment. Because the id becomes a directory name, it must match a safe single-segment pattern (alphanumeric start, then alphanumerics, dots, underscores, hyphens; no
..). Ids are UUIDs everywhere in the product. Anything else throws rather than being sanitized — that is the path-traversal guard and it fails loudly rather than writing a workspace’s data to a path someone else chose. - Defense in depth. The
workspaceIdcolumns are still there and still written. Redundant within a file, they keep the sync-feed triggers, FTS indexes and existing row shapes unchanged and they make a file self-describing when it is inspected on its own — which is what export and import rely on. - Domain layer. Services that mutate entities by id validate
entity.workspaceId == workspaceIdat a single chokepoint before proceeding and throwWorkspaceMismatchExceptionon mismatch — the loud failure for an id that arrived from the wrong context. Denying loudly matters: a silent no-op hides the bug and proceeding leaks. - RPC layer. The repo-RPC dispatcher is the chokepoint. Every workspace-scoped op must carry
workspace_idin its own args — there is no per-session “current workspace” to leak and an unknown id is refused as not-found before anything opens a database, so a bad id cannot materialize a ghostworkspace.db. Genuinely global ops (the newsfeed, the fleet queue) declare themselves unscoped as an explicit, reviewed decision. MCP tools follow the same rule: any tool touching workspace-scoped data requiresworkspace_id. - Cross-workspace fan-out is enumerable. Answering a question about several workspaces means opening several databases. That fan-out is confined to one helper,
CrossWorkspaceQueries, so the complete list of things that legitimately cross the boundary lives in one file’s call sites instead of diffusing behind doc comments. Those call sites are: server-wide aggregation, startup reconcilers, retention and GC sweeps, backup, event routers — and the membership lookup below. - ID-only access is not sufficient. Looking up an entity by its UUID does not prove it belongs to the caller’s workspace. The file split makes the wrong file unreachable; the domain-layer validation catches a wrong id that arrives through a legitimate file.
Membership, not the pairing key, is what grants access
Section titled “Membership, not the pairing key, is what grants access”Holding a device credential authenticates a device. It does not authorize anything inside a workspace. Every workspace-scoped op resolves the caller’s membership role and enforces a floor derived from the op’s kind — read needs guest, a mutation needs member, a destructive operation needs admin — and ops that expose repo content additionally check the caller’s per-repo grant. A valid pairing key with no membership gets Not a member of this workspace.
The consequence for isolation is neat: membership rows live in the workspace’s own file, so “who is in this workspace?” is an ordinary scoped read. The inverse question — “which workspaces am I in?” — is by definition spread across files, so it is one of the few sanctioned CrossWorkspaceQueries fan-outs and it is what the workspace picker runs before any workspace has been chosen.
See Multiplayer for the role ladder and repo grants.
The global registry
Section titled “The global registry”Not everything is workspace-scoped. A second database, <dataDir>/global.db, holds what is genuinely server-wide:
- the workspace registry, so the switcher can list every workspace without opening a single workspace file — and so a workspace’s name, logo, owner, manual order and soft-delete marker live outside the file they describe;
- identity: users, user preferences and paired devices, because one human is one user across every workspace and a paired device survives a workspace being deleted;
- the fleet queue (workers, jobs, placement log), whose scheduler matches the whole queue against every worker on each tick;
- the newsfeed, which is server-wide by design — adding a feed in one workspace adds it everywhere;
workspace_routes, the pre-auth index that answers “which workspace owns this key?” for entry points that arrive with nothing but a secret or an opaque id (an invite hash, a webhook token, a deep link) and no workspace. A miss there is a not-found; there is deliberately no scan fallback;- install-wide settings and identity: the install id, host-level settings and SSO connections.
Boot opens only this file. Workspace files open lazily on first touch, so startup cost stays flat no matter how much history the workspaces accumulate and the per-file quick_check is paid on first use rather than on the path to the ready banner.
A workspace file carries only a single-row workspace_meta for self-identification (its id, the install that created it, the schema version it was created with, when). Everything descriptive about a workspace is in the registry.
Creating a workspace
Section titled “Creating a workspace”Creating a workspace does more than insert a registry row. In one operation the server stamps the creating user as ownerUserId, records that user’s owner-role membership and publishes a WorkspaceCreated domain event. Listeners on that event then seed, idempotently and in the background:
- a CEO agent plus four specialists —
qa,architect,engineer,librarian— with the specialists reporting to the CEO; - the built-in pipeline templates and their triggers (most of which ship disabled);
- the starter eval suites.
This happens for every workspace, not only the first. The seeded agents are created with no adapter, so they run on Control Center (built-in) with Anthropic’s default model until you set an adapter and model per agent at Settings → Workspace → Agents. See The agent model.
The owner-membership write matters more than it looks: without it the freshly created workspace would have no members and every workspace-scoped call the creator makes next would be denied.
Deleting, exporting, importing
Section titled “Deleting, exporting, importing”Deleting a workspace is a soft delete: the registry row is marked and the workspace disappears from every list and lookup, but its directory and database file stay on disk and maintenance sweeps (backup, retention) still visit them via the “including deleted” id list. Nothing reclaims that space automatically.
Because one workspace is one file, handing a workspace around is a file operation rather than a table-by-table dump:
workspace.exportis a singleVACUUM INTO— a consistent, defragmented snapshot taken under a read transaction while the server keeps serving.workspace.importadopts such a file, replacing whatever the target workspace currently holds. The embeddedworkspace_metalets an import distinguish “my own file, re-adopted” from “a file from another install” (the latter is allowed, but logged rather than invisible).server.backupNowsnapshots the whole install into a timestamped directory that mirrors the live data dir —manifest.json,global.dband one<workspaceId>/workspace.dbper workspace — so restoring is copying it back.server.listBackupsreads that directory back, newest first, with the size and workspace list of each snapshot. A snapshot whose manifest is missing or names files that are not there is still reported, flagged incomplete — hiding it is how an operator comes to believe they have a backup they do not.
All four are on Settings → Server → Backup & restore: “Back up now” takes an install snapshot, the list underneath says which snapshots exist and whether each one is whole, and every workspace carries its own export, import and delete. Restoring one workspace out of a snapshot is workspace.import, pointed at that snapshot’s <workspaceId>/workspace.db, so the two cannot drift into different treatments of the same file. Restoring a whole install is still a copy-back of the snapshot directory with the server stopped — the layout mirrors the live data directory precisely so that nothing else is needed for it.
What none of them carry is the rest of the workspace directory. A workspace accumulates more than a database — pasted images, skill and agent files, chat credentials, worktrees — and export and import move the file, so everything beside it stays where it is. That is deliberate for credentials and disposable for worktrees; for the rest it is a limit to know about rather than a feature. The backup reference has the full table.
Moving a backup between the server and your device
Section titled “Moving a backup between the server and your device”Every op above speaks in paths on the server, which is a complete answer only when the server is your own machine. Three signed HTTP routes — /backup/workspace, /backup/snapshot and /backup/restore — carry the bytes for every other topology, on the same authenticated lane the media proxy and the image store already use. On the page this is a Download button beside each export and each snapshot (you pick where it lands), and Choose a file and upload to restore from a file sitting on the machine you are using. The upload is the one that works when the server is not your machine: workspace.import needs the file to already be on the host, and this is how it gets there.
Three properties are worth knowing:
- A download mints a fresh copy and the server keeps none of it. Downloading is not “fetch the file the export button made” — it exports again, streams that, and deletes it. The response is
no-storewithAccept-Ranges: nonefor the same reason: a resumed range would splice two different exports into one file that looks valid and is not. - An upload is streamed to disk, never buffered, on both ends, and the staged copy is deleted on every path out — including a refusal. A workspace database is not a screenshot.
- A relayed connection has no HTTP origin, so the transfer controls are disabled there with a note saying why. Everything that speaks in server-side paths keeps working over the relay; only the byte lanes need a direct connection.
Multiple workspaces
Section titled “Multiple workspaces”You can run multiple workspaces side by side. Each operates independently: agents in one workspace cannot message agents in another, cannot read another workspace’s memory and cannot see its tickets. Recipient resolution for agent-to-agent messaging is exact and never crosses a workspace.
There is no cross-workspace dashboard. The analytics surface at /observability subscribes to a bounded global feed of recent runs and immediately narrows it to the active workspace, so what you see there is one workspace at a time. The only genuinely multi-workspace surfaces a person touches are the workspace picker and the switcher.
One practical limit worth knowing: open workspace databases are cached and never evicted. Past 32 open at once the server warns rather than refusing, because each open file holds a background isolate and a page cache.
Related concepts
Section titled “Related concepts”- Multiplayer: membership, roles and repo grants — the access half of isolation
- The agent model: agents belong to exactly one workspace
- Memory and knowledge: memory is workspace-scoped
- Architecture: where the databases sit in the client/server split
- Back up and restore: taking a snapshot, moving a workspace, keeping copies elsewhere
- Backup, export and import: every operation, route, manifest field and limit
- Add repositories: registering a checkout into a workspace