Four design decisions explain almost everything about how Pithy behaves. None of them is a feature, and together they are why adding a capability never rewrites the ones you already have.
Logic lives in the packages and upgrades with a minor release. What you own is a config file per Worker, a wrangler file, and a mount — small enough to read in a sitting and to review in a pull request.
--eject copies a capability's source into your repo when you want it. It is an escape hatch, not the starting point.
import { defineWorker } from "@pithy-sh/core";
import { auth } from "@pithy-sh/auth";
import { payments } from "@pithy-sh/payments";
export default defineWorker({
name: "api",
capabilities: [auth(), payments()],
});
Core, every capability and your own app are the same kind of thing: an object contributing some of config, migrations, routes, middleware, workflows and bindings.
A capability reads a seam in core — who the authenticated caller is, the ability to record an audit event, the translator that knows what language this reader is in — and never another capability's internals. That is why payments can write to the audit trail without importing it, and why removing a module does not break its neighbors.
A seam is present whether or not anything fills it. With no audit capability composed the recorder is a no-op, and with no i18n capability the translator is one over the English each package contributed — so a capability writes its keys and its events with no null check, and a project that composes neither is byte-identical to one from before either existed.
// The seam, not the sibling package.
const user = requireAuth(c);
await grantEntitlement(c, user.id, "pro");
// With no audit capability composed, this is a no-op
// recorder — so the grant can never fail for want of
// somewhere to record it.
c.var.emit({ action: "payments/grant", subject: user.id });
// Same shape, different seam: with no i18n capability
// this is the English the package shipped with.
return c.json({ message: c.var.t.t("payments/granted") });
Anything multi-step that must survive a failure — sending mail, running a migration, retrying a webhook, enriching an upload — runs as a Cloudflare Workflow.
It is why a send cannot silently vanish, and why you cannot await one. Strictly more reliable, slightly less convenient, and not configurable.
// Not a send. A row, and a Workflow that owns delivery
// from here — retried, tracked, and auditable.
await c.var.email.enqueue({
to: user.email,
template: "welcome",
});
return c.json({ ok: true });
Every capability executes in your Worker, against your D1, KV and R2, in your Cloudflare account. There is no Pithy-operated service in any request path, because there is no Pithy-operated service.
Even the dashboard is a client rather than a host: it holds a private key, you hold and can revoke the public one, and everything it does lands in your own audit trail.
$ pithy deploy --env prod
▸ deploy myapp-api → your-account.workers.dev
# Bindings written to your wrangler.jsonc:
# DB your D1
# SESSIONS your KV
# UPLOADS your R2
Done.
The packages that are not modules
The seams every capability composes against, plus the control-plane guard. Always present.
One client for every out-of-Worker Cloudflare operation. Used by the CLI at provisioning time, never in a request handler.
The dev plugin that resolves your front end's view of the backend in memory, so there is no generated SDK on disk.
Every capability is MIT, on GitHub, and running in your account rather than ours.