Skip to main content
announcement — llm-ops

berth: ports your agents and your terminal agree on

8 min read

I run a lot of Claude Code sessions at once. Each one is working in its own repository, and each one, sooner or later, starts something that listens: a Vite dev server, a Next app, a Postgres container, a mail sink. Vite wants 5173. Next wants 3000. Postgres wants 5432. The first session gets the port. The second gets address already in use and does one of two things, both bad.

It increments. Now the app is on 3001, the summons email it just sent still points at 3000, and the agent reports success. Or it clears the way: kill whatever is on 3000, which was another session's server, mid-test. Either way, nobody on the machine, human or agent, can answer the only question that matters: who holds 3000, and is it safe to stop?

This is the same problem as putting LLMs into production, just on a laptop: once several agents share a machine, you need a deterministic rule they all follow and a record anyone can check, not their best guesses. berth is the small tool I built to provide that. It is open source, it runs on macOS and Linux with Node 20, and it is a Claude Code plugin, so an agent session gets it for free.

The number is the rule​

Most port registries hand out numbers from a table. berth does not have a table to remember, because the number is the record:

code
port = 10000 + 1000·P + 100·W + R

P is the project's permanent number, 0 to 29. W is the git worktree, 0 for the main checkout. R is the role: 00 web, 01 api, 02 db, 03 cache, 04 smtp, 05 mail-ui, 06 docs, 07 worker, 08 and 09 for OpenTelemetry, and 10 to 99 for anything a project names itself.

Port 13204 decoded as base 10000, project 3, worktree 2, smtp; and 31010 decoded by subtracting the base first, giving project 21, worktree 0, role 10Port 13204 decoded as base 10000, project 3, worktree 2, smtp; and 31010 decoded by subtracting the base first, giving project 21, worktree 0, role 10

So reading a port is decoding it: subtract the base, then read what is left as P, W, R. 13204 − 10000 = 3204, so project 3, second worktree, the SMTP sink.

Subtract first, because for the first ten projects the digits happen to line up one per field and it is tempting to read them straight off the number. That stops at project 10, where the thousands carry into the leading digit. 31010 − 10000 = 21010: project 21, worktree 0, role 10 — not project 1, as the digits alone suggest.

Every project owns a thousand ports it can never collide inside, every worktree owns a hundred, and the numbers never change because P is permanent. Scratch servers that need no name come from a separate pool, 40000 to 41999, with an eight-hour lease.

Advisory, never enforcement​

berth does not proxy traffic, does not run a daemon, and does not kill anything. It keeps a ledger of leases (who claimed which port, from which session, for what), then reconciles that ledger against what is really listening, using lsof, netstat, Docker's Compose labels, and the session marker Claude Code puts in every process it spawns. Containers behind Colima or Docker Desktop are attributed too, not blamed on the VM's port proxy.

Each port ends up in one of eight states, and each state comes with exactly one suggested command:

statemeaningberth suggests
okleased and bound by its owner, or attributed by evidencenothing
idleleased, nothing bound yetnothing
staleleased, owner process is goneberth release
orphanleased, the checkout no longer existsberth release
unmanagedbound in a block with no lease and no attributionberth adopt
squatterbound inside another project's blockberth who
conflictlease owner and live holder disagreeberth who --json
drifta config declares a port outside its allocationberth scan --write

The suggestion is all it does. berth free refuses to signal a process another session owns, and the commands with teeth (free, anything with --force, adopting a port as a human) are refused outright when the caller is an agent session rather than a person at a terminal. The rule "berth never kills" holds even when the session running it is wrong.

What an agent session sees​

With the plugin installed, every Claude Code session starts with its ports already in context, before it has run a single command:

code
## Ports (berth)
Project berth (P=0) owns 10000–10999. This checkout is W0 (main) → 10000–10099.
Ports: web 10000 · api 10001 · db 10002 · cache 10003 · smtp 10004 · mail-ui 10005 …
Exported for this session: PORT, WEB_PORT, API_PORT, DB_PORT, … ; BERTH_BLOCK=10000-10099.
Shared observability (owned by skills-telemetry; do not start another): grafana 3000 · prometheus 9090 · …
Rules: pass the port explicitly (--port $PORT, vite --strictPort); never let a framework pick one.

The hook that injects that is read-only and budgeted at 180 ms, so it never slows a session down. The same plugin puts berth on the session's PATH, exposes six MCP tools (berth_env, berth_who, berth_check, berth_claim, berth_release, berth_ls), and ships two skills. The one I use most is onboarding. In a repository berth has never seen:

code
register this repo in berth

The agent assigns the next free project number, records the ports the repo already hardcodes (from Compose files, Vite and Next configs, .env), exports the new numbers, and writes a launch config for the desktop preview pane. Nothing is renumbered and nothing outside that repository is touched. When it finds a container that was started by hand rather than through Compose, it says so and shows the recreate command instead of writing an override that could never apply.

Using it from a terminal​

Install once, register each repository once:

bash
npm install -g @amiable-dev/berth
berth init # starting policy in ~/.config/berth/policy.toml
cd ~/projects/my-app && berth project add . # permanent number, ports found in your configs

Then let your shell follow you. This hook exports PORT, API_PORT, DB_PORT and friends when you cd into a registered project and unsets them when you leave, spawning berth only when the project root changes:

bash
# ~/.zshrc
eval "$(berth shell-init zsh)"

Day to day, four commands cover almost everything:

bash
berth check              # what is listening, who holds it, what needs attention
berth who 5432 # decode, live holder, and the evidence for it
berth claim --role api # record that this session is about to bind API_PORT
berth env --dotenv # PORT=13000, DB_PORT=13002 … for an app that reads .env

For Compose, the pattern that works with and without berth is the interpolated host port. The file keeps standard numbers as its default, and a berth shell moves them:

yaml
services:
postgres:
ports:
- "${DB_PORT:-5432}:5432"

If you would rather not touch the file, berth env --compose-override writes an override with the same effect.

The map​

berth ui serves a small dashboard on the project's own web port. The map view is one row per project: live legacy ports as numbered cells on the left, then the ten role cells per worktree, coloured by state, and after those any extras the project named for itself, each carrying its two-digit slot. Click a cell and the drawer shows the lease, the live holder, and every piece of evidence behind the verdict.

berth ui map view: one row per project, legacy ports on the left, then role cells per worktree coloured by stateberth ui map view: one row per project, legacy ports on the left, then role cells per worktree coloured by state

When a migration leaves debris behind (a stale lease for the old port, say), the agent runs berth tidy --dry-run and shows you the plan; you run berth tidy once from your own terminal to apply it.

What it deliberately does not do​

No proxy, no DNS, no daemon, no cloud. It does not rewrite your application's config. There is a written design for that — per-tool recipes, where an agent proposes the diff and a human applies it — but it is still a proposed architecture decision rather than something shipped, and even once it lands the edit stays a pull request you read. And it never decides a process should die.

Try it​

If your machine has more than one agent on it, the first berth check is usually enough to see why this exists.