Project config

View as markdown

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:

ModeWhere the config lives
Global (default)One flat file: ~/.shelbi/projects/<id>.yaml carries every field.
In-repoA two-file split: the committed <repo>/.shelbi/project.yaml carries the shared fields (SHARED_PROJECT_FIELDS); a per-machine ~/.shelbi/projects/<id>/local.yaml carries the user-local fields (LOCAL_PROJECT_FIELDS). No flat ~/.shelbi/projects/<id>.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.

In the flat global file the name: key is a free-form human label (see top-level fields); it never sets the id. The committed in-repo project.yaml is the one exception, described below.

In global mode every field lives in the one flat ~/.shelbi/projects/<id>.yaml that shelbi init --mode global writes. In-repo mode uses a two-file split instead. shelbi init --mode in-repo writes both halves; shelbi init --pick-up writes just the user-local half and reads the already-committed shared half in place:

  • Shared fields (SHARED_PROJECT_FIELDS) — name, display_name, default_branch, default_workflow, orchestrator, agent_runners, runners, agents, zen, heartbeat, git, and the rest — live in the committed <repo>/.shelbi/project.yaml, so every clone shares them.
  • User-local fields (LOCAL_PROJECT_FIELDS) — repo, machines, workspaces, editor — live in a per-machine ~/.shelbi/projects/<id>/local.yaml.

There is no flat ~/.shelbi/projects/<id>.yaml for an in-repo project. 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.

In the committed <repo>/.shelbi/project.yaml, the name: key is the project id — a valid slug, which shelbi init --pick-up reads back to seed a teammate's local alias — and the human label rides under display_name: (only emitted when the entered name was slugified into a different id). This is the one file where name: carries the id rather than a label.

A present local.yaml is authoritative

Once ~/.shelbi/projects/<id>/local.yaml exists, that split is the project's config: the loader reads the committed shared half plus your local half, and a stale flat ~/.shelbi/projects/<id>.yaml left over from a pre-migration global registration can never shadow it. shelbi project migrate-to-in-repo is now just the path that converts an existing flat/global project into this same split — fresh init --mode in-repo and --pick-up produce it directly.

Minimal example

# ~/.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 }

Top-level fields

FieldTypeRequiredDefaultDescription
namestringnothe idFree-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. Omit it and the id is displayed.
display_namestringnoDeprecated 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.
repostringyesClone URL or path to the repository. User-local.
default_branchstringnomainBranch new work is based on and the fallback merge target when git.base_branch is unset.
default_workflowstringnodefaultWorkflow used by tasks that omit workflow: frontmatter. Names workflows/<name>.yaml; explicit task workflow: still wins.
machineslist of MachineyesThe hosts workspaces run on. User-local.
orchestratorOrchestratoryesWhich runner boots the orchestrator.
agent_runnersmap of name → AgentRunneryesThe command lines agents and the orchestrator are launched with.
runnersmap of RunnerKindProjectRunnerno{}The kind-keyed runner fleet plus an optional house model / reasoning_effort per kind. The evolution of agent_runners; see agents. Additive: empty projects keep resolving through agent_runners.
agentsmap of name → ProjectAgentno{}Per-agent overrides, keyed by the agent's directory name. Pins which runner kind the project runs that agent on. See agents.
workspaceslist of Workspaceno[]Fixed pool of workspace slots. User-local.
zenZennoall defaultsZen Mode check list, CI timeout, and danger paths.
editorstringno$EDITOREditor invoked by shelbi … edit commands. User-local.
github_urlstringnoInformational GitHub URL recorded by the setup wizard.
config_modeglobal | in-reponoglobalWhich on-disk layout the project uses. Elided from the wire form when global. See config modes.
workspace_poll_interval_secsintegerno5How often the hub poller samples each workspace pane for state changes.
github_reconcile_interval_secsintegerno900How often (seconds) the hub poller reconciles review tasks whose PR was merged on GitHub out-of-band, advancing them to done and skipping the local merge. Runs on its own slow clock, never the workspace poll tick. Set to 0 to disable.
workspace_permissions_modestringnoautoPermissions posture rendered into the workspace settings template (auto → claude's acceptEdits).
heartbeatHeartbeatno60s / 5mAdaptive 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.
gitGitnoall defaultsBase branch, generated task branch prefix, and merge strategy for shelbi merge and Zen's auto-merge.
reviewReviewnoall defaultsReview-interface behavior. Today just diff_command, the View Diff escape hatch for a diff tool that reviews a revision range instead of git's directory-diff pair.

machines

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

FieldTypeRequiredDefaultDescription
namestringyesMachine identifier, referenced by workspaces[].machine.
kindlocal | sshyesWhether commands run on this box or over SSH.
work_dirpathyesBase directory the repo checkout and worktrees live under. ~ is expanded.
hoststringnonameSSH hostname. Required when kind: ssh; falls back to name if omitted. Ignored for local.
tagslist of stringno[]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.
forwardunix | tcpnoautoHow the hub reverse-forwards its daemon socket to an ssh machine. Unset (the default) auto-detects: start with a Unix-socket forward and fall back to TCP loopback if the Unix landing socket is unusable (the Tailscale-SSH root-owned-socket case). tcp skips detection and forwards over TCP loopback from the first attempt; unix pins the Unix forward and disables the fallback. Ignored for local. Elided when unset.
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.

FieldTypeRequiredDefaultDescription
runnerstringyesName of an entry in agent_runners used to boot the orchestrator.
orchestrator:
  runner: claude

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

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. A worker slot no longer selects a runner: the dispatched agent (role) owns runner/model resolution through its agent.yaml manifest, and the project's runner fleet supplies the concrete launcher. So a project can run a Codex orchestrator while dispatching a task agent onto Claude, or run one agent on Opus and another on Sonnet, all resolved per agent rather than per slot. See agents for the manifest and the precedence chain.

agent_runners

A map from runner name to the command line agents are launched with. Referenced by orchestrator.runner, and used as the concrete launcher an agent resolves onto (kind-keyed lookup, bridged from this map when no top-level runners fleet is declared).

FieldTypeRequiredDefaultDescription
commandstringyesExecutable to invoke (e.g. claude, codex).
flagslist of stringno[]Extra flags appended to every invocation.
dialog_signatureslist of objectnobuilt-in per-runner setBlocking-dialog text signatures the poller uses to detect a frozen pane. When empty, defaults keyed on command apply.
agent_runners:
  claude:
    command: claude
    flags: []
  codex:
    command: codex
    flags: []

Note

Model-in-flags stays supported: an agent_runners entry that bakes --model into flags keeps working. When an agent resolves a model, that model takes precedence and the stale baked flag is replaced, so the launched line always carries exactly one model.

runners

An optional, kind-keyed runner fleet that layers on top of agent_runners: one entry per runner kind (claude / codex / generic) carrying the concrete launcher plus an optional house model / reasoning_effort authoritative for every agent of that kind. A field it omits falls through to each agent's agent.yaml recommendation. Empty by default; when empty, a kind resolves through the agent_runners bridge. See agents for the full shape and the resolution precedence.

runners:
  claude:
    command: claude
    # model / reasoning_effort omitted -> each agent's manifest decides
  codex:
    command: codex
    model: gpt-5          # authoritative for every codex agent

agents

Per-agent overrides keyed by the agent's directory name. Today each entry carries a single runner: field that pins which runner kind the project runs that agent on, authoritative over the manifest's preferred_runner. There is deliberately no per-agent model block: per-agent differentiation comes from each agent's manifest, and a house model is set once at the kind level in runners.

agents:
  orchestrator: { runner: codex }
  review:       { runner: claude }

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.

FieldTypeRequiredDefaultDescription
namestringyesWorkspace identifier. Names the worktree at <machine.work_dir>/.shelbi/wt/<name>.
machinestringyesName of a machine this slot runs on.
tagslist of stringno[]Capability tags for this slot. A workspace's effective tags are these unioned with its machine's tags. Tag-based routing picks a free workspace whose effective tags are a superset of a status's required tags. Accepts a scalar tag: alias and a bare string as shorthand for a one-element list. Elided when empty.
slotintegernodeclaration-order indexNumeric slot exported to transition run 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.
workspaces:
  - { name: alice, machine: hub }
  - { name: bob,   machine: m2 }
  - { name: rev,   machine: hub, tags: [review], slot: 0 }

A workspace no longer selects a runner

Runner/model/effort are resolved per dispatch from the agent that runs in the slot (see agents), not from the workspace. A legacy runner: key on a workspace is accepted-and-ignored on load and dropped on the next save, so an existing project migrates with no manual step. To change which runner an agent uses, set agents.<name>.runner (a runner kind) or a house model in runners.

Route a slot to review work by giving it the review tag. See 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. Per-workflow overrides live in the workflow file; per-task overrides live on the task frontmatter.

FieldTypeRequiredDefaultDescription
checks.locallist of stringno[]Shell commands run in the worktree root before handoff to CI. Each entry is one command line.
ci_timeoutduration (seconds)no900 (15m)How long Zen waits for CI to report before timing out the promotion.
danger_pathssee belownoextend 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:

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 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 60s → 2m → 4m → 5m, then resets to 60s the moment something happens.

heartbeat accepts three shapes:

FormMeaning
heartbeat: 60sBare duration — sets interval and keeps the default max (5m).
heartbeat: offDisables heartbeats entirely.
heartbeat: { interval: 60s, max: 5m }Map — sets both bounds explicitly.

The map form's fields:

FieldTypeRequiredDefaultDescription
intervaldurationno60sStandard cadence, used whenever supervisable work is in flight.
maxdurationno5mBack-off cap the interval doubles toward while the board is quiescent. A maxinterval 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).

heartbeat:
  interval: 60s   # standard cadence while work is in flight
  max: 5m         # 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.

FieldTypeRequiredDefaultDescription
base_branchstringnodefault_branchBranch new work is based on and merged into. Falls back to the project's default_branch.
branchstringnononeFull 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. Mutually exclusive with branch_prefix.
branch_prefixstringnononeShorthand alternative to branch: when a task omits branch:, Shelbi cuts <branch_prefix>/<task-id>. Use it when a bare prefix is all you need and you don't want to spell out a full {{var}} template. Mutually exclusive with branch — declaring both in one git: block fails the load.
merge_strategysquash | merge | rebasenosquashHow shelbi merge integrates a workspace branch. Zen exact-ref auto-integration currently supports squash; other strategies require human PR merge.
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}}'.

review

Behavior of the review interface's View Diff action.

FieldTypeRequiredDefaultDescription
diff_commandstringnononeCommand View Diff runs instead of the default git difftool -d -y <base> HEAD. Set it when your diff tool reviews a revision range rather than git's directory-diff pair. See below.

By default View Diff runs git difftool -d -y <base> HEAD — git's directory-diff mode, which invokes your diff.tool with two directory paths. A tool whose CLI takes a revision range instead (such as skim, invoked skim <base> <head>) can't interpret those directories: it renders nothing and leaks git warnings into the pane. Point review.diff_command at the tool's revision-range invocation to bypass directory-diff entirely.

The command runs in the review worktree with three placeholders substituted (shell-escaped):

  • {worktree} — the review workspace worktree,
  • {base}merge-base(<base branch>, HEAD), the fork point, and
  • {head}HEAD.

{base} and {head} are the same range shelbi diff reports, so the override reviews the same changeset the default path does.

review:
  diff_command: skim {base} {head}

Leave diff_command unset and View Diff keeps the default git difftool -d -y path, so existing setups are unchanged. Either way, a diff tool that exits without rendering leaves a short message in the pane rather than only git warnings.

The PR-body template (pr-template.md)

Alongside the project's YAML, workflows/, agents/, and zenmode.md, each project carries a pr-template.md. It is the guidance a developer worker follows when it authors the pull-request body for a finished task. Shelbi materializes it from a shipped default on shelbi init, self-heals it on shelbi reload, and the boot-time config-upgrade pass materializes it for projects that predate the file. Like zenmode.md and the agent instructions, it is user-editable and self-heal-preserving: your edits survive a reload byte-for-byte, and editing the file changes the guidance workers follow for subsequent PRs.

Not a substitution template

Unlike the git.branch template above, pr-template.md has no {{var}} placeholders and does no variable substitution. It is instructions to the worker, not a fill-in-the-blanks form. Only an agent that understands the change can write a real summary, produce a Mermaid diagram, sketch an ASCII wireframe, or list a concrete QA checklist, so the worker reads the template and writes the body itself.

Where it lives

The path is config-mode-aware, resolving next to the rest of the project's config half:

ModeWhere pr-template.md lives
Global (default)~/.shelbi/projects/<id>/pr-template.md
In-repo<repo>/.shelbi/pr-template.md

What the shipped default asks for

The default template tells the worker to write the body for the human reviewer, grounded in the real problem the change solves rather than a copy of the task requirements, in these sections, top to bottom:

  1. Summary: a few sentences on the feature or fix and the problem it solves, so the reviewer understands the why before the how.
  2. Technical details: key design decisions and rejected alternatives, tradeoffs, risky or subtle areas, and any migration or rollout notes.
  3. Data model changes: a conditional section, included only when the change touches a database or schema, with a Mermaid ER or class diagram GitHub renders natively.
  4. UI changes: a conditional section, included only when the change alters the interface, with a shareable screenshot when one exists or an ASCII wireframe otherwise.
  5. QA checklist: concrete, checkable items a reviewer or tester can run through, using GitHub task-list syntax.

Conditional sections are omitted entirely when they do not apply, so a non-schema, non-UI change carries only sections 1, 2, and 5.

How a worker uses it at handoff

When a worker finishes a task it reads pr-template.md, writes the finished body to .shelbi/pr-body.md in its worktree, then emits its ready marker as usual. The orchestrator picks up .shelbi/pr-body.md when it opens the pull request. If the file is absent (a worker that genuinely could not produce a useful description just does not write it), Shelbi falls back to a task-body-based PR, so a missing description never blocks the handoff.

See also

  • Config modes — global vs. in-repo layout and the shared/user-local field split.
  • Config upgrade: the boot-time pass that materializes pr-template.md for older projects and heals drifted config.
  • Workspaces and the orchestrator — the concepts behind workspaces: and orchestrator:.
  • Workflow config — the per-project status pipeline and its per-workflow zen / git overrides.