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.
| 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
environmentis a list, not a mapping.environment: {KEY: value}fails with “must be a list”. Writeenvironment: ["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 orenvinside the workspace is supported; changing the runtime or port contract means creating a new workspace. - Reserved names.
pccannot be a port name, andenvkeys cannot start withBERTH_,GIT_DIRorGIT_WORK_TREE, because berth injects those into every process. - Probe ports follow the execution context. A container probe uses the internal
listenport, neverBERTH_HOST_PORT_*. copy_dirsand.worktreeincludediffer.copy_dirscopies whole dependency trees with copy-on-write where supported;.worktreeincludecopies individual files and is a plain path list..berth/is machine state. It holds the private data directory, the generatedpc.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(orberth hook install cursor) mergesberth adopt --setupinto.cursor/worktrees.json, which Cursor runs inside every worktree it creates in the Agents Window, the IDE or the CLI. The threesetup-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 windsurfappends the same command topost_setup_worktreein.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 claudeadds aWorktreeCreatehook to.claude/settings.jsonas a sibling of the events already configured there, preserving every other key. It is opt-in —alldeliberately skips it — because Claude Code aborts worktree creation when aWorktreeCreatehook exits non-zero. The installed command therefore ends with|| exit 0: a failed adoption prints its diagnosis on stderr and leaves the checkout forberth 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.