Manage the project’s Workers under apps/ — the registry every other command discovers.
Adding a second Worker is the walkthrough; two Workers sharing a binding name share one database; and pithy dev runs every Worker it finds.
Synopsis
pithy worker add <name> [--skip-install] [--json]
pithy worker list [--json]
pithy worker remove <name> [--json]
pithy worker rename <from> <to> [--force] [--json]
pithy worker sync [--worker <name>] [--env <environment>] [--json]apps/ is the registry. There is no hand-maintained list: pithy dev, pithy deploy, pithy migrate and the rest enumerate apps/* and read each Worker’s pithy.worker.jsonc. Add, remove or rename a Worker here and the dev set follows automatically.
Only rename reaches Cloudflare, and only to ask a question. It asks the account which scripts are live under the old name before anything moves; it never writes there. add, list, remove and sync touch no account at all — sync in particular writes config and runs no deploy, so it is safe on any branch, at any time, as often as you like.
Flags
| Argument or flag | Applies to | Default | Purpose |
|---|---|---|---|
<name> | add, remove | required | The Worker. add takes a new kebab-case name; remove takes the name worker list shows or the apps/<dir> basename |
<from> <to> | rename | required | The Worker to rename, and its new kebab-case name |
--skip-install | add | false | Skip the workspace install after scaffolding |
--force | rename | false | Rename even though a script is deployed under the old name. It stays live, and the report names it |
--worker <name> | sync | resolved | Which Worker to reconcile. A single-Worker project needs no flag; several prompt at a terminal and raise an actionable error under --json |
--env <environment> | sync | every declared one | Reconcile just this environment — its route, its Workflow bindings, its cron |
--json | all five | false | One line of machine-readable output |
What it does
add
Scaffolds apps/<name>/ and wires it in: the Worker’s wrangler.jsonc (named <project>-<name>), its pithy.worker.jsonc, its tsconfig.json and its package.json.
It then generates every discovered Worker’s .dev.vars — not only the new one, because generation is idempotent by content and a run that changes nothing writes no bytes — takes a port when there is a feature block to take one from, and installs the workspace. At a terminal it also asks where the Worker answers, per environment, against the account’s real zones, and writes the answer as a domains block.
All-or-nothing: anything that fails after the directory is made rolls it back, so the same command works on the retry.
list
Reports the discovered Workers with their autostart state and pinned dev port — which Workers exist, which autostart, which port each holds.
remove
Deletes apps/<name>/ and releases its port back to the feature’s block. The target is resolved from the discovered set and restricted to apps/*, so nothing outside it can be addressed. Your data is untouched: this deletes a directory, not a database.
sync
Writes what the Worker’s pithy.config.ts declares into its wrangler.jsonc. Two halves, one job — the declaration is the truth, and this is what makes wrangler agree with it.
The first half is the address. A domains block names where the Worker answers per environment, and sync writes the custom_domain route and the vars.BASE_URL it implies. This is the only non-interactive way to get that route written. Before it, the route was written by the domain prompt during pithy init or pithy worker add and nowhere else — so a domains block added by hand, which is the documented way to add an environment, declared an address nothing served: doctor reported it healthy, deploy shipped it, and the Worker answered on nothing.
The second half writes the app capability’s declared Workflows and cron triggers, for every environment it declares — the app’s equivalent of what pithy <capability> provision writes for a library capability’s. Three rules: the app’s entries are replaced rather than merged; an entry carrying a script_name belongs to a library capability’s provisioner and is never touched; triggers.crons is set to exactly the declared schedules. The WorkflowEntrypoint subclass stays yours to export from the Worker’s main.
pithy doctor and pithy deploy --env <name> read that half back. A declared job that was never synced used to deploy clean and never run, with nothing anywhere saying so. Which is why an app capability declaring no Workflows is reconciled too rather than skipped: dropping the last job has to take its binding and its cron out, or the fault would name a command that could not fix it. Nothing is invented for a project that never had either — no empty workflows key, no triggers block.
Idempotent, comment-preserving and all-or-nothing.
rename
A Worker’s name is three strings that have to agree: the directory apps/<name>/, the deployed script name in wrangler.jsonc and package.json, and vars.WORKER — which is what tells two Workers’ audit events apart when they share a database. This moves all three at once, comment-preserving, holding the new name to the same kebab-case rule add holds a new one to and refusing a destination that already exists.
Two things it deliberately leaves alone: the app capability’s name in pithy.config.ts, which is a migration namespace stamped into every applied row, and a script name you chose yourself — only a name carrying the Worker segment is recomputed, so a Worker migrated in as my-service keeps its name and the command says so.
A rename after a deploy is not a rename. Resource names are computed rather than stored, so <project>-<env>-<binding> survives untouched — but the Worker script is named for the Worker, so a renamed Worker deploys as a new script and leaves the old one live, serving and billing. So the account is asked first, and a live script under the old name is refused by name. --force is how you say that is understood; the report then names exactly what was left behind.
Where the account cannot be reached — no credentials, an offline laptop, a token that will not list — the rename proceeds and says it did not check. It never reports an unchecked account as a clear one, which is what accountChecked exists for.
What it does not touch is everything outside the Worker’s own directory: the root tsconfig.json references, a vitest config, a CI workflow. Those are yours, they are grep-able, and the command’s last line says to look. pithy doctor checks the three stamps agree on every run, so a hand-rename that misses one fails CI instead of a deploy.
--json
One line, one object, one shape per subcommand. The command field is the subcommand’s dotted name.
All five subcommands name the Worker the same way. worker is the apps/ directory — what you typed, what --worker accepts, and what every path in the same payload is relative to. deployedAs is the script name from wrangler.jsonc — what the Cloudflare dashboard shows, and what wrangler deploy writes.
They are two identities and not interchangeable. A Worker scaffolded as web in project acme is web and acme-web at once, and pithy worker rename can leave the two unrelated entirely. Read worker to act on a Worker, deployedAs to find it in an account.
worker.add
$ pithy worker add web --json
{"command":"worker.add","worker":"web","deployedAs":"acme-web","dir":"/repo/apps/web","port":null,"reconciled":false,"devVarsRefused":[],"domains":null}| Key | Type | Meaning |
|---|---|---|
command | "worker.add" | The subcommand that produced the line |
worker | string | The apps/ directory the Worker was scaffolded at |
deployedAs | string | The script name it will deploy under, <project>-<worker> as scaffolded |
dir | string | The absolute directory the Worker was scaffolded at |
port | number or null | The port pinned for this Worker, or null when none was — which is what a plain checkout looks like, since there is no feature block to take one from |
reconciled | boolean | Whether the feature’s .dev.config.json was rewritten. Only ever true inside a feature worktree |
devVarsRefused | string[] | One sentence per Worker whose .dev.vars this run did not generate. This command regenerates every Worker it discovers, so a sibling that will not get its bindings is named here |
domains | object or null | The domains declaration this run wrote. Always null under --json: the picker is interactive |
domains.<env>.pattern | string | The hostname |
domains.<env>.zone | string | The Cloudflare zone the hostname sits under |
worker.list
$ pithy worker list --json
{"command":"worker.list","workers":[{"worker":"api","deployedAs":"acme-api","dir":"/repo/apps/api","autostart":true,"hasWrangler":true,"port":8787}]}| Key | Type | Meaning |
|---|---|---|
command | "worker.list" | The subcommand that produced the line |
workers | object[] | The discovered Workers, in discovery order |
workers[].worker | string | The Worker’s apps/ directory |
workers[].deployedAs | string | The Worker’s wrangler.jsonc name, or the directory basename when the file declares none |
workers[].dir | string | The Worker’s directory |
workers[].autostart | boolean | Whether pithy dev starts it. From dev.autostart, defaulting to true |
workers[].hasWrangler | boolean | Whether the directory holds a wrangler.jsonc. false means a non-Worker process in the dev set, which pithy deploy skips |
workers[].port | number or null | The port pinned in .dev.config.json, or null when none is assigned |
worker.remove
$ pithy worker remove web --json
{"command":"worker.remove","worker":"web","deployedAs":"acme-web","dir":"/repo/apps/web","reconciled":true}The name argument is matched against either identity, so both pithy worker remove web and pithy worker remove acme-web find the same Worker. The payload always reports both.
| Key | Type | Meaning |
|---|---|---|
command | "worker.remove" | The subcommand that produced the line |
worker | string | The removed Worker’s apps/ directory |
deployedAs | string | The script name it deployed under |
dir | string | The directory that was deleted |
reconciled | boolean | Whether the feature’s .dev.config.json was rewritten, returning the freed port to the block |
worker.rename
$ pithy worker rename api board --json
{"command":"worker.rename","worker":"board","deployedAs":"acme-board","from":"api","to":"board","dir":"/repo/apps/board","script":{"from":"acme-api","to":"acme-board"},"orphaned":[],"accountChecked":true,"reconciled":false}worker and deployedAs describe the Worker after the move, so a caller reading the identity keys gets the same two facts here it gets from the other four subcommands. from, to and script are what a rename adds on top: the transition.
| Key | Type | Meaning |
|---|---|---|
command | "worker.rename" | The subcommand that produced the line |
worker | string | The Worker’s apps/ directory after the move. The same string as to |
deployedAs | string | The script name it deploys under after the move |
from | string | The apps/ directory basename before the move |
to | string | The new name — the directory, the script’s Worker segment, and the WORKER var |
dir | string | Where the Worker now lives: apps/<to> |
script | object or null | The deployed script name before and after, or null when the declared name carried no Worker segment to move |
script.from | string | The script name as declared before the rename |
script.to | string | The script name it deploys under now |
orphaned | string[] | Script names left live on the account under the old name. Non-empty only under --force. Read it with accountChecked — it is empty both when the account said nothing is there and when it said nothing at all |
accountChecked | boolean | Whether the account answered. false means unchecked, not clear |
reconciled | boolean | Whether the feature’s .dev.config.json was rewritten |
worker.sync
$ pithy worker sync --worker api --json
{"command":"worker.sync","worker":"api","deployedAs":"acme-api","routes":[{"env":"prod","pattern":"api.example.com","baseUrl":"https://api.example.com","changed":true}],"runs":[{"env":"dev","workflows":[{"binding":"KEY_ROTATION","name":"acme-dev-app-key-rotation","class_name":"KeyRotationWorkflow"}],"crons":["0 4 * * *"],"changed":true}]}| Key | Type | Meaning |
|---|---|---|
command | "worker.sync" | The subcommand that produced the line |
worker | string | The apps/ directory |
deployedAs | string | The deployed script name, from wrangler.jsonc |
routes | object[] | One entry per environment the domains block declares. Empty when the Worker declares none |
routes[].env | string | The environment routed. Never dev — local answers on the pinned port |
routes[].pattern | string | The hostname the custom_domain route now points at |
routes[].baseUrl | string | What vars.BASE_URL now holds: https://<pattern> |
routes[].changed | boolean | Whether that stanza moved. false on a re-run, and nothing is written |
runs | object[] | One entry per environment reconciled. Empty when the Worker declares no app capability at all; one entry each, binding nothing, when the app declares no Workflows |
runs[].env | string | The environment reconciled. dev names the top-level stanza |
runs[].workflows | object[] | The entries that environment’s workflows table now declares for the app, verbatim as written |
runs[].workflows[].binding | string | The binding name the Worker env exposes, e.g. KEY_ROTATION |
runs[].workflows[].name | string | The deployed Workflow name, <project>-<env>-<capability>-<job> |
runs[].workflows[].class_name | string | The exported WorkflowEntrypoint subclass that runs the job. Cloudflare resolves it in this script, so it must be exported from the Worker’s main |
runs[].crons | string[] | The cron schedules that environment’s triggers now carries |
runs[].changed | boolean | Whether anything moved |
That worker/deployedAs split holds across every command that reports a Worker — pithy add, pithy ui and pithy upgrade included. It did not always: four subcommands reported a single name, and it was the directory in one and the deployed script name in the other three, with nothing in the payload saying which. The two coincide whenever a project and its Worker are named alike, which is what kept it hidden.
Errors
No such Worker. remove and rename, when nothing under apps/ answers to the name.
No worker named "wbe" under apps/.
Run pithy worker list to see the workers this project has.A name that is not kebab-case.
Worker name must be kebab-case (got "Admin_API").
Use lowercase words joined by hyphens, e.g. web or admin-api.The destination already exists.
apps/board already exists.
Pick another name, or remove that directory first.A live script under the old name. The refusal names every script the account reported.
api is deployed as acme-api, acme-api-prod.
A renamed worker deploys as a NEW script and leaves that one live and serving. Delete it first, or pass --force to rename anyway and orphan it.An unreadable wrangler.jsonc. Raised before anything moves — this command edits that file.
Nothing to sync. A Worker declaring neither domains nor an app capability has nothing to write.
A domains block that is not valid. Refused with the field named, rather than written into a routes entry Cloudflare rejects at deploy.
An illegal --env. Checked first, before any config is loaded.
The project has no name. sync requires the root config’s name and never guesses it: a Workflow name is account-scoped and stable forever once deployed, so a guessed project would name a Workflow another project owns.
Examples
Scaffold a second Worker.
$ pithy worker add web
Worker web scaffolded at /repo/apps/web.
Ports are assigned when you run pithy feature create or sync.
Done.See what the project has.
$ pithy worker list
acme-api api autostart port 8787
acme-web web autostart port 8788Rename, when nothing is deployed under the old name.
$ pithy worker rename api board
Renamed api to board.
Deploys as acme-board, not acme-api.
Check anything outside the worker that names it: tsconfig, CI, imports.
Done.Rename anyway, knowing what is orphaned.
$ pithy worker rename api board --force
Renamed api to board.
Deploys as acme-board, not acme-api.
Still deployed under the old name: acme-api. Delete or keep.
Done.Write the route for a domains block added by hand, then run it again.
$ pithy worker sync --worker api
prod: routed to api.example.com. BASE_URL https://api.example.com.
Done.
$ pithy worker sync --worker api
api is already in sync.
Done.