How it works

A kit, not a framework.

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.

Your repository stays thin.

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.

myapp/apps/api/pithy.config.ts
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()],
});

Capabilities depend on seams, never on each other.

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.

inside @pithy-sh/payments
// 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") });

Durable work runs in Workflows, not in a request.

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.

a request handler
// 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 });

It runs in your account. All of it.

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.

terminal
$ 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

Three things you never add.

core

The seams every capability composes against, plus the control-plane guard. Always present.

cloudflare

One client for every out-of-Worker Cloudflare operation. Used by the CLI at provisioning time, never in a request handler.

vite

The dev plugin that resolves your front end's view of the backend in memory, so there is no generated SDK on disk.

Read the code before you trust it.

Every capability is MIT, on GitHub, and running in your account rather than ours.