# Project config

Field-by-field reference for a project's YAML — name, repo, machines, orchestrator, agent runners, workspaces, and Zen Mode.

The project YAML is the root of a Shelbi project: it declares the repo,
the machines work runs on, the runners agents boot, the workspace pool,
and the Zen Mode policy. This page is the authoritative field reference.
For the *why* behind each block, follow the concept links.

## Where it lives

The file's location depends on the project's
[config mode](/docs/concepts/config-modes):

| Mode | Shared fields | User-local fields |
| --- | --- | --- |
| Global (default) | `~/.shelbi/projects/<id>.yaml` | same file |
| In-repo | `<repo>/.shelbi/project.yaml` (committed) | `~/.shelbi/projects/<id>/local.yaml` |

## The project id comes from the filename

A project's **id** is the config file's basename: `~/.shelbi/projects/shelbi.yaml`
has the id `shelbi`. The id is the machine-facing key: it names the state
folder (`~/.shelbi/projects/<id>/`), the settings file, the tmux session, the
event-log `project=<id>` field, and is what `--project` / `$SHELBI_PROJECT`
match against. It must be a valid slug: lowercase ASCII letters, digits, `-`,
and `_`, starting with a letter or digit. A file whose stem breaks that rule
(`Shelbi.yaml`, `my project.yaml`) fails to load with an actionable error;
the fix is to rename the file, not to edit a field.

The `name:` key *inside* the file is a free-form human **label** (see
[top-level fields](#top-level-fields)); it never sets the id.

In global mode every field lives in one flat file. In in-repo mode the
**shared** fields (`name`, `default_branch`, `default_workflow`, `orchestrator`,
`agent_runners`, `zen`, …) are committed and the **user-local** fields
(`repo`, `machines`, `workspaces`, `editor`) live in
a per-machine `local.yaml`. Putting a field on the wrong side errors at
load time. The canonical bucket lists are `SHARED_PROJECT_FIELDS` and
`LOCAL_PROJECT_FIELDS` in
[`shelbi_core::model`](https://github.com/jlong/shelbi/blob/main/crates/shelbi-core/src/model.rs).

## Minimal example

```yaml
# ~/.shelbi/projects/myapp.yaml (id `myapp`, taken from the filename).
# `name:` is an optional free-form label; omit it and the id `myapp` shows.
name: My App
repo: git@github.com:me/myapp.git
default_branch: main
# Optional: tasks without `workflow:` use workflows/app.yaml instead of
# workflows/default.yaml.
default_workflow: app
machines:
  - name: hub
    kind: local
    work_dir: ~/Workspaces/myapp
orchestrator:
  runner: claude
agent_runners:
  claude:
    command: claude
    flags: []
  codex:
    command: codex
    flags: []
workspaces:
  - { name: alice, machine: hub, runner: claude }
```

## Top-level fields

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | string | no | the id | Free-form human display **label** (uppercase, spaces, anything) shown in the sidebar and command palette. It is *not* the id; the id comes from the [filename](#the-project-id-comes-from-the-filename). Omit it and the id is displayed. |
| `display_name` | string | no | — | **Deprecated** alias for `name`, accepted for one release with a load-time warning and removed in the next. Move its value to `name:`. When both are set, `display_name` wins as the label. |
| `repo` | string | yes | — | Clone URL or path to the repository. User-local. |
| `default_branch` | string | no | `main` | Branch new work is based on and the fallback merge target when `git.base_branch` is unset. |
| `default_workflow` | string | no | `default` | Workflow used by tasks that omit `workflow:` frontmatter. Names `workflows/<name>.yaml`; explicit task `workflow:` still wins. |
| `machines` | list of [Machine](#machines) | yes | — | The hosts workspaces run on. User-local. |
| `orchestrator` | [Orchestrator](#orchestrator) | yes | — | Which runner boots the orchestrator. |
| `agent_runners` | map of name → [AgentRunner](#agent-runners) | yes | — | The command lines agents and the orchestrator are launched with. |
| `workspaces` | list of [Workspace](#workspaces) | no | `[]` | Fixed pool of workspace slots. User-local. |
| `zen` | [Zen](#zen) | no | all defaults | Zen Mode check list, CI timeout, and danger paths. |
| `editor` | string | no | `$EDITOR` | Editor invoked by `shelbi … edit` commands. User-local. |
| `github_url` | string | no | — | Informational GitHub URL recorded by the setup wizard. |
| `config_mode` | `global` \| `in-repo` | no | `global` | Which on-disk layout the project uses. Elided from the wire form when `global`. See [config modes](/docs/concepts/config-modes). |
| `workspace_poll_interval_secs` | integer | no | `5` | How often the hub poller samples each workspace pane for state changes. |
| `workspace_permissions_mode` | string | no | `auto` | Permissions posture rendered into the workspace settings template (`auto` → claude's `acceptEdits`). |
| `heartbeat` | [Heartbeat](#heartbeat) | no | `3m` / `60m` | Adaptive hub heartbeat written to `events.log` so the orchestrator's watch fires on a quiet board. Holds at `interval` while work is in flight, backs off toward `max` when quiescent. |
| `git` | [Git](#git) | no | all defaults | Base branch, generated task branch prefix, and merge strategy for `shelbi merge` and Zen's auto-merge. |

## machines

Each entry is a host where workspaces can run. The worktree for a
workspace lives at `<work_dir>/.shelbi/wt/<workspace-name>`.

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | string | yes | — | Machine identifier, referenced by `workspaces[].machine`. |
| `kind` | `local` \| `ssh` | yes | — | Whether commands run on this box or over SSH. |
| `work_dir` | path | yes | — | Base directory the repo checkout and worktrees live under. `~` is expanded. |
| `host` | string | no | `name` | SSH hostname. Required when `kind: ssh`; falls back to `name` if omitted. Ignored for `local`. |
| `tags` | list of string | no | `[]` | Capability tags every workspace on this machine inherits (e.g. `gpu`, `review`). A tag declared here applies to all of the machine's slots without repeating it per workspace. Accepts a scalar `tag:` alias and a bare string as shorthand. Elided when empty. |

```yaml
machines:
  - name: hub
    kind: local
    work_dir: ~/Workspaces/myapp
    tags: [review]        # every slot on hub inherits the review tag
  - name: m2
    kind: ssh
    host: m2.local
    work_dir: ~/work/myapp
```

## orchestrator

The orchestrator is a single agent that watches the board and dispatches
work. See [the orchestrator concept](/docs/concepts/orchestrator).

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `runner` | string | yes | — | Name of an entry in [`agent_runners`](#agent-runners) used to boot the orchestrator. |

```yaml
orchestrator:
  runner: claude
```

To run the orchestrator with Codex, point `orchestrator.runner` at a
declared Codex runner:

```yaml
orchestrator:
  runner: codex
agent_runners:
  codex:
    command: codex
    flags: []
```

Shelbi validates that `orchestrator.runner` names an entry in
`agent_runners`. Claude gets Shelbi's Claude-specific launch wiring
(`--append-system-prompt` and the first-message bootstrap prompt). Codex
is launched as the configured `command` plus `flags`, then receives an
initial startup prompt containing the rendered orchestrator instructions
and bootstrap request. Other runners launch exactly as configured. Put any
required Codex mode flags in `agent_runners.codex.flags`.

Worker hooks are deployed into each worktree under `.shelbi/hooks/`, and
Claude's `.claude/settings.json` references `.shelbi/hooks/claude.*` so the
pane pushes orchestrator messages to the agent as they arrive. Codex has no
hook channel Shelbi can wire without overwriting user-owned `.codex/`
configuration, so a Codex worker instead pulls its messages: its startup
prompt carries a polling contract that tails `.shelbi/messages/<task-id>.log`.
The chosen channel (hooks or polling) is recorded per launch in the events log.

`orchestrator.runner` only controls the dashboard/orchestrator pane. It does
not change worker slots. Worker slots use their own
[`workspaces[].runner`](#workspaces) value, so a project can run a Codex
orchestrator while still dispatching tasks to Claude workers, or mix Claude
and Codex workers in the same pool.

## agent_runners

A map from runner name to the command line agents are launched with.
Referenced by `orchestrator.runner` and `workspaces[].runner`.

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `command` | string | yes | — | Executable to invoke (e.g. `claude`, `codex`). |
| `flags` | list of string | no | `[]` | Extra flags appended to every invocation. |
| `dialog_signatures` | list of object | no | built-in per-runner set | Blocking-dialog text signatures the poller uses to detect a frozen pane. When empty, defaults keyed on `command` apply. |

```yaml
agent_runners:
  claude:
    command: claude
    flags: []
  codex:
    command: codex
    flags: []
```

## workspaces

The fixed pool of workspace slots. Each owns a stable worktree on its
machine and picks up tasks from the board, switching branches (with
cleared context) between assignments. See
[workspaces](/docs/concepts/workspaces).

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | string | yes | — | Workspace identifier. Names the worktree at `<machine.work_dir>/.shelbi/wt/<name>`. |
| `machine` | string | yes | — | Name of a [machine](#machines) this slot runs on. |
| `runner` | string | yes | — | Name of an [agent runner](#agent-runners) this slot boots. |
| `tags` | list of string | no | `[]` | Capability tags for this slot. A workspace's *effective* tags are these unioned with its machine's [`tags`](#machines). Tag-based routing picks a free workspace whose effective tags are a superset of a status's required [`tags`](/docs/configuration/workflow#statuses). Accepts a scalar `tag:` alias and a bare string as shorthand for a one-element list. Elided when empty. |
| `slot` | integer | no | declaration-order index | Numeric slot exported to transition [`run`](/docs/configuration/workflow#transitions) commands as `$SLOT` (e.g. to derive a per-slot port). When unset, falls back to the slot's zero-based index among its machine's workspaces. Elided when unset. |

```yaml
workspaces:
  - { name: alice, machine: hub, runner: claude }
  - { name: bob,   machine: m2,  runner: codex }
  - { name: rev,   machine: hub, runner: claude, tags: [review], slot: 0 }
```

To switch existing worker slots after setup, use:

```bash
shelbi workspace set-runner codex --all
```

That command edits the same `workspaces[].runner` fields shown above and
validates that `codex` is declared in `agent_runners`. You can also update
the YAML directly by changing each desired workspace row:

```yaml
workspaces:
  - { name: alice, machine: hub, runner: codex }
  - { name: bob,   machine: m2,  runner: codex }
```

Leave `orchestrator.runner` unchanged unless you also want to change the
dashboard/orchestrator pane. Run `shelbi workspace list` afterward; its
`RUNNER` column shows the runner each workspace will launch.

Route a slot to review work by giving it the `review` tag. See
[review workspaces](/docs/concepts/review-workspaces) for the tag-routing
model.

## zen

Zen Mode configuration: which local checks gate a promotion, how long to
wait on CI, and which paths always require human review. See
[Zen Mode](/docs/concepts/zen-mode). Per-workflow overrides live in the
[workflow file](/docs/configuration/workflow#zen); per-task overrides
live on the task frontmatter.

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `checks.local` | list of string | no | `[]` | Shell commands run in the worktree root before handoff to CI. Each entry is one command line. |
| `ci_timeout` | duration (seconds) | no | `900` (15m) | How long Zen waits for CI to report before timing out the promotion. |
| `danger_paths` | see below | no | extend built-ins with `[]` | Glob patterns too sensitive to auto-promote. |

`danger_paths` accepts three forms. A bare sequence is shorthand for
`extend`, which keeps the built-in danger list and adds yours;
`override` replaces the built-ins entirely:

```yaml
zen:
  checks:
    local:
      - cargo test --workspace
      - npm --prefix site test
  ci_timeout: 1200
  danger_paths:
    extend: [".env", "infra/**"]   # or: override: [...], or a bare list
```

## heartbeat

The recurring line the hub poller writes into `events.log` so the
orchestrator's `events tail --follow` watch wakes up to sweep active
tasks even when no real transition has fired. See
[the events log](/docs/concepts/events-log#heartbeats) for how the
orchestrator consumes it.

The cadence is **adaptive**: it holds at `interval` whenever there's
supervisable work in flight (any active, ready, or in-review task,
even one emitting no events, which is exactly when the sweep earns its
keep), and once the board is quiescent it backs off exponentially,
doubling each idle tick up to `max`. Any real event snaps the cadence
straight back to `interval`. So a fully idle hub relaxes
`3m → 6m → 12m → … → 60m`, then resets to `3m` the moment something
happens.

`heartbeat` accepts three shapes:

| Form | Meaning |
| --- | --- |
| `heartbeat: 3m` | Bare duration — sets `interval` and keeps the default `max` (`60m`). |
| `heartbeat: off` | Disables heartbeats entirely. |
| `heartbeat: { interval: 3m, max: 60m }` | Map — sets both bounds explicitly. |

The map form's fields:

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `interval` | duration | no | `3m` | Standard cadence, used whenever supervisable work is in flight. |
| `max` | duration | no | `60m` | Back-off cap the interval doubles toward while the board is quiescent. A `max` ≤ `interval` pins the cadence at `interval` (no back-off). |

Durations take an explicit unit: `45s`, `3m`, `1h`. A bare integer is
rejected (there's no implicit unit).

```yaml
heartbeat:
  interval: 3m    # standard cadence while work is in flight
  max: 60m        # back-off cap once the board is quiescent
```

## git

Where workspace branches are based, how generated task branches are named,
and how they integrate back. See [the merge command](/docs/cli/merge).

| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `base_branch` | string | no | `default_branch` | Branch new work is based on and merged into. Falls back to the project's `default_branch`. |
| `branch` | string | no | none | Full branch-name template for generated task branches when a task omits `branch:`. Rendered with `{{var}}` substitution, including `{{github_user}}` (authenticated GitHub username) and `{{id}}` (task id), plus the task's frontmatter params. So `branch: '{{github_user}}/{{id}}'` yields e.g. `jlong/fix-login`. |
| `merge_strategy` | `squash` \| `merge` \| `rebase` | no | `squash` | How `shelbi merge` integrates a workspace branch. Zen exact-ref auto-integration currently supports `squash`; other strategies require human PR merge. |

```yaml
git:
  base_branch: develop
  branch: '{{github_user}}/{{id}}'
  merge_strategy: rebase
```

After substitution, `branch` must use the same safe characters Shelbi allows
for generated refs: ASCII letters, numbers, `-`, `_`, and `/`. It must not end
with `/`. Resolution runs in order: an explicit `branch:` in the task's
frontmatter wins; otherwise Shelbi renders `git.branch`; when neither is set it
names the branch after your authenticated GitHub username, falling back to
`user`. The shipped `task` workflow sets `branch: '{{github_user}}/{{id}}'`.

## See also

- [Config modes](/docs/concepts/config-modes) — global vs. in-repo layout
  and the shared/user-local field split.
- [Workspaces](/docs/concepts/workspaces) and
  [the orchestrator](/docs/concepts/orchestrator) — the concepts behind
  `workspaces:` and `orchestrator:`.
- [Workflow config](/docs/configuration/workflow) — the per-project
  status pipeline and its per-workflow `zen` / `git` overrides.
