# Workspaces and execution (https://condensate.dev/guides/workspaces/)

> Set the repositories, accounts, models and limits a task may use.

A workspace gives a task its project context and its limits. It names the repositories the task works in, the accounts whose subscriptions it may spend, and the models it may run. The runtime checks these rules when a task is spawned, before anything starts, so a task that breaks a rule is refused with a reason. This page covers the registry file, policy fields, how a spawn picks its workspace, where tasks run, and heads.

[Diagram: Project context and execution are separate. A workspace supplies the context for a task. A thread records its history; an engine session performs its work.]

## Configure the registry

The runtime reads `~/.condensate/workspaces.json`. Set `CONDENSATE_WORKSPACES_FILE` to read a different file. The file is a JSON document with one `workspaces` array, and the runtime validates it against a schema. A missing required field or a value of the wrong type is an error. A key the schema does not know is dropped without a warning, so a misspelled optional field such as `modles` silently means no limit. Check the result with the read below.

You do not need to restart the runtime after an edit. It remembers the file's modification time and size, and rereads the file when either changes.

If the file does not exist, the registry is empty. No task can name a workspace, so every spawn that names one is refused. If the file exists but cannot be read or decoded, spawns fail with a 500 that names the file, instead of treating the registry as empty. That keeps a broken file from looking like a missing one. Fix the file promptly: an existing thread whose session is rebuilt while the file is broken runs as if it had no workspace.

This example uses placeholders. Replace `my-repo` and `my-account` with a repository directory name and an account name from your account registry:

```json
{
  "workspaces": [{
    "name": "my-project",
    "repos": [{ "name": "my-repo", "writable": true }],
    "accounts": ["my-account"],
    "policy": { "models": ["claude-opus-5-5"] }
  }]
}
```

Check what the running runtime sees with a read:

```sh
curl -fsS http://127.0.0.1:4741/workspaces
```

The port comes from `CONDENSATE_AGENTS_PORT` and defaults to 4741. The response includes each repository's GitHub slug when the runtime can find it. Do not infer the registry from a similarly named file in another checkout. Ask the running runtime.

Each workspace has these fields:

| Field | Meaning |
| --- | --- |
| `name` | The key that threads carry. Keep it unique: the runtime does not check, and the first workspace with a name wins. Use lowercase words joined by hyphens. |
| `repos` | The repositories the workspace works in. Each entry has a `name` and a `writable` flag. |
| `accounts` | The account names the workspace may spend, in order of preference. |
| `gitIdentity` | Whose name commits, pushes and pull requests carry. `account` is the default. `app` uses the GitHub App. |
| `githubUser` | The GitHub login whose token the workspace's proxied tasks use for GitHub calls. |
| `policy` | Required. Limits on models, failover, the Fable pool and container size. Use `{}` for no limits. See [Policy](#policy). |

## Repositories

The registry names repositories. It does not store their paths. A repository applies to a task when its name is one of the directory names in the task's working path, compared without regard to case. A task in `/work/my-repo/src` matches `my-repo`.

Set `writable: false` on a repository that a workspace should read but never change. The runtime refuses pull request work and GitHub writes to that repository with a 409 `repo-read-only` response. The flag does not change file permissions in the checkout.

## Git identity and GitHub access

The GitHub tokens behind these settings are swapped in by the credential proxy. The proxy is on by default. With `CONDENSATE_PROXY=off`, an `app` workspace still writes commits as `condensate-dev[bot]`, but pushes and GitHub calls use the account's own credential, and `githubUser` has no effect.

- With `gitIdentity: "account"`, commits and pushes use the account's own GitHub access.
- With `gitIdentity: "app"`, commits are made as the `condensate-dev[bot]` identity. The runtime mints an installation token for the GitHub calls.
- With `githubUser`, the proxied task's GitHub token comes from `gh auth token --user` for that login, not from whichever gh account is active.

When a workspace sets both `gitIdentity: "app"` and `githubUser`, the app wins.

## Policy

The `policy` object sets limits. A field that is absent means the runtime applies no limit.

| Field | Effect | When absent |
| --- | --- | --- |
| `models` | The models a task may name. A spawn on any other model is refused with a 400 `model-not-allowed`, and the response lists the allowed models. A model with and without the `[1m]` bracket counts as the same model. | Any model the engine serves |
| `failover` | `{ "engine", "model" }`. When every account a task may spend on its engine is out of quota, the turn moves to this engine and model. `engine` is `claude`, `codex` or `pi`. The workspace needs at least one account for the target engine. | Off. The turn fails, and the runtime raises an alert that the accounts are spent. |
| `pools.fable` | `{ "maxUtilization", "fallback" }`. When a Fable spawn would run on an account whose five-hour window is at or above `maxUtilization` percent, the task runs `fallback` instead. The fallback must be a permitted model. | No guard |
| `container` | `{ "cpus", "memoryMb" }` limits for each container task. CPUs range from 1 to 16. Memory ranges from 256 to 32768 MiB. | 2 CPUs and 4096 MiB |
| `maxSeats` | Stored in the file. No code path enforces it yet. | Not applicable |

Replace a workspace's whole policy with `PUT /workspaces/:name/policy`. The body is the policy object, and it replaces the old one. The runtime checks the body against the same schema as the file, and it also refuses any field the schema does not know. It refuses a fallback model that routing does not permit, with a 400 `policy-refused`. It then writes the file atomically, so a reader never sees a half-written registry. An unregistered name gets a 404 `unknown-workspace`.

## How a spawn picks its workspace

A spawn can name its workspace. If it does not, the runtime picks one in this order:

1. The workspace named in the spawn.
2. The workspace whose repository matches the task's working path.
3. The parent thread's workspace, for a child task.

A named workspace that is not registered is refused with a 400, and the message lists the registered names. The runtime never falls through to a different workspace. A task with no workspace may spend only accounts that no workspace lists, so a workspace's subscriptions cannot leak into an unassigned task.

## Accounts and model refusals

The runtime refuses these before a task starts:

- **An account outside the workspace.** The spawn names an account that the workspace does not list. The response lists the workspace's accounts (400, `account-not-in-roster`).
- **A model outside `models`.** The response includes the allowed list (400, `model-not-allowed`).
- **A model no account can serve.** None of the workspace's accounts runs the model's engine (400, `model-not-served`). A Claude task needs a Claude account. A Codex task needs a Codex login. Condensate's own agent (engine id `pi`) spends a Codex login for an OpenAI model, and an OpenCode or Cerebras account for those providers' models.

Within a workspace, the runtime tries accounts in order and moves on when one is out of quota. When every account is spent, the turn fails unless `failover` is set. The [accounts guide](https://condensate.dev/guides/accounts/) explains how credentials and routing fit together.

Calls to the OpenAI-compatible completions endpoint must name a workspace in the `x-condensate-workspace` header. A call without it is refused with a 400 `workspace_required`. The call is charged to that workspace and runs on its accounts. See the [HTTP reference](https://condensate.dev/reference/http/).

## Where a task runs

A workspace is not a machine. Each spawn chooses a runner with its `runner` field: `process`, `container`, `k8s`, or `any`. `any`, or leaving the field out, lets the runtime choose. When Kubernetes is configured and a node labelled `condensate.dev/seats=true` is ready, the runtime places reviewer and fixer tasks there, if the Kubernetes runner accepts them. Everything else runs in a local process.

| Runner | Turned on by | Runs | Limits |
| --- | --- | --- | --- |
| `process` | Always available. It is the default. | A local process on the host | Supports plugins, MCP servers, host-side tools and every engine: Claude Code, Codex and Condensate's own agent |
| `container` | `CONDENSATE_CONTAINER_IMAGE`, `CONDENSATE_CONTAINER_PROXY_URL` and `CONDENSATE_CONTAINER_NETWORK` | A Claude task in its own apple/container VM | Claude only. No plugins, forks, middleware keys or MCP servers. The task's checkout is mounted at `/work`, and the VM has no internet access. |
| `k8s` | `CONDENSATE_KUBECONFIG`, plus `CONDENSATE_SEAT_IMAGE` and `CONDENSATE_PROXY_SECRETS_DIR` | A Kubernetes Job | Claude only. No plugins, MCP servers or forks. The task runs in the pod's `/work`. |

Both the container and Kubernetes runners need the credential proxy, which is on by default, so do not set `CONDENSATE_PROXY=off`. The task's Claude account must reach them as a proxy placeholder. The container network must be a host-only apple/container network with the proxy on its gateway. Setting `CONDENSATE_KUBECONFIG` without `CONDENSATE_SEAT_IMAGE` and `CONDENSATE_PROXY_SECRETS_DIR` stops the runtime from starting, and a Kubernetes task must use `/work` as its working directory.

Codex tasks and tasks on Condensate's own agent run only on the process runner, because the container and Kubernetes runners refuse any engine other than Claude.

Mac-only MCP servers (iMessage, Calendar, Spotify and Mac tools) and host-side stdio MCP servers need the process runner. The other runners refuse them and say why.

Presence of a runner's configuration is not the same as a working runner. Check the runner that a task will use before you move work to it. A fresh checkout does not have a Kubernetes cluster, and it does not have a container image unless you configure one.

## Heads

A workspace has at most one head. A head is a coordinating thread that reads, decides, briefs other tasks, watches their progress and reports back. It hands the implementation to child tasks.

- A spawn that asks for a second head in the same workspace is refused with a 409 that names the existing head.
- `GET /workspaces/:name/head` returns the head thread, or a 404 when the workspace has none.
- Demote a head with `POST /threads/:id/role` and the body `{ "role": "chat" }`. The log and the session stay. A head that has any events is refused with a 409 `head-holds-transcript` unless the body sets `"explicit": true`. A demotion that would leave the workspace with no head is refused with a 409 `workspace-would-be-headless` unless it sets `"allowHeadless": true`. A thread that is not a head gets a 409 `not-a-head`.
- A child task is its own durable thread with its own log. Follow it through the thread event APIs and `/threads/:id/subscriptions`.

## Memory and workspace events

Each workspace has its own memory bank. The bank is named after the workspace, under a configured prefix. Threads with no workspace use the `default` bank. Recall, retain and reflect through `/workspaces/:name/memory/...`.

A workspace also exposes its activity as one set of routes:

| Route | Returns |
| --- | --- |
| `GET /workspaces/:name/events` | The workspace log as server-sent events, in one cursor order across all of its threads |
| `GET /workspaces/:name/tail` | Recent events |
| `GET /workspaces/:name/changes` | Changes to the workspace's threads |
| `GET /workspaces/:name/approvals` and `/questions` | Tool calls and questions waiting on a person |
| `POST /workspaces/:name/prs/:number/agents` | A build task on a pull request's branch, in a worktree the runtime owns |

The [HTTP reference](https://condensate.dev/reference/http/) lists every route.

## Change a workspace safely

- Spawn checks read the registry when a task starts, so a change to accounts or models applies to tasks spawned after it. A running session keeps the account it started with. When the runtime builds a new session for an existing thread, for example after a restart, it reads the registry again and picks from the current accounts.
- Threads keep their own log when you change the registry.
- Before you change where the runtime stores its data, back up `threads.db` with SQLite's online backup, and back up the `blobs/` folder beside it and the engine state too. A copy of `threads.db` alone is not a complete backup while the runtime runs. The [troubleshooting guide](https://condensate.dev/operations/troubleshooting/) has the commands.

For how threads, sessions and runners relate, see [the runtime](https://condensate.dev/concepts/runtime/).
