Start the local development environment: every Worker in apps/, plus each composed capability’s host Worker and any front end, under one supervising process.
Synopsis
pithy dev [--json]Flags
| Flag | Meaning |
|---|---|
--json | Machine-readable output, one object per line. Default false |
What it does
One command runs the whole backend, so you never hand-juggle terminals or ports.
It discovers your Workers from apps/
apps/ is the registry. pithy dev enumerates apps/* — there is no hand-maintained list — and reads each Worker’s co-located pithy.worker.jsonc, a file you own sitting beside wrangler.jsonc, which stays wrangler’s. Its dev block carries:
| Key | Meaning |
|---|---|
dev.autostart | Does this Worker need to run for the local environment to function? |
dev.readySignal | The regex marking “ready” in its output. Default /Ready on https?:\/\// |
dev.preferredPort | Optional |
dev.command | Optional. Run a non-Worker process — a Vite front end with no wrangler.jsonc — instead of wrangler dev |
Discovery keys on pithy.worker.jsonc, so such a process can join the dev set. Add, remove or rename a Worker with pithy worker and the dev set follows automatically.
It runs each composed capability’s host Worker too
apps/ is the app Worker registry. A capability that owns Workflows ships a prebuilt host Worker that pithy <capability> provision deploys, and none of them lives in apps/. pithy dev starts those as well: it reads each app Worker’s pithy.config.ts, and for every composed capability that owns a host it resolves that capability’s committed wrangler.jsonc template into a local config under .wrangler/pithy/hosts/<capability>/ — git-ignored, regenerated every run — and starts it.
A host is an ordinary member of the dev set. Its own pinned port, its own label and color in the terminal and in logs/dev.log, its own entry in .dev-state.json, reaped with everything else. It is registered under the capability’s own name, so its siblings reach it at EMAIL_ORIGIN, MEDIA_ORIGIN and so on; an apps/ Worker already using that name is refused rather than silently shadowed.
Locally a host binds its databases by binding name — the same names pithy migrate --env dev filled — so a first pithy dev boots rather than erroring on a missing table. This is why mail sent from localhost now goes somewhere: the app Worker’s EMAIL_SENDER Workflow named a Worker pithy dev did not run, so every enqueued message sat pending while the UI reported success.
It sends real mail from your machine, and says when it cannot
The email host’s send_email binding runs with remote: true in dev by default, so a magic link you trigger from localhost is delivered through Cloudflare Email Service for real — the same pipeline, the same DKIM, the same delivery logs as production.
That needs a Cloudflare login and a sending domain already onboarded. pithy dev checks what it cheaply can before spawning anything: with no credentials, or a from address on a domain nobody can onboard, it resolves the host for its local simulator instead, says so with the command that fixes it, and starts the session anyway. The simulator logs the sender, recipient and subject, and writes the rendered HTML and text bodies to disk.
The verdict is said once, in the ready banner, where you actually look.
The preflight is not the guarantee. A failure that only appears when the binding starts, or at the first send, is caught in the host’s own output, rendered as a problem line and an action line, and never kills the session: the host is re-resolved for its simulator on the spot, so the sends that follow are logged and written to disk rather than lost. email({ devDelivery: "simulator" }) selects the simulator deliberately, and every deployed environment always sends for real.
It runs a front end as part of the set
A Worker scaffolded by pithy ui add does not get a second process. Its dev.command replaces wrangler dev with Vite, and Vite serves the SPA and the Worker on that Worker’s one pinned port.
The command is argv, and the token {port} in any argument is substituted at spawn time — ["bun", "x", "vite", "dev", "--strictPort", "--port", "{port}"] runs with the real port. {port} is the only token substituted.
It supervises, labels, and says when something never arrived
Every autostart Worker is spawned, its output labeled and colorized, and everything teed to the terminal and to logs/dev.log. One ready banner prints when every started Worker has matched its dev.readySignal.
A Worker that starts and never becomes ready is a live child. wrangler dev does not exit when a build fails — it prints the error and keeps running — and the same shape covers a startup that hangs, a port that never binds, and a dev.command process that comes up wrong. The banner waits on the whole set, so it never fires, and the session used to proceed looking healthy with the real error forty lines up the scrollback.
So: 90 seconds after the last Worker is spawned, pithy dev says Still waiting on: support. — every Worker still missing, by name — and repeats it every 30 seconds while it stays true. The clock starts at the spawn rather than at the command, because everything before it is tens of seconds on a cold project and none of it is a Worker being slow.
It is still waiting, not failed: the first wrangler dev of a session pays for a cold bundle, and a slow Worker is not a broken one. A Worker that arrives late drops out of the next line on its own.
A Worker that never arrives is reported, never killed. One child exiting tears the whole session down, and stopping every healthy Worker over one Worker’s typo is a worse trade than a line naming it. The report names a restart, because a wrangler dev whose first build fails never rebuilds — fixing the file and waiting is the one thing that cannot work.
It resolves ports safely, and wires the Workers to each other
Each Worker’s start port is the one pinned in this worktree’s port block, verified rather than probed. A port is used only if free on both 127.0.0.1 and ::1 — Vite binds IPv6-only, wrangler binds both. If a pinned port is taken, the run reports a conflict and stops rather than drifting: a Worker that quietly moves breaks every sibling that was told its address ahead of time.
Resolved ports are exported as env, and the cross-Worker URLs are baked in as *_ORIGIN dev vars, so Workers call each other directly instead of relying on wrangler’s cross-process service registry.
It generates every Worker’s .dev.vars
wrangler loads a .dev.vars from the directory it runs in and merges nothing, so each apps/<worker>/ needs its own — and each one is written here, from sources that never leave your machine: every cf-secrets-store secret your registry declares, read straight from <config>/<project>/secrets.jsonc, plus whatever in <config>/<project>/dev.json no registry declares, overridden by the repo’s root .dev.vars.local, overridden in turn by that Worker’s own.
The dev secrets file is the source, not a file something copies out of. Edit a value there and the next pithy dev hands the Worker the new one, with no pithy seed in between; delete one and it is gone from every generated file, with no stale copy anywhere to fall back to.
pithy init writes no .dev.vars at all, a clone has none, and pithy dev is the command that runs every time — unlike a postinstall, which runs before the values exist.
Each generated file opens with a marker, and a .dev.vars Pithy did not write is never overwritten and never merged: it is named, .dev.vars.local is offered as the place for local values, and that Worker starts without one rather than with somebody else’s file replaced underneath it. Writing is idempotent by content rather than mtime, so a second run writes no bytes and wrangler’s watcher has nothing to react to.
Signing in: press l
pithy seed can mint a real, signed-in session for a seeded user. pithy dev is where you use it.
The ready banner names the user, and nothing else. Dev login: ada@example.com — press l to open a signed-in browser. No session cookie is ever printed, to the terminal or to logs/dev.log. It used to be — a document.cookie = "…" line to paste into a browser console — and a working session token rendered as text is a working session token at rest in a scrollback, a log and a screenshot. The value now travels from the Worker to the browser over HTTP and lands nowhere else.
l opens the browser you already use. It opens http://localhost:<port>/__pithy/dev-login with the platform’s own opener — xdg-open, open, start — so it works in whatever browser is default, from a second profile, and from an incognito window. That route sets the cookie and redirects to /. Reload nothing; you are signed in.
The route exists only in a dev composition, and never under CI. @pithy-sh/auth registers GET /__pithy/dev-login behind two independent gates, both at registration rather than inside the handler: the composition’s ENVIRONMENT must be dev, and CI must be unset or blank. A staging or prod Worker does not carry the route at all, and neither does a dev Worker started by a CI job. It mints an authenticated session with no credential presented, so neither gate is allowed to imply the other.
Which Worker. The candidates are the started Workers that compose auth — a cookie is scoped to the origin that set it, so no other origin can be signed in by opening it. With one candidate, l opens it. With several, the one carrying a front end wins; if that does not decide, pithy dev prints the choices rather than guessing.
No seeded session, or an expired one, gets a line naming pithy seed rather than a URL that 404s. A piped run and any run in CI never enter raw mode and never wait for input — the banner prints the URL instead. --json starts no key handling at all.
Bound today: l. Nothing else.
Session state and cleanup
- A git-ignored
.dev-state.jsonrecords the pid, the resolved ports and the child pids. - A re-run stops the previous session first, then reaps orphaned
workerdandwranglerprocesses still holding the default ports, so a crashed session cannot block startup. - Children are spawned via
setsid, so one signal to the process group tears down the wholewrangler → workerdsubtree. Teardown is a gracefulSIGTERM, thenSIGKILLafter a short grace window.
Ports, and running several checkouts at once
Port collisions are the one thing that stops two feature worktrees running simultaneously — and, since every project starts at the same base port, two projects as readily as two worktrees.
The fix is a registry every checkout reads before it assigns, held once per machine rather than once per checkout: dev-ports.json in the Pithy config directory. It is keyed by main-checkout root, then by branch, each value the contiguous block that branch owns.
{
"/home/jo/code/acme": {
"main": { "block": 0, "base": 8787, "size": 20 },
"feature/12-auth": { "block": 1, "base": 8807, "size": 20 }
},
"/home/jo/code/other-app": {
"main": { "block": 2, "base": 8827, "size": 20 }
}
}It used to sit at the repo root, which meant every project on a machine kept its own, every one of them started empty, and every one of them handed out block 0 — so two projects on their default branch pinned the same twenty ports.
Each worktree also gets a git-ignored .dev.config.json recording its reserved block and pinning one port per Worker. pithy dev reads it as its start ports, which is why every Worker’s address is known ahead of time and the Workers can auto-wire to each other.
Ports are assigned at creation, never probed at startup. Probing when a Worker boots is a time-of-check/time-of-use race: two pithy dev processes in two worktrees can both observe the same port free and both try to bind it. Pre-assigning removes the race by construction.
Adding a Worker is additive. Assignment is sticky: a Worker that already holds a port keeps it, and only genuinely new Workers are assigned. Discovery is alphabetical, so purely positional assignment would renumber every later Worker the moment somebody added one that sorts earlier — moving addresses out from under a running session.
Worktree development is the whole story, including pithy feature sync and how the registry heals after a wiped config directory.
--json
Every line on stdout is one object. The session keeps running, so a script reads pithy dev --json line by line rather than waiting for it to end.
Everything said to a person moves to stderr under --json — the Starting … line, the delivery verdict, a .dev.vars refusal, and the Workers’ own output, which is the bulk of the stream. It used to share stdout with the JSON, so pithy dev --json | jq choked on the first thing wrangler said. Splitting by descriptor costs a person nothing: both halves still reach the terminal, and logs/dev.log carries the lot in either mode.
The session line
Written as soon as every Worker is started. It is what tells a script where the Workers are.
{"command":"dev","workers":{"api":{"port":8787,"origin":"http://localhost:8787"},"web":{"port":8788,"origin":"http://localhost:8788"}}}| Key | Type | Meaning |
|---|---|---|
command | string | "dev" |
workers | object | One entry per started Worker, keyed by its name |
workers.<name>.port | number | The port that Worker was assigned in .dev.config.json, verified free before it started |
workers.<name>.origin | string | The localhost address its siblings were told to call it on |
Those four keys are the whole session line. It carries no dev-login field: the ready banner is suppressed under --json, no key handling starts, and a session cookie has no business in a machine-readable line any more than in a human-readable one. A script that wants the dev login builds the URL from an origin above plus /__pithy/dev-login.
The still-waiting line
Written 90 seconds after the last Worker is spawned, and every 30 seconds after that, while any Worker has started and not become ready. Not written at all when every Worker arrives in time.
{"command":"dev","event":"still-waiting","waiting":["support"]}| Key | Type | Meaning |
|---|---|---|
event | string | "still-waiting". What distinguishes this line from the session line |
waiting | string[] | The Workers that have started and not matched their dev.readySignal yet, in start order. Read afresh at every report, so a Worker that arrives late is gone from the next one |
The session line cannot carry this: it is written the moment the children are spawned, and readiness is decided after it. A run whose support Worker cannot build emits exactly the same session line as a healthy one.
Errors
pithy dev supervises, so most of what can go wrong is reported and survived rather than thrown.
- A pinned port is taken. The run reports the conflict and stops. It never drifts to another port.
- A
.dev.varsPithy did not write. Never overwritten and never merged. The file is named, and that Worker starts without one. - A Worker whose
.dev.varscould not be written. Named, and every other Worker still starts. - A Worker exits. The rest come down with it.
SIGINTorSIGTERMtears the session down the same way. lwith no browser to open. One line naming the URL to open by hand. The session keeps running — no browser is not a reason to stop supervising Workers.
Examples
pithy dev
pithy dev --json