Your API
The contract is the source: the validation, the specs, the SDKs and the MCP tools follow from it. Add your operations beside the example’s, then take the example’s out.
charter spec # after every change to the contract, migrations/ or the queries
charter check # fails on a stale spec, a failing test, or (in Go) a TinyGo gap
The files
| TypeScript (oRPC, Zod) | Go (Huma) | |
|---|---|---|
| The contract | api/contract.ts | api/contract.go |
| The handlers | api/handlers.ts | api/handlers.go |
| The storage | api/store.ts (D1, with Drizzle on api/tables.ts) | api/store.go (Store, and MemStore for go test and -native), api/store_d1.go (D1, through api/queries.go) |
| The schema and its typed code | migrations/*.sql; api/tables.ts, written from them | migrations/*.sql and api/queries.sql; api/queries.go, written from them (below) |
| The specs’ document | api/spec.ts, whose specs charter spec writes | api/spec.go: API(Env), which charter spec writes the specs from |
| The Worker | worker.ts, bundled into build/worker.mjs; its bindings are env from cloudflare:workers, typed from charter.toml (.charter/env.d.ts) | main.go, with the bindings in platform_js.go; worker.mjs, the Worker’s entry. Its module (go.mod) is the project’s name, on the Go library github.com/joeblew999/charter |
| The tests | test/, and unit tests beside the code (api/*.test.ts) | test/, the TypeScript twin’s tests, with the schemas they check answers against (test/schemas.ts); unit tests in api/*_test.go |
| charter’s | charter.toml (below): the Worker, its bindings, its tests | the same |
Add an operation
-
In the contract: its method, path and
operationId(also the MCP tool’s name), its input with the rules each value follows, its output, and who may call it (Auth). In Go, struct tags say where a value comes from and its rules:type GetInput struct { ID int64 `path:"id" minimum:"1" example:"42" doc:"The note's id"` } - The handler gets its input already checked. To refuse, return Huma’s error in Go (
huma.Error404NotFound("...")), throw oRPC’s in TypeScript; every refusal goes out as problem details (The standard API). - The storage: a table change is the next numbered file in
migrations/; never edit one that was applied. Thencharter specwrites the typed code again (The database). - The SDK’s names:
x-fern-sdk-group-nameandx-fern-sdk-method-nameon the operation nameclient.<group>.<method>()(the example’ssdk(...)helper). Every constrained field needs a valid example.
The database
The schema is migrations/*.sql, the one place the tables are said: a deploy applies them to D1, charter dev to its local database. From them charter spec writes the code that reads and writes the tables, typed, as db in charter.toml says, and charter check fails when it is stale.
db | You write | charter spec writes |
|---|---|---|
"drizzle" (TypeScript) | The queries with Drizzle, on drizzle(env.DB) | api/tables.ts: each table, as SQLite has it once the migrations ran |
"sqlc" (Go) | The queries in api/queries.sql, each named (-- name: CreateNote :one, :many or :exec), with a comment that starts with its name as its doc | api/queries.go: a function for each, over charter/d1, its arguments and rows typed by sqlc against the schema. A row of a whole table is that table’s type (NotesRow); a column that may be NULL is a pointer |
The Go is written over charter/d1, not database/sql, which D1 does not need: a statement is one call, and its rows come back as one string. The notes examples keep their notes this way.
Write for Workers
- No memory between requests. State goes in D1 or a Durable Object.
- No background work after the answer; little work at start-up, which runs for every new instance.
- In Go, TinyGo lacks parts of
reflect, the disk and some ofnet:charter checkruns the API under workerd, which finds it;go testcannot.
MCP
Every operation that answers once is also a tool at /api/mcp, with the same checks and the same sign-in: a call is let in, refused and limited as the route is. A stream or a WebSocket is not a tool. To hide one: mcp.NotATool(op) in Go, .meta(notATool()) in TypeScript.
claude mcp add --transport http billing-api https://billing-api.<your-subdomain>.workers.dev/api/mcp
charter.toml
Everything charter knows about the project: its Worker’s name and URL, its port, its bindings (D1, Durable Objects; its rate limits are the contract’s), its secrets (and throwaway local values in [local]), its SDKs, and its tests: [test] run against the API on workerd (charter check), live against the deployed one (charter live-test). A TypeScript Worker names its entry: [build] entry = "worker.ts". Every key: go doc github.com/joeblew999/charter/project.
Take the example out
| What | What to do |
|---|---|
| The contract, handlers and storage | Replace the notes; keep hello and the wiring |
migrations/0001_init.sql | Replace it if never deployed; otherwise add a file |
test/ | Rewrite the notes checks |
charter.toml | Your SDKs’ name: [sdks] name, then charter spec |
Without a stream, remove the hub too: the Hub export, the HUB binding and the stream’s code. grep -rni note api test migrations charter.toml shows what is left.