Contracts
A contract describes a set of operations once — method, path, schemas, scope, which transports each is exposed on. From it stitchkit derives the HTTP route, the MCP tool, the agent tool and the typed client. One declaration; the surfaces cannot drift.
defineContract
import { defineContract } from 'stitchkit'
const contract = defineContract(meta, endpoints)
meta—{ prefix: string }, or{ prefix: string, scope: string }to give every endpoint a default scope.endpoints— a map ofkey → endpoint definition. The key becomes the client method name and the handler name.
defineContract throws at definition time if two endpoints declare the same
toolName on the same transport — a tool-name clash is a bug, caught early.
An endpoint
export const users = defineContract({ prefix: 'users' }, {
list: {
method: 'GET',
path: '/',
desc: 'List all users',
output: z.array(UserSchema),
},
create: {
method: 'POST',
path: '/',
desc: 'Create a user',
input: CreateUserSchema,
output: UserSchema,
},
get: {
method: 'GET',
path: '/:id',
desc: 'Get a user by id',
params: z.object({ id: z.string() }),
output: UserSchema,
},
})
Fields
| Field | Required | Purpose |
|---|---|---|
method |
yes | GET · POST · PUT · PATCH · DELETE |
path |
yes | route path under the contract prefix; :name marks a path param |
desc |
yes | human description — also the MCP / agent tool description |
params |
no | Zod schema for path params (:id, …) |
input |
no | Zod schema for the request body (or query, for GET/DELETE) |
output |
no | Zod schema for the response body |
scope |
no | access scope for this endpoint — see Auth & errors |
expose |
no | which transports carry this endpoint — see below |
toolName |
no | explicit MCP / agent tool name (defaults to prefix_key) |
multipart |
no | field name of a file upload — see below |
timeout |
no | per-endpoint client timeout in ms, for slow endpoints |
idempotent |
no | safe to call twice with the same input (like PUT/DELETE); a retrying transport reads it — see Realtime |
meta |
no | opaque app metadata — read in hooks / on tool mounts, never in OpenAPI (below) |
params vs input vs output
The three schemas are distinct on purpose:
params— values in the URL path.path: '/:id'⇒params: z.object({ id: z.string() }). The client takes them from the call argument and substitutes them into the URL.input— the request payload. ForPOST/PUT/PATCHit is the JSON body; forGET/DELETEit is the query string. The handler reads it asctx.input.output— the response shape. When set, the client parses the response through it; the handler’s return value is type-checked against it.
The typed client merges params and input into one argument object — the
caller passes a single flat object, the client routes each field to the path or
the body.
// path: '/:id', params: { id }, input: { text }
await api.update({ id: '1', text: 'new' }) // PUT /users/1 body: { text: 'new' }
Input vs. output types
For the client, an endpoint’s argument type is the schema’s input type
(pre-parse). A field with .default() is therefore optional for the caller but
present (required) in the handler’s parsed ctx.input. The contract handles the
two type views; you do not.
Query input (GET / DELETE)
GET and DELETE carry their input as the query string, and a query
string can only encode flat values. An input field on these verbs must be a
string, number, boolean, or an array of string / number (repeated
query keys). A nested object has no canonical query encoding — the typed
client throws on one instead of sending a silently incomplete request:
// input: z.object({ q: z.string(), filter: z.object({ … }) }) on a GET
await api.search({ q: 'x', filter: { status: 'active' } })
// ✗ throws: GET /: input field "filter" is a nested object — it cannot travel
// as a query parameter
Flatten the field (status: 'active') or move the operation to a body verb
(POST). Remember the server parses query values from strings — use
z.coerce.number() for numbers and z.stringbool() for booleans in a
query-input schema (not z.coerce.boolean(), which is Boolean(str), so
'false' would become true).
Transports
By default an endpoint is exposed on every surface — HTTP, MCP and agent
tools. Narrow it with expose:
{
method: 'POST', path: '/', desc: 'Internal sync',
expose: ['HTTP'], // HTTP only — not an MCP or agent tool
}
{
method: 'GET', path: '/search', desc: 'Search the catalog',
expose: ['HTTP', 'MCP', 'AGENT'], // explicit — all three
}
expose: ['HTTP']— HTTP only. The endpoint never becomes a tool.expose: ['MCP', 'AGENT']— a tool only; no HTTP route.- omit
expose— all transports.
Tool transports (MCP, AGENT) skip multipart endpoints automatically — a
file upload is not a tool call.
toolName
When an endpoint is exposed as a tool, its name defaults to a verb-aware
derivation from the method key + prefix (users + create ⇒ create_user,
users + list ⇒ list_users). Set toolName for an explicit, stable name:
{ method: 'POST', path: '/', desc: 'Create a user', toolName: 'create_user', /* … */ }
Endpoint metadata (meta)
meta is an opaque, app-defined bag the core attaches no meaning to — the
same escape-hatch spirit as scope being a free string (ADR 0002 /
ADR 0021). Declare app concerns
the generic core does not model — a feature gate, a rate tier, a cache hint, a
doc/owner tag — right next to the endpoint:
broadcast: {
method: 'POST', path: '/broadcast', desc: 'Send a broadcast',
input: BroadcastInput, output: Broadcast,
meta: { requiredFeature: 'broadcasts' }, // opaque to the core
}
It rides through to MethodDef.meta, readable in lifecycle hooks (the second
argument is the endpoint) and on tool mounts. The consumer narrows the type when
reading:
beforeHandle: (ctx, endpoint) => {
const feature = endpoint.meta?.requiredFeature
if (typeof feature === 'string' && !ctx.user?.features?.includes(feature)) {
throw forbidden('feature not enabled')
}
}
meta is app-private — it is never serialized into the OpenAPI document. It
can still drive generation: generateOpenApiDocument’s includeMethod reads
it to curate a public spec (e.g. meta: { public: true }), without ever emitting
meta itself — see Curating the spec.
Declare a meta type as a
type, an inline literal, or withsatisfies— not aninterface. A TSinterfacehas no implicit index signature (it can be augmented by declaration merging), so it is not assignable tometa’sRecord<string, unknown>— and on the overloadeddefineContractthe error misleadingly surfaces as ascopemismatch. Usetype EndpointMeta = { requiredFeature?: PlanFeature }, ormeta: { requiredFeature: 'x' } satisfies EndpointMeta. The read side is unchanged —endpoint.meta?.xisunknown, narrow it in the hook.
File uploads
multipart names the form field carrying a file. The handler receives it as
ctx.file:
upload: {
method: 'POST',
path: '/avatar',
desc: 'Upload an avatar',
multipart: 'file',
output: z.object({ url: z.string() }),
}
The client sends a multipart/form-data request; the field value must be a
Blob. See HTTP server → multipart.
Multipart text fields
Any non-file fields sent alongside the file are validated by the endpoint’s
input schema. A multipart text field is always a string (per the spec) —
the schema owns its type, exactly as with query input:
input: z.object({
id: z.string(), // an id like '33111715' stays a string
count: z.coerce.number(), // '5' → 5
active: z.stringbool(), // 'true' → true, 'false' → false
meta: z.preprocess((v) => JSON.parse(String(v)), MetaSchema), // opt a field into JSON
})
The content is never sniffed to guess a type — a field is a string until the
schema coerces it. Send a JSON blob as a stringified field and parse it with
z.preprocess; do not rely on the framework to auto-decode it.
Pagination
Every list endpoint should return the cursor envelope — one shape, one infinite- query helper:
import { paginatedSchema } from 'stitchkit'
feed: {
method: 'GET',
path: '/',
desc: 'Paginated feed',
input: z.object({ limit: z.coerce.number().default(20) }),
output: paginatedSchema(PostSchema), // { items: Post[], nextCursor: string | null }
}
paginatedSchema(itemSchema) produces { items, nextCursor }. The page size
default lives in the contract (limit’s .default()), never on the client —
the two cannot diverge. The client side is createCursorQuery.
The format of nextCursor is the server’s choice — keep it opaque. For the
usual keyset cursor (resume after the last row’s (sortValue, id)), encode it
with encodeCursor and read it back with decodeCursor (Zod-validated;
a missing or garbage cursor decodes to null, i.e. “start from the top”):
import { encodeCursor, decodeCursor } from 'stitchkit'
const Cursor = z.object({ v: z.string(), id: z.string() }) // your keyset shape
const after = decodeCursor(ctx.input.cursor, Cursor) // { v, id } | null
const rows = await db.list({ after, take: limit + 1 }) // your keyset WHERE
const nextCursor =
rows.length > limit ? encodeCursor({ v: last.createdAt, id: last.id }) : null
The codec is base64url over UTF-8 (btoa/atob, not Node Buffer) — server,
client and browser safe, and a non-ASCII sort value round-trips. The keyset
WHERE clause is yours (it’s ORM-specific); stitchkit only carries the string.
Scope
An endpoint may carry a scope string; the contract meta.scope is the default
for every endpoint that declares none. Scopes are free strings — the framework
attaches no meaning, your auth hook does. See
Auth & errors.
A plain defineContract defaults a missing scope to 'public' — forget it and
the endpoint is public (fail-open). If every contract in your app must be scoped,
createContractFactory binds your scope vocabulary once and makes scope
required and typed — a missing or mistyped scope is a compile error:
// app: one line, once
export const { defineContract } = createContractFactory<'public' | 'user' | 'admin'>()
// scope is now mandatory and checked against the union
export const users = defineContract({ prefix: 'users', scope: 'user' }, { … })
The vocabulary is yours; the returned contracts are ordinary ContractDefs.
One source of truth
A contract is plain data — no classes, no decorators, no codegen. It is imported
by the server (to implement), by the client (to createClient) and by the
tool layer (to mountMcp / mountAgent). Change an endpoint and every surface
is re-typed by the compiler at once.