pithy contract

Write each Worker’s OpenAPI document: every route it serves, with its parameters, body, responses and how a caller is verified, read off the app the Worker composes. A Worker that serves a socket gets an AsyncAPI document beside it.

Synopsis

pithy contract [--worker <name>] [--out <dir>] [--env <name>] [--json]

Flags

FlagTypeDefaultMeaning
--worker <name>stringevery WorkerOnly this Worker, by the name pithy worker list shows or its apps/<dir> basename
--out <dir>stringbeside each WorkerWrite each document as <dir>/<worker>/openapi.json, and <dir>/<worker>/asyncapi.json for a Worker that serves a socket. Without it, each lands in apps/<worker>/
--env <name>stringprodThe environment each Worker is composed for
--jsonbooleanfalseMachine-readable output

What it does

A client that is not TypeScript has nothing to import from the kit. It has this. Every language has an OpenAPI generator, so a Swift, Kotlin or C# client generates its calls from the document rather than writing each one by hand.

pithy contract loads each Worker’s pithy.config.ts for one environment, assembles its app exactly as the Worker does at startup, and walks the route table. Nothing in the document is written by hand:

  • What a route accepts comes from its validate(target, Schema) middleware: path, query, header and cookie parameters, and the JSON or form body.
  • What it is for, how its caller is verified, and what it returns come from its describeRoute({ summary, verification, responses }) middleware.
  • Every field’s description comes from the Zod schema’s .describe(), so a generated client carries the same documentation as the TypeScript one.
  • Every error is the one { error: { code, status, message } } body, described once and referenced by every operation.

Your routes, and the kit’s

Every route the kit provides is described. Your own routes are described as far as you describe them, and adding a route shows how.

A route with a describeRoute is written in full. A route without one is still written, from its validate schemas alone, and marked x-pithy-undescribed: true. A generated client still reaches it, and the mark tells you which routes to finish. The command lists them:

$ pithy contract
api: 42 operations. Wrote apps/api/openapi.json.
  1 without describeRoute, written from their validators and marked:
    GET /boards/:board
Done.

A request part read through a bare zValidator from @hono/zod-validator still validates, but its schema is invisible to the document. validate is zValidator with Pithy’s failure hook applied and the schema left readable on the middleware, which is how the document finds it.

One environment per document

The route table is one per environment: @pithy-sh/auth mounts its dev sign-in in dev and nowhere else. prod is the default because it is the table a shipped client calls.

What the document says

It states OpenAPI 3.1. Its info.version is your root package.json version, or 0.0.0 without one. Each operation carries x-pithy-verification, the route’s verification strategies, and x-pithy-scopes when it needs a scope beyond being verified. A route that declares them carries x-pithy-power, the power its gate requires, x-pithy-operation, its permanent name, and x-pithy-tool, { effect } when it is a tool on your app’s MCP endpoint, with effect: null for a read.

It writes only when the bytes changed, so a second run is a no-op and the file is safe to commit and to check in CI.

It is generated, so your formatter leaves it alone. A project scaffolded by pithy init excludes **/openapi.json and **/asyncapi.json in biome.jsonc. A project scaffolded before then adds "!**/openapi.json" and "!**/asyncapi.json" to files.includes itself. Otherwise biome check reformats a document and the next pithy contract writes it back.

A Worker that serves a socket

OpenAPI stops at the upgrade; after it there are only frames. A capability that serves a socket — @pithy-sh/multiplayer’s sessions, @pithy-sh/matchmaking’s presence — declares it, and asyncapi.json describes each one in AsyncAPI 3.0: its path, how the upgrade is verified, the subprotocol versions it serves, and a schema for every frame each side sends, with every field described. Each configured game’s own action and view schemas are in it as <gameKey>.action and <gameKey>.view, so a client in any engine types the part that is the game. How those frames may change from one release to the next is the socket contract.

$ pithy contract
api: 42 operations. Wrote apps/api/openapi.json.
api: 2 sockets. Wrote apps/api/asyncapi.json.
Done.

A Worker that serves no socket gets no asyncapi.json, and its asyncapi is null under --json. One that stopped serving a socket loses the asyncapi.json this command wrote for it. A file it did not write is left alone.

pithy schema describes the CLI and reads nothing from your project. This command reads your project. That is why they are two commands.

The kit publishes the same documents for its own routes and sockets, every capability composed: docs/openapi.generated.json and docs/asyncapi.generated.json in the repository.

--json

One line on stdout.

$ pithy contract --json
{"command":"contract","schemaVersion":1,"environment":"prod","workers":[{"worker":"api","path":"apps/api/openapi.json","operations":42,"undescribed":[],"changed":true,"asyncapi":{"path":"apps/api/asyncapi.json","channels":2,"changed":true}}]}
KeyTypeMeaning
command"contract"The command that produced the line
schemaVersionintegerThe version of this payload’s shape. Bumped only when a key is removed or retyped; pithy schema publishes the current one
environmentstringThe environment every Worker was composed for
workersobject[]One entry per selected Worker, in apps/ order
workers[].workerstringThe Worker’s apps/ directory
workers[].pathstringWhere its document was written, relative to the project root
workers[].operationsintegerHow many operations the document holds, one per method and path
workers[].undescribedobject[]Routes written from their validators alone and marked x-pithy-undescribed. Empty when every route is described
workers[].undescribed[].methodstringThe HTTP method, as Hono records it
workers[].undescribed[].pathstringThe route’s path pattern
workers[].changedbooleanWhether the file’s contents changed. false when it already matched
workers[].asyncapiobject | nullIts AsyncAPI document, for the sockets it serves, or null for a Worker that serves none
workers[].asyncapi.pathstringWhere its AsyncAPI document was written, relative to the project root
workers[].asyncapi.channelsintegerHow many sockets the document describes
workers[].asyncapi.changedbooleanWhether the file’s contents changed. false when it already matched

Errors

Each one is a problem line then an action line, and the process exits by its class.

An environment that is not one. --env production is refused with prod named, before anything is composed.

A root config with no name. Every document is titled with the project’s name, so the root pithy.config.ts must declare one.

No workers here. Nothing under apps/ is a Worker. Create one with pithy worker add.

No worker named "<name>". --worker matched nothing. The refusal lists the Workers that exist.

A config that will not load. The Worker’s pithy.config.ts threw while it was evaluated. The refusal names the Worker and the error.

Examples

pithy contract
pithy contract --worker api --out contracts
pithy contract --env staging --json

Then generate a client in any language. For example, with the OpenAPI Generator:

openapi-generator-cli generate -i apps/api/openapi.json -g swift5 -o ios/PithyClient
ESC