berth / Documentation / Features

Features and configuration

A workspace is the unit berth manages: an isolated Git worktree, a private data directory, a reserved set of ports and a supervised process graph. This page explains each concept, documents every berth.yaml field and lists the traps that make a render fail.

Core concepts

Workspaces

A workspace combines an isolated Git worktree, a private data directory, dynamically allocated ports and a set of supervised background processes. Each workspace reads its own branch configuration, so a change to berth.yaml in one workspace never affects another.

Workspaces are created beside the repository in <repo>.berths/<slug> — or under worktree_root when it is set — on branch berth/<slug>. A slug is lowercase ASCII letters, digits, - and _ only. The primary checkout is never a workspace, and the commands that take no slug (berth run, berth open) resolve the workspace from the current directory, so run them from inside the path that berth new printed.

Dual runtime modes

Native runs ordinary processes directly on the host. It relies on environment variables and command-line flags to assign dynamic ports and data paths, which makes it the fastest option with zero container overhead. It does not isolate hardcoded loopback ports, absolute storage paths, Unix sockets, registries or shared credentials.

Container runs one reusable Linux container per workspace using an existing local Docker or Podman engine. It preserves hardcoded loopback ports through internal forwarding gateways: if the application insists on binding 8080 or 5432, the container isolates the network namespace while host traffic still reaches the service.

Port allocation

Ports are declared by name in berth.yaml — ports: [web, pg] — and every workspace receives unique host ports for those names. The reservation happens in the same atomic transaction that registers the workspace, so two concurrent berth new runs cannot be handed the same value, and the assignment stays stable for the lifetime of the workspace.

Programs read the result from the environment: BERTH_PORT_WEB for the port inside the execution context and BERTH_HOST_PORT_WEB for the host publication on 127.0.0.1.

Private data and state isolation

Each workspace gets its own data directory: .berth/data in native mode and /workspace/.berth/data in container mode, exported as $BERTH_DATA_DIR. Databases, caches and scratch files stay isolated to the workspace. Writable dependencies can be copied with copy-on-write on filesystems that support it — Linux reflink, macOS clonefile — so a large dependency tree does not double disk usage, and an independent copy is taken everywhere else.

Daemonless operation

berth runs no continuous background daemon. It relies on file locks and a single machine-level registry file. Long operations hold their own workspace lock rather than a global machine lock, so separate workspaces proceed concurrently while commands inside one workspace serialize. Process supervision is delegated to process-compose or to the container runtime.

Configuration reference: berth.yaml

berth.yaml lives at the repository root and is read from each workspace’s own branch, so commit it before creating a workspace.

All fields are optional except version and the process command; runtime.* and listen are container-only.
Field Notes
version Must be 1.
base Baseline branch for new workspaces; defaults to main.
worktree_root Where worktrees are created. Relative values resolve inside the repository, absolute values do not. Defaults to the sibling <repo>.berths directory.
runtime.backend native or container; defaults to native.
runtime.engine docker or podman; container only, defaults to docker.
runtime.image Prebuilt Linux image; container only and mandatory.
runtime.memory, runtime.cpus, runtime.user Optional container overrides such as 2g, 2 or a UID/GID. Native mode rejects them.
ports Named ports such as [web, pg]. A name becomes BERTH_PORT_<NAME>. The name pc is reserved.
listen Container listen port per declared port, for example {web: 8080}. Container mode requires one entry per declared port.
env Extra variables for every process, hook and berth run. Keys may not start with BERTH_; GIT_DIR and GIT_WORK_TREE are reserved. Values stay single-line and expand ${VAR} from the environment contract.
env_file Repository-relative file berth writes with a managed # BEGIN BERTH / # END BERTH block, for host tools that read dotenv files.
copy_dirs Repository-relative directories copied into each new workspace, with copy-on-write where the filesystem supports it.
hooks.setup Shell commands that run on create, on up after a failed setup, and on reset. They must be idempotent.
hooks.teardown Shell commands that run before an owned workspace is removed.
processes.<name>.command Required for a managed process.
processes.<name>.working_dir Defaults to the workspace root (/workspace in container mode).
processes.<name>.environment A list of KEY=VALUE strings, not a mapping. berth prepends its identity variables and refuses BERTH_* and GIT_* overrides.
processes.<name>.readiness_probe http_get (host, port, path) or exec (command), with initial_delay_seconds, period_seconds and failure_threshold.
processes.<name>.* The mapping is passed through to process-compose v0.5, so its other documented fields — log_location, depends_on, restart — work too.
gc.idle_stop_hours berth gc stops the runtime of a workspace unused for this long. 0 disables.
gc.remove_after_days Age beyond which a stopped, merged workspace becomes a removal candidate. 0 disables.
gc.max_workspaces Per-repository quota; berth gc evicts above it once commits are preserved. 0 disables.

Worktree include file

A .worktreeinclude file at the repository root lists repository-relative paths, one per line, with # for comments. Each listed path is copied into a new worktree during setup — berth new, berth reset, berth adopt --setup — and entries that do not exist are skipped.

It is a plain path list, not a .gitignore pattern file: use it for individual local gitignored files such as .env, and use copy_dirs for whole dependency trees.

Practical examples

Native Node.js web application

version: 1
base: main
ports: [web]
env:
  PORT: ${BERTH_PORT_WEB}
processes:
  web:
    command: npm run dev -- --port "$BERTH_PORT_WEB"
    readiness_probe:
      http_get:
        host: 127.0.0.1
        port: "${BERTH_PORT_WEB}"
        path: /

Native application with a managed PostgreSQL service

The database lives in $BERTH_DATA_DIR, so each workspace initialises and migrates its own cluster while sharing the installed binaries.

version: 1
base: main
ports: [web, pg]
env:
  PORT: ${BERTH_PORT_WEB}
  DATABASE_URL: postgres://127.0.0.1:${BERTH_PORT_PG}/app
hooks:
  setup:
    - mkdir -p "$BERTH_DATA_DIR/pg"
    - test -d "$BERTH_DATA_DIR/pg/base" || initdb -D "$BERTH_DATA_DIR/pg" --no-locale --encoding=UTF8
processes:
  pg:
    command: >
      postgres -D "$BERTH_DATA_DIR/pg" -p "$BERTH_PORT_PG" -k "$BERTH_DATA_DIR"
      -c fsync=off -c synchronous_commit=off
    readiness_probe:
      exec:
        command: pg_isready -h 127.0.0.1 -p "$BERTH_PORT_PG"
  web:
    command: npm run dev -- --port "$BERTH_PORT_WEB"
    readiness_probe:
      http_get:
        host: 127.0.0.1
        port: "${BERTH_PORT_WEB}"
        path: /

Container mode with a fixed listen port

When the application hardcodes port 8080 and cannot read dynamic environment variables, declare the original port under listen and let berth publish it on the host.

version: 1
base: main
runtime:
  backend: container
  engine: docker
  image: berth-runtime:local
ports: [web]
listen:
  web: 8080
processes:
  web:
    command: python3 -m http.server 8080 --bind 127.0.0.1
    readiness_probe:
      http_get:
        host: 127.0.0.1
        port: 8080
        path: /

Note that the probe runs in the execution context, so it uses the internal listen port (8080) rather than BERTH_HOST_PORT_WEB.

Accelerating data initialisation with copy-on-write

For large databases, running full migrations in every workspace is slow. Keep a cleanly initialised template directory and clone it instantaneously:

version: 1
base: main
ports: [pg]
hooks:
  setup:
    - |
      if [ ! -d "$BERTH_DATA_DIR/pg/base" ]; then
        cp -a --reflink=auto .seed/pg "$BERTH_DATA_DIR/pg" 2>/dev/null || \
        (mkdir -p "$BERTH_DATA_DIR/pg" && initdb -D "$BERTH_DATA_DIR/pg" --no-locale --encoding=UTF8)
      fi

On Linux with btrfs or xfs, and macOS with APFS, files clone in milliseconds without consuming initial disk space.

Traps that fail a render

  • environment is a list, not a mapping. environment: {KEY: value} fails with “must be a list”. Write environment: ["KEY=value"].
  • The runtime contract is immutable. Changing runtime.* or the named-port set makes an existing workspace unusable (“port contract changed; create a new workspace”). Changing processes, hooks or env inside the workspace is supported; changing the runtime or port contract means creating a new workspace.
  • Reserved names. pc cannot be a port name, and env keys cannot start with BERTH_, GIT_DIR or GIT_WORK_TREE, because berth injects those into every process.
  • Probe ports follow the execution context. A container probe uses the internal listen port, never BERTH_HOST_PORT_*.
  • copy_dirs and .worktreeinclude differ. copy_dirs copies whole dependency trees with copy-on-write where supported; .worktreeinclude copies individual files and is a plain path list.
  • .berth/ is machine state. It holds the private data directory, the generated pc.yaml, the supervisor log and the identity marker. Keep it out of version control and never hand-edit it.

Environment variables

berth injects the following into every hook, service and berth run command:

Variable Meaning
BERTH_WORKSPACE Absolute path to the workspace directory
BERTH_ROOT Path to the primary Git repository
BERTH_DATA_DIR Private data storage path for this workspace
BERTH_SLUG Workspace slug identifier
BERTH_BRANCH Git branch associated with this workspace
BERTH_PORT_<NAME> Internal listening port for the named service
BERTH_HOST_PORT_<NAME> Published host port on 127.0.0.1 for browser access

In container mode, additional Git metadata variables are provided: GIT_DIR, GIT_WORK_TREE, HOME and XDG_CACHE_HOME.

What the container image must provide

runtime.backend: container needs an existing Linux image and a listen entry for every declared port. The image must contain bash, sh, sleep, socat, git, python3 and process-compose v1.122.0 alongside the project toolchain. The checked-in runtime/Dockerfile in the berth repository is the reference and takes BASE_IMAGE as a build argument.

Build dependencies into the image once rather than on every run: berth never builds or pulls an image implicitly, and it never falls back to running a command on the host when the engine or image is missing.

Integration with coding agents

berth is a plain CLI, so any agent that can run shell commands can drive it. On top of that, berth init installs one skill into the shared .agents/skills convention that most harnesses already read.

Installing the skill

berth init and a bare berth skill install deploy the skill to <repo>/.agents/skills/berth/SKILL.md, the location twelve harnesses read directly: Codex, Cursor, GitHub Copilot, Gemini CLI, opencode, Windsurf/Devin Desktop, Roo Code, Kilo Code, Zed, JetBrains Junie, Google Antigravity and pi. For Zed that location is the only skills root; for Junie and pi, project skills load only once the project is trusted.

Two harnesses do not read the shared convention, so they get their own copy when they are named explicitly:

Command Writes
berth skill install <repo>/.agents/skills/berth/
berth skill install --agent claude <repo>/.claude/skills/berth/
berth skill install --agent cline <repo>/.cline/skills/berth/
berth skill install --all Every path above
berth skill install --scope user The same relative paths under your home directory, never inside the repository

--agent accepts a comma-separated list and may be repeated; an unknown name fails with the list of valid names. Project scope writes only inside the repository, user scope only inside the home directory. Every install is idempotent: a repeat run rewrites nothing and reports already installed. berth writes nothing into AGENTS.md, GEMINI.md or any harness’s own rules file, and berth agents [--json] reports which harness reads which path and whether each artifact is currently installed.

The skill can also be installed without the binary at all: npx skills add Mrjwj34/berth. It checks for the binary first and tells your agent how to install berth when it is missing.

Installing worktree hooks

berth installs exactly one kind of hook: the moment a harness creates a Git worktree, so that worktree registers itself with berth adopt --setup. It installs no session-end, interrupt or per-tool hooks — those run on a small time budget or on the critical path of every action, where a berth down that waits for supervised processes would be cancelled rather than completed.

  • berth hook install (or berth hook install cursor) merges berth adopt --setup into .cursor/worktrees.json, which Cursor runs inside every worktree it creates in the Agents Window, the IDE or the CLI. The three setup-worktree* arrays are appended to only when they hold no berth entry: every existing command and every unknown key in the file is preserved, and a file berth cannot parse is reported instead of overwritten.
  • berth hook install windsurf appends the same command to post_setup_worktree in .windsurf/hooks.json, creating the file when it is absent. Windsurf merges hook files across system, user and workspace scope, so this adds an entry rather than replacing one.
  • berth hook install --agent claude adds a WorktreeCreate hook to .claude/settings.json as a sibling of the events already configured there, preserving every other key. It is opt-in — all deliberately skips it — because Claude Code aborts worktree creation when a WorktreeCreate hook exits non-zero. The installed command therefore ends with || exit 0: a failed adoption prints its diagnosis on stderr and leaves the checkout for berth gc, and it can never block Claude.

Every hook file is written through a temporary file and a rename, because no harness documents a locking or partial-write contract for third-party hook installation. Running any install twice is a no-op.

The remaining harnesses are driven by the skill alone. Codex, opencode, Kilo Code, pi, Gemini CLI and Antigravity document no repository-side worktree setup hook, and Junie, Cline and Antigravity create worktrees with no hook surface at all, so those worktrees are registered manually with berth adopt --setup or berth new <slug>.

Session-end hooks are deliberately not installed anywhere. Codex caps its synchronous SessionEnd at a few seconds while berth down waits for processes to exit, and the other harnesses do not promise an event when a worktree or conversation goes away. Stop work explicitly with berth down (keep data) or berth done (release the workspace), and let berth gc reclaim what was abandoned — gc.idle_stop_hours makes it stop idle runtimes.

Guiding your agent

With the skill installed, you can ask for work directly in an isolated workspace:

Prompt: “Create a new workspace named auth-refactor using berth, start the services, and implement the token renewal endpoint.”

The agent uses the embedded skill to run berth commands, verify service health with berth status --json and read failures from berth logs <proc>, then runs tests through berth run inside the dedicated environment.