There are two of these files, and the split between them is a decision rather than an accident.
| File | Holds |
|---|---|
The root pithy.config.ts | Project identity and policy — only what cannot be per Worker |
apps/<name>/pithy.config.ts | What that Worker is made of |
Together with wrangler.jsonc and a four-line mount file, that is the entire surface you own.
Why capabilities are per Worker
Project structure is where these files sit, the configuration schema is every field, and the Worker contract is what the config assembles.
Everything a capability drives is per Worker: the composed route tree, the bindings written into that Worker’s wrangler.jsonc, and Durable Object class migrations — which register a class against a specific script.
So a Worker declares what it is made of, and a KV-only Worker never sees a D1 it does not use.
The root config
Four things, and each is there because it cannot be per Worker.
const config = {
name: "acme",
// environments: ["staging", "prod"],
// cloudflare: { accountName: "acme", accountId: "" },
// tokens: { overrides: {} },
// seed: { productionEnvironments: ["live"] },
};name is the leading segment of every Cloudflare name this project provisions, and the only key teardown can find them by. It stops at 26 characters and is effectively permanent — Naming conventions covers why.
environments is every environment this project deploys to, in the order provisioning walks them — least-production first, so a mistake is made in staging before it is made in production. Absent means staging and prod. dev is never listed: it is local, it is the top-level wrangler stanza rather than an env.dev, and it always exists.
It is the one answer to what environments does this project have, and everything that iterates environments reads it. Like name, renaming one does not rename anything provisioned under the old name — it orphans it.
cloudflare says which account this project belongs to, and it earns its keep the moment more than one person deploys.
accountName selects the credentials file. Without it, every project on the machine reads the same one — so a developer working across two companies switches accounts by editing that file in place, every project silently follows, and the next deploy authenticates successfully against the wrong tenant with nothing anywhere disagreeing.
accountId pins the account those credentials must belong to. Every command that resolves them compares the two and refuses on a mismatch, naming both. The nickname means whatever each machine says it means; this is what makes the repository the authority. An account id is an identifier rather than a secret — wrangler configs commit them — so it is safe here, including in a public repository.
seed.productionEnvironments names every environment this project treats as production beyond the built-in ones. Each then requires the type-to-confirm phrase rather than just a flag — a safety rule no single Worker should be able to quietly omit.
The Worker config
const config = {
domains: DOMAINS,
capabilities: [
// pithy:capabilities (managed region — do not remove this marker)
],
app,
};capabilities is the managed region. pithy add writes into it, in dependency order, and the marker is how it finds the region again. Editing what is inside it is fine; deleting the marker means the next add cannot place anything.
app is your own capability — routes, middleware, tables, bindings, English. It composes last, after every library capability. What is a capability? is the model.
domains is where this Worker answers, per environment. It is worth its own section.
The origin is declared once, and derived everywhere
const DOMAINS = {
staging: { pattern: "staging.api.example.com", zone: "example.com" },
prod: { pattern: "api.example.com", zone: "example.com" },
};
export const PUBLIC_ORIGIN = originFor(compositionEnvironment(), DOMAINS);
const config = { domains: DOMAINS, capabilities: [ … ], app };Every capability that needs a public origin asks the same question. Auth builds OAuth callbacks, magic-link URLs and the CSRF allowed-origin from one. Email builds tracking and unsubscribe links. Payments’ return URLs decide where a browser lands after checkout.
Write a URL into any one of them and you have written one environment’s origin into all of them. Three of them, one mistake, three separate discoveries: staging mails real users links into production, an unsubscribe from a staging test unsubscribes them in production, and a staging payer lands in production on an account that bought nothing.
So it is derived. originFor is the one answer to where is this Worker reachable, and it is the same call the CLI makes to generate the BASE_URL var — so the Worker’s runtime origin and the origins its capabilities were configured with cannot disagree.
The declaration is hoisted because the origin has to exist before the capabilities that take it are constructed, and domains: DOMAINS beside them is the same object: one declaration, two readers.
pithy add writes PUBLIC_ORIGIN unquoted for every option whose manifest says its value is an origin. A --set override still wins, because a Worker fronted by something Pithy does not know about has an origin no derivation can produce.
An environment domains does not name resolves to localhost, never to another environment’s origin. An undeclared environment is an unpublished one, so the fallback fails closed: a link that goes nowhere is useless rather than harmful. A deployed environment cannot keep it — pithy deploy --env <name> refuses one that declares no origin.
Name the constant for the Worker rather than for the capability that asked first. The first project to write this called it AUTH_BASE_URL, and that is part of why email and payments kept their hardcoded URLs for days.
One origin deliberately does not derive: the control-plane issuer. It is an identity rather than an address, and a per-environment issuer would make a connection minted in staging unverifiable in production.
Declining an optional binding
A capability marks a binding optional when its own code has a path for the binding’s absence. Usually you never think about it: a capability declares one only when its config asks for the feature behind it.
Sometimes the feature is on and the resource is deliberately not there. Name the binding, with the reason:
declinedBindings: {
SUPPORT_BUCKET: "no R2 in this account yet; the inbox runs without stored bytes",
},pithy upgrade then leaves it out, and pithy doctor reports it as declined rather than missing — so a stanza you delete by hand stays deleted instead of coming back on the next upgrade.
The reason is required, and it is the point. A binding simply absent is indistinguishable from one somebody forgot, which is the state this replaced. Doctor prints your sentence back on every run, so write it for whoever reads the report next.
Reach for the capability’s own config first: turning attachments off is a better answer than declining the bucket, because the capability then declares nothing at all.
Three declines are refused outright — a required binding, a Workflow, and a Durable Object. Optional is the capability’s statement that its code has a path for the absence; a required binding has none. On a Workflow, optional means not provisioned yet, so declining only hides the instruction. And a Durable Object’s class migration tag is written once and never revisited, so a decline arriving after an upgrade cannot undo what that upgrade stamped.