← stitchkitDocumentation

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 of key → endpoint definition. The key becomes the client method name and the handler name.

defineContract throws at definition time if two endpoints declare the same tool.name 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 · HEAD · POST · PUT · PATCH · DELETE
path yes route path under the contract prefix; :name marks a path param and a named terminal /*filePath captures the remaining path
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; its presence declares a JSON result (null is data, undefined is not)
scope no access scope for this endpoint — see Auth & errors
expose no which transports carry this endpoint — see below
tool no what the tool surfaces (MCP / agent / CLI) read that HTTP does not — see below
multipart no typed file fields, cardinality, delivery and upload policy — see below
maxJsonBodyBytes no per-route JSON body ceiling; overrides the server default
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)
rawResponse no the handler returns the Response itself — a download, a file, an SSE stream. HTTP-only, never a tool, no output. See Raw-response endpoints
rawBody no retain original JSON text for a signed HTTP webhook. See Signed JSON webhooks
responseMeta no make a typed-data endpoint HTTP-only, optionally declare its success status and expose ctx.response.headers — Typed JSON response metadata
contentType no documented response media type of a rawResponse endpoint (OpenAPI only)

HEAD is an explicit HTTP-only operation, never an automatic alias for GET. It must declare rawResponse: true; request body, input, multipart, rawBody, typed output and tool exposure are rejected. The handler owns status and headers, while Stitchkit guarantees an empty wire body even if the returned Response accidentally contains one.

params vs input vs output

output owns response presence across HTTP and tool transports. With a schema, the handler must return a value that validates and can be represented as JSON; a nullable schema may return null, but undefined is always a contract violation. Without output, the handler is empty (undefined/null) and may not return undeclared data. On HTTP this means a default 200 JSON response with output and a default bodyless 204 without it.

The three schemas are distinct on purpose:

  • params — values in the URL path. path: '/:id' infers { id: string } and materializes the same Zod string schema for HTTP, OpenAPI, MCP, Agent and CLI. Declare params: z.object({ id: z.uuid() }) only when the path value needs validation or coercion; an explicit schema must cover every name in the path. The client takes params from the call argument and substitutes them into the URL. A terminal wildcard is explicitly named: path: '/:slug/*filePath' matches both /foo/page and /foo/a/b; the handler reads ctx.params.filePath as 'page' or 'a/b'. Bare /*, invalid/duplicate names and a wildcard before the final segment fail at contract definition.
  • input — the request payload. For POST / PUT / PATCH it is the JSON body; for GET / DELETE it is the query string. The handler reads it as ctx.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' infers params: { id: string }; input: { text }
await api.update({ id: '1', text: 'new' })   // PUT /users/1  body: { text: 'new' }

// path: '/:slug/*filePath', params: { slug, filePath }
await api.app({ slug: 'foo', filePath: 'a/b' })   // GET /apps/foo/a/b

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.

For a curated or security-sensitive tool surface, opt into explicit tool exposure at the scoped factory:

const { defineContract } = createContractFactory<AppScope>({
  toolExposure: 'explicit',
})

With this policy, omitting expose materializes ['HTTP'] on the returned endpoint. MCP, Agent and CLI then require an explicit endpoint array. The plain factory and defineContract keep the default-on behaviour above.

Consequently, expose: ['HTTP'] is redundant inside a factory configured with toolExposure: 'explicit': omit it unless the explicit spelling is useful to a local reader. Add MCP, AGENT or CLI only on endpoints that intentionally join those surfaces.

Tool transports (MCP, AGENT) skip four kinds of endpoint automatically: multipart (a file upload is not a tool call), rawResponse (its answer is bytes, which a tool result cannot carry — it would serialize to {}), responseMeta (outbound HTTP headers have no meaning on a tool call) and rawBody (HTTP-only by construction).

Tool options (tool)

Everything a tool surface reads that HTTP does not lives in one group, so an endpoint that can never be a tool (a raw response, a stream, responseMeta, rawBody, HEAD) refuses all of it with one key:

tool. Purpose
name explicit tool name (default: a verb-aware derivation, below)
view a different call or answer on MCP / agent / CLI — input defaults alone, or with a projected, separately validated output; declared only with withToolView — see MCP & agents
ui MCP Apps widget for the tool’s results — see MCP & agents
annotations MCP behavioural hints (readOnlyHint, destructiveHint, title, …)
mcp elicitation rounds asked before the handler runs (inputRequired)

expose stays outside the group: it chooses transports, HTTP included. Setting tool on an endpoint with no tool transport — expose: ['HTTP'], or an omitted expose under a factory with toolExposure: 'explicit' — throws at definition time. The pre-0.94 top-level keys (toolName, toolView, ui, annotations, mcp) are refused by name, with where each one went.

tool.name

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 tool.name for an explicit, stable name:

{ method: 'POST', path: '/', desc: 'Create a user', tool: { name: 'create_user' }, /* … */ }

Every tool name — derived or explicit — must match [a-zA-Z0-9_-] and be at most 64 characters, the character class every major provider accepts.

Derivation normalises per half, and the halves differ (→ ADR 0035). The service half collapses everything outside [a-zA-Z0-9_] to _, hyphen included: bot-status + get ⇒ get_bot_status, admin/analytics + get ⇒ get_admin_analytics. The method half keeps its hyphen and normalises only what no provider accepts ([^a-zA-Z0-9_-]), because a hyphenated method key has always shipped a legal name that a client config may already pin: notes + get-user ⇒ get-user_notes. An explicit tool.name is taken verbatim.

A prefix with no usable character at all ('///', '_', a fully non-ASCII prefix) and any explicit tool.name outside the accepted class throw at mount. An unusable prefix is rescued by setting an explicit tool.name — the prefix then never enters the name. An over-long name is fixed by a shorter explicit tool.name (or a shorter prefix / method key). Nothing downstream checks this: the provider rejects the whole request, so one bad name takes every tool of that mount down with it. → ADR 0035.

Endpoint metadata (meta)

A contract can declare a default meta that every endpoint inherits, and an endpoint’s own keys are shallow-merged over it:

defineContract({ prefix: 'admin', meta: { public: true } }, {
  list: { method: 'GET', path: '/', desc: 'List', /* meta → { public: true } */ },
  purge: { method: 'POST', path: '/purge', desc: 'Purge', meta: { rateTier: 2 } },
  //                                          meta → { public: true, rateTier: 2 }
})

One level deep — no deep merge. To opt an endpoint out of an inherited key, declare it with an explicit undefined:

defineContract({ prefix: 'leads', meta: { page: 'LEADS' } }, {
  // Public website form inside an otherwise admin-gated contract:
  submit: { method: 'POST', path: '/', desc: 'Submit', meta: { page: undefined } },
  //                                       meta → { page: undefined } — gate off
  list: { method: 'GET', path: '/list', desc: 'List' },   // meta → { page: 'LEADS' }
})

The key stays present with value undefined, so read meta by value (method.meta?.page), never by key membership. expose deliberately has no contract-level equivalent (→ ADR 0036).

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 with satisfies — not an interface. A TS interface has no implicit index signature (it can be augmented by declaration merging), so it is not assignable to meta’s Record<string, unknown> — and on the overloaded defineContract the error misleadingly surfaces as a scope mismatch. Use type EndpointMeta = { requiredFeature?: PlanFeature }, or meta: { requiredFeature: 'x' } satisfies EndpointMeta. The read side is unchanged — endpoint.meta?.x is unknown, narrow it in the hook.

File uploads

multipart is the single source of truth for file fields, cardinality and transport-level upload policy. Buffered delivery (the default) gives the handler a typed ctx.files map:

uploadAttachments: {
  method: 'POST',
  path: '/:answerId/attachments',
  desc: 'Upload attachments',
  params: AnswerIdParamsSchema,
  input: UploadMetadataSchema,
  output: UploadedAttachmentsSchema,
  multipart: {
    maxRequestBytes: 120 * 1024 * 1024,
    maxFieldBytes: 64 * 1024,
    files: {
      cover: {
        required: false,
        maxBytes: 10 * 1024 * 1024,
        contentTypes: ['image/*'],
      },
      attachments: {
        multiple: true,
        maxFiles: 8,
        maxBytes: 20 * 1024 * 1024,
        contentTypes: ['image/*', 'application/pdf'],
      },
    },
  },
}
uploadAttachments: ({ params, input, files }) => {
  files.cover        // File | undefined
  files.attachments  // File[] in multipart order
}

required defaults to true; multiple defaults to false. maxFiles is valid only for a multiple field. contentTypes accepts exact media types and validated type wildcards such as image/*; it checks the declared multipart header, not file contents. Content sniffing, antivirus and storage policy stay in the application.

The typed client accepts a web Blob/File or React Native { uri, name, type } descriptor for each single value, and arrays for multiple fields. It appends repeated multipart field names in stable order. Undeclared file fields, duplicate single fields, missing required fields and wrong part kinds fail before the handler. Multipart endpoints are HTTP-only.

Set delivery: 'stream' when files must go directly to consumer-owned storage without becoming File objects in framework memory. The contract descriptor stays the same; the implementation supplies receivers with Web streams. 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 mandatory, checked against the union and retained as the 'user' literal
export const users = defineContract({ prefix: 'users', scope: 'user' }, { … })

The vocabulary is yours; each returned ScopedContractDef retains its concrete scope literal as a required meta.scope field, so scope-aware registries select the exact config without NonNullable or another wrapper. Plain ContractDef keeps optional scope because ordinary defineContract still supports its default-public overload.

The union covers per-endpoint overrides too, not just the contract’s scope — that is where typos are densest, and one there used to compile and then fail closed at request time ([stitchkit] auth: no rule for scope "…"):

defineContract({ prefix: 'posts', scope: 'user' }, {
  drop: { method: 'DELETE', path: '/:id', desc: 'Drop', scope: 'admn', … },
  //                                              ^ compile error, and TypeScript
  //                                                suggests 'admin'
})

Contracts built with plain defineContract are unaffected — their endpoint scope stays a free string.

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.