The Worker contract

Your Worker is four lines:

import { createEntrypoint } from "@pithy-sh/core/src/createEntrypoint";
import config from "../pithy.config";

export default createEntrypoint(config);

This page is what those four lines do.

Two functions, one boundary

What a capability is is what gets assembled here, request context is what each one contributes to a request, and routing and verification is how a route says who may call it.

createEntrypoint(config) produces the Worker entrypoint — the object the runtime calls. Its fetch is one Hono app, and its email handler fans inbound mail out to every capability that declares one, which is how bounce processing works with nothing wired by you.

createBackend({ capabilities, app }) is what builds that Hono app, and it is the function worth understanding. Everything below is what it does at assembly time and on the first request.

At assembly

flowchart TD
    A["createEntrypoint(config)"] --> B["[...capabilities, app]<br/>your app composes last"]
    B --> C{"Every declared<br/>peer composed?"}
    C -- no --> CX["Throws, naming both"]
    C -- yes --> D["Compose hooks run<br/>across the full composed set"]
    D --> E["Registries merged<br/>databases · KV · Workflows"]
    E --> F["Required bindings derived<br/>English merged once"]
    F --> G["First request arrives<br/>env exists only now"]
    G --> H{"Required bindings<br/>present?"}
    H -- no --> HX["Fails with the<br/>binding's name"]
    H -- "yes — optional ones ride through" --> I["Memoized: checked once,<br/>not per request"]
    I --> J["Your handler runs"]

    subgraph assembly ["At assembly — once, before any request"]
      A
      B
      C
      CX
      D
      E
      F
    end
    subgraph request ["On the first request"]
      G
      H
      HX
      I
      J
    end

Your app composes last. [...capabilities, app] — so library routes mount first and yours mount after them.

Missing peers fail immediately. A capability that declares a peer the Worker does not compose throws at assembly, naming both:

Capability "auth" requires the "secrets" capability, which is not composed.

That is a startup failure rather than a per-request one, deliberately: a capability reading a seam its peer fills would otherwise fail one request at a time, forever, with no single moment that says why.

Compose hooks run. Each capability may wire across the full composed set — which is how the secrets capability aggregates every other capability’s registry slice into one combined registry. Hooks run after peer validation, so a hook can rely on its peers being present.

Registries are merged. Every capability’s databases, KV namespaces and Workflows are unioned into one registry each, and the required-binding list is derived from all three — because a named database implies its D1 binding, a namespace implies its KV binding, and a registered job implies its Workflow binding.

English is merged once. Every composed capability’s strings, under the domain rule, built at assembly rather than per request: it is a pure function of the capability set, which cannot change between requests.

On the first request

Binding validation runs once, and it is memoized.

Not at module load, and that is a Workers fact rather than a choice: env is per-request in Workers, so there is no environment to check until a request arrives.

A missing required binding fails with the binding’s name, which is the difference between a five-minute fix and an afternoon. An optional binding — a capability’s enrichment Workflow, say — rides through, so a project boots before it has provisioned the host.

What is on c.var when your handler runs

VariableWhat it isWhen it is null
dbThe typed database registry, one Kysely instance per named database — c.var.db.appNever; it is a registry
kvThe typed namespace registryNever
logThe logger. Zero-config, structured recordsNever
tThe translator. Zero-configNever
authThe authenticated identityUntil a verification strategy sets it
localeThe negotiated localeWith no i18n capability composed
controlPlaneThe verified management callerOn every request that is not one
emitThe audit recorderNever; a no-op without the audit capability

Three of those are never null on purpose, and that is the seam pattern the whole kit is built on: log, t and emit are always present, so no capability null-checks one and no project that never opted in behaves differently.

auth and locale and controlPlane are null, and each null means a specific thing. locale null means nothing was negotiated, which is not the same fact as the default was chosen. controlPlane is its own variable rather than a flavor of auth, because a control-plane call must never satisfy a capability’s requireAuth() — that would be a scope escalation across the whole tree, and separating the two seams is what makes it impossible rather than merely unlikely.

GET /health is mounted for you

Public, deliberately: it reads nothing about the caller, so there is no credential to send it. That is what makes it the one request a scaffolded front end can make with no auth composed.

It answers status and the deployed version id, which is what turns pithy deploy’s post-deploy check from a liveness probe into an assertion. status: "ok" at your domain proves a Worker is there; it does not prove it is the one just shipped — and the old version answering happily is exactly the failure worth catching.

The version is null where the binding is absent, which is honest: a project scaffolded before that binding existed says I cannot tell you, and deploy reports the check as inconclusive rather than failing it.

Errors become responses

Every PithyError is mapped to its declared HTTP status by the error handler, with the internal detail stripped from the wire body. A validation failure renders as a validation/invalid_input 400 like every other failure, rather than through the validator’s own response.

Errors covers the payload shape and what a client does with it.

Types come from the capability list

createBackend is typed precisely from the array it is given. c.var.db.app.selectFrom(…) gets autocomplete on every registered table, and a column you did not declare is a compile error rather than a runtime surprise.

That inference is why defineCapability exists rather than a bare annotation: a widened Capability type loses the literals the registry types are built from.

What your app contributes

Everything a library capability can. Routes and middleware, yes — but also tables, migrations, Workflows, its own English, and its own required bindings.

const app = defineCapability({
  name: "app",
  requiredBindings: [{ type: "d1", name: "DB" }],
  routes: (a) => {
    a.get("/notes", requireAuth(), handler);
  },
});

routes takes the Hono app. There is no wrapper and no route DSL — you get Hono, with the kit’s context types on it.

ESC