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
| Flag | Type | Default | Meaning |
|---|---|---|---|
--worker <name> | string | every Worker | Only this Worker, by the name pithy worker list shows or its apps/<dir> basename |
--out <dir> | string | beside each Worker | Write 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> | string | prod | The environment each Worker is composed for |
--json | boolean | false | Machine-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}}]}| Key | Type | Meaning |
|---|---|---|
command | "contract" | The command that produced the line |
schemaVersion | integer | The version of this payload’s shape. Bumped only when a key is removed or retyped; pithy schema publishes the current one |
environment | string | The environment every Worker was composed for |
workers | object[] | One entry per selected Worker, in apps/ order |
workers[].worker | string | The Worker’s apps/ directory |
workers[].path | string | Where its document was written, relative to the project root |
workers[].operations | integer | How many operations the document holds, one per method and path |
workers[].undescribed | object[] | Routes written from their validators alone and marked x-pithy-undescribed. Empty when every route is described |
workers[].undescribed[].method | string | The HTTP method, as Hono records it |
workers[].undescribed[].path | string | The route’s path pattern |
workers[].changed | boolean | Whether the file’s contents changed. false when it already matched |
workers[].asyncapi | object | null | Its AsyncAPI document, for the sockets it serves, or null for a Worker that serves none |
workers[].asyncapi.path | string | Where its AsyncAPI document was written, relative to the project root |
workers[].asyncapi.channels | integer | How many sockets the document describes |
workers[].asyncapi.changed | boolean | Whether 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 --jsonThen 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