← stitchkitDocumentation

Auth & errors

Client-side login/session operations may legitimately answer 401. Declare that policy with contract-owned matchers instead of path strings:

const http = createHttpClient({
  baseUrl,
  suppressUnauthorizedFor: contractEndpointMatchers(publicAuth, ['login', 'verify']),
})

Only the selected operations suppress the global unauthorized event; a 401 from any neighbouring protected route still signals session expiry.

stitchkit carries no domain model — it does not know what a user is. What it provides is the control flow: a scope on every endpoint, one hook that enforces it, and one error model shared by every transport. The identity and the scope vocabulary are yours.

Scopes

An endpoint declares a scope — a free string. The contract meta.scope is the default for endpoints that declare none. (Authored through createContractFactory<Scope>(), both the contract’s scope and any endpoint override are held to your own union instead.)

export const posts = defineContract({ prefix: 'posts', scope: 'user' }, {
  list:   { method: 'GET',    path: '/',    desc: 'List posts', scope: 'public', output: /* … */ },
  create: { method: 'POST',   path: '/',    desc: 'Create',     /* inherits 'user' */ },
  remove: { method: 'DELETE', path: '/:id', desc: 'Delete',     scope: 'admin' },
})

The framework attaches no meaning to the strings — 'public', 'user', 'admin' mean whatever your auth hook decides.

createAuthHook

createAuthHook builds one scope gate that can run in both HTTP and tool lifecycles. On HTTP it belongs in hooks.authorize: Stitchkit validates path params, resolves identity and scope, and rejects 401/403 before reading a JSON or multipart body. On MCP, agent and CLI surfaces it belongs in lifecycle.beforeHandle, because those transports have already received their arguments before the common tool runner starts.

import { createAuthHook, createServer } from 'stitchkit/server'

const authHook = createAuthHook<User>({
  resolve: (ctx) => resolveSession(ctx),     // → a User, or null
  rules: {
    public: 'public',                        // always pass
    user:   'authenticated',                 // any resolved identity passes
    admin:  (user) => user.isAdmin,          // custom predicate
  },
  inject: (ctx, user) => { ctx.user = user },
})

createServer({ services, hooks: { authorize: authHook } })

AuthRule

The value of each rules entry, keyed by scope:

  • 'public' — always passes; the identity is attached if present.
  • 'authenticated' — any resolved identity passes; no identity ⇒ 401.
  • a function (identity, ctx) => boolean | contribution, sync or async — a custom check. It receives request metadata and validated path params, so a resource-scoped rule can do a DB lookup. It cannot read input or files: the body has deliberately not been consumed yet. false denies, true passes, and a returned plain object passes and contributes inferred handler fields.

Resource-scoped rule — reading a path/prefix param

For a multi-tenant API (pathPrefix: '/tenants/:tenantId', see Route groups → param prefixes), the rule gates access to the tenant in the path. A :param from the group prefix or the endpoint is on the context root as ctx.<name> (a raw string):

const authHook = createAuthHook<User>({
  resolve: sessionResolver,
  rules: {
    public: 'public',
    // gate the tenant in the path — ctx.tenantId comes from the group prefix
    tenant: async (user, ctx) => userCanAccessTenant(user.id, String(ctx.tenantId)),
  } satisfies Record<Scope, AuthRule<User>>,
  // derive per-request facts onto ctx for handlers (role within this tenant, …)
  inject: async (ctx, user) => {
    ctx.user = user
    if (user) ctx.tenantRole = await roleInTenant(user.id, String(ctx.tenantId))
  },
})

inject runs on every request (identity may be null) — use it to put the identity and any derived values (role, parent-id) on ctx for handlers. Read them back as ctx.tenantRole (typed unknown — narrow at the read site). The endpoints under the group declare scope: 'tenant'.

When the async authorization lookup already produced handler data, return it instead of repeating the lookup in inject and a handwritten scope map:

const auth = createAuthHook({
  resolve: sessionResolver,
  rules: {
    project: async (user, ctx) => {
      const membership = await findMembership(user.id, String(ctx.projectId))
      return membership
        ? { userId: user.id, projectId: membership.projectId, role: membership.role }
        : false
    },
  },
})

const implementFor = createScopedImplement<AuthScopes<typeof auth>>()

Contributions are merged only after full validation. Runtime-owned context keys, arrays, class instances, accessors, symbols, unsafe prototypes and partial proxy reads fail without mutating context. false | object contributes required fields; true | object correctly makes them optional.

AuthHookConfig

Field Purpose
resolve (ctx) => identity | null — HTTP identity from ctx.req (cookie / bearer + lookup)
resolveFromContext (ctx) => identity | null — identity on a tool call (no req)
rules access rule per scope; endpoint.scope is the key
defaultScope scope applied when an endpoint declares none
inject write the resolved identity onto ctx for handlers
onAnonymous thrown when a scope needs an identity and there is none (default 401)
onForbidden thrown when an identity is present but the rule rejects it (default 403)

Annotate rules with satisfies Record<MyScope, AuthRule<User>> so the compiler catches a scope you forgot to cover. With the scoped rule objects below, widen the annotation to satisfies Record<MyScope, AuthRule<User> | ScopedAuthRule<User, AuthRuleContribution>> — it keeps the coverage check and does not disturb the derivation. (When every scope comes from the derived map, the check is already implicit: a scope missing from rules is no key of the map, and a contract using it fails to compile.)

Scoped rules — the map createScopedImplement consumes

A rule may take an object form, { rule, inject }, where inject returns the fields this scope contributes. That return type is a declaration the hook derives a scope→context map from — feed it to createScopedImplement and the map can never drift from the hook, because it is computed from it:

const authHook = createAuthHook({
  resolve: sessionResolver,             // no explicit generic — let it infer
  rules: {
    public: { rule: 'public', inject: (user) => ({ userId: user.id }) },
    user: { rule: 'authenticated', inject: (user) => ({ userId: user.id }) },
    admin: {
      rule: (user) => user.admin,
      inject: (user) => ({ userId: user.id, isAdmin: user.admin }),
    },
  },
})

export const implementFor = createScopedImplement<AuthScopes<typeof authHook>>()
  • A 'public' rule’s fields come out optional: public admits the anonymous caller, it does not refuse to know the logged-in one — resolve and inject still run, so a public me / logout handler reads ctx.userId as string | undefined and narrows, instead of being pushed toward a cast by a public: object map.
  • Any other rule’s fields are required — the rule rejected the request before the handler if no identity resolved.
  • A scope with no rule is no key of the derived map, so a contract using it fails to compile — the type-level mirror of the hook’s fail-closed no rule for scope throw.

The per-rule inject runs after the shared one, only when an identity resolved, and before the rule check — it may run for an identity the rule then rejects, so keep it pure and synchronous (an async inject is a compile error, and a runtime one for untyped callers: merging a Promise would merge nothing). Write rule objects inline in rules — a hoisted, annotated rule widens its inject and degrades that scope’s fields to object.

Derivation needs the identity generic to be inferred (write no explicit createAuthHook<User> — TypeScript has no partial inference); with an explicit generic, or fields injected outside the hook, keep the hand-written map. → ADR 0078

Multiple auth domains — composeAuthHooks

Independent identity domains should keep separate resolvers and rules. Compose their canonical hooks and derive one handler context without a manual dispatcher or scope-map intersection:

const userAuth = createAuthHook({
  resolve: resolveUser,
  resolveFromContext: resolveToolUser,
  rules: {
    user: (user) => ({ userId: user.id }),
    workspace: (user) => ({ userId: user.id }),
  },
})

const workspaceAuth = createAuthHook({
  resolve: resolveMembership,
  resolveFromContext: resolveToolMembership,
  rules: {
    workspace: (membership) => ({ workspaceId: membership.workspaceId }),
  },
})

export const auth = composeAuthHooks({
  hooks: [userAuth, workspaceAuth],
  defaultScope: 'user',
})
export const implementFor = createScopedImplement<AuthScopes<typeof auth>>()

Only hooks that declare the selected scope run. An unknown scope fails closed; when several hooks own one scope, every owner must pass in declaration order. Each owner evaluates on an isolated shadow context. Stitchkit validates all changed fields, rejects reserved/unsafe fields and cross-owner key collisions, then commits the combined contribution once. A rejected or cancelled composite therefore exposes no partial fields to the handler.

Isolation is per-key, not deep: a contribution is the set of context keys whose descriptor changed. Mutating an existing value in place (ctx.user.role = 'admin') edits the object every owner already shares, so it is neither reported as a contribution nor checked for collisions — contribute new fields instead of editing another owner’s object. External side effects likewise remain consumer-owned and are not rolled back.

The composite’s defaultScope is the only implicit scope. A child hook’s own default must be absent or equal to it, so reordering hooks cannot change which scope protects an endpoint.

Auth on the tool surface — resolveFromContext

The same hook guards every transport, but the lifecycle slot and identity source differ:

  • HTTP — hooks.authorize calls resolve(ctx) from ctx.req (a cookie or bearer token) before body parsing.
  • Tool calls (MCP / agent) — there is no req. The transport authenticated the caller (an MCP API key) and buildMcpServer’s context injected the identity into ctx. lifecycle.beforeHandle calls resolveFromContext(ctx) to locate it.
const authHook = createAuthHook<User>({
  resolve: (ctx) => resolveSession(ctx),          // HTTP — from ctx.req
  resolveFromContext: (ctx) => ctx.user ?? null,  // tool — from injected ctx
  rules: { public: 'public', user: 'authenticated', admin: (u) => u.isAdmin },
})

The scope check is identical on both surfaces — only identity resolution differs. If you omit resolveFromContext, a scoped tool call has no identity and fails closed (rejected by onAnonymous) — the hook never silently passes a tool call it cannot authenticate. → ADR 0014

createServer({ services, hooks: { authorize: authHook } })
createMcpHandler({ services, lifecycle: { beforeHandle: authHook } })

createBearerResolver

For API-key or bearer-token auth (the usual MCP case), createBearerResolver strips Authorization: Bearer <token> and hands the raw token to your lookup:

import { createBearerResolver } from 'stitchkit/server'

const resolve = createBearerResolver<User>({
  lookup: (token, req) => db.apiKeys.resolve(token),   // → User, or null
})

Use it as createMcpHandler’s auth, or inside createAuthHook’s resolve.

JWT

verifyJwt(token, secret) verifies an HS256 JWT and returns its payload, or throws unauthorized. It pins the algorithm (the token’s own alg can never pick the scheme), and checks exp and nbf.

import { verifyJwt, extractToken } from 'stitchkit/server'
import { env } from './env'

const token = extractToken(req)           // from Authorization, or a cookie name
if (!token) throw unauthorized('Bearer token is required')
const payload = await verifyJwt(token, env.JWT_SECRET)

extractToken(req, cookieName?) reads a bearer token from the Authorization header, or from the named cookie.

Telegram Mini Apps

A Mini App hands its backend an initData query string that Telegram signed with a key derived from the bot token. verifyTelegramInitData checks that signature and reports what the string says — and refuses with a reason, because an application answers a stale string differently from a forged one.

import { verifyTelegramInitData } from 'stitchkit/telegram'
import { unauthorized } from 'stitchkit/contract'
import { env } from './env'

const result = await verifyTelegramInitData({
  initData,
  botToken: env.BOT_TOKEN,
  maxAgeSeconds: 3600,          // a signed string is otherwise valid forever
})
if (!result.valid) {
  // 'missing-hash' | 'signature-mismatch' | 'malformed' | 'expired'
  throw unauthorized(result.reason === 'expired' ? 'Reopen the app' : 'Invalid session')
}
const telegramId = result.data.user?.id

Three things it does that a hand-written check usually does not:

  • the signature is checked first, before auth_date or anything else is read — an expiry inside an unverified payload is a number the sender chose;
  • digests are compared without an early exit, so the time a rejection takes is not a measurement of how much of the digest was right;
  • raw keeps every signed pair, so a field Telegram adds after this release does not need a release to reach.

The user arrives camelCase — firstName, languageCode — as TelegramUser. A bot meets the same person as grammY’s ctx.from, in Telegram’s snake_case; parseTelegramUser(ctx.from) gives the same TelegramUser, so one ensureAccount(user) serves the Mini App and the chat.

maxAgeSeconds is optional and there is no default: only the application knows how long its own session is worth. Omitting it means the string never expires, which is a decision rather than an oversight.

The module is server-only and pulls in no bot library — the token never belongs in a browser bundle. stitchkit/application/grammy remains the lifecycle adapter for an injected grammY bot. Its companion, classifyTelegramSendFailure, names why a send was refused and separates retry this send from stop addressing this recipient:

import { classifyTelegramSendFailure } from 'stitchkit/telegram'

try {
  await bot.api.sendMessage(chatId, text)
} catch (error) {
  const failure = classifyTelegramSendFailure(error)
  if (failure.recipientUnreachable) await markUnreachable(chatId, failure.reason)
  else if (failure.retryable) await requeue(chatId, failure.retryAfterSeconds)
}

The two flags are separate because one answer cannot serve both. A rate limit is retryable and implicates nobody; a blocked user is unreachable and no retry helps; a message Telegram could not parse is neither — the recipient is fine and our payload is wrong, which is the case a list of substrings quietly counts against the user. An unrecognised refusal leaves the recipient reachable: losing a working subscriber forever costs more than one wasted send.

TELEGRAM_SEND_FAILURE_REASONS lists every reason. A table keyed by TelegramSendFailureReason — error codes, the text a person reads — fails to compile when a release adds one, instead of falling through to a default:

const codes: Record<TelegramSendFailureReason, string> = {
  'blocked-by-user': 'E_BLOCKED',
  /* … every reason … */
}

The rest of what a bot is assembled from — its resources, journal, operator channel, broadcasts and local Bot API files — is in Telegram bots.

Cookies

import { defineCookie } from 'stitchkit/server'

const session = defineCookie({ name: 'sid', httpOnly: true, secure: true, sameSite: 'Lax', path: '/' })

session.get(req)        // string | undefined
session.set('abc123')   // → a Set-Cookie header value
session.clear()         // → a Set-Cookie value that expires it

defineCookie bundles a cookie’s name and options into a typed handle, so the config is not repeated at every call site. parseCookies(header), parseCookieHeader(header) and serializeCookie(name, value, opts) are the lower-level primitives.

Two cookies, one name. A browser sends every cookie whose domain and path match, so a sid on .example.com and a sid on app.example.com both arrive — in an order the browser chooses (RFC 6265 §5.4) and a server should not rely on (§4.2.2). get returns the last one by default, which is a fact of the header order, not a rule: the same session then validates in one browser and not in another. Name the behaviour you rely on, or refuse to guess:

const session = defineCookie({ name: 'sid', domain: '.example.com', duplicates: 'reject' })

session.get(req)     // undefined when two *different* values arrive; the one value when they agree
session.getAll(req)  // every candidate, in header order — validate each, clear the loser

duplicates: 'reject' turns the lottery into “no session”, which a login page handles; 'first' and 'last' exist so an application can state which coin it flips. For a session cookie on a parent domain the durable fix is getAll: validate each candidate and answer with clear() for the one that lost, so the next request carries one.

To set a cookie from a schema-validated JSON endpoint without losing its typed client result, declare responseMeta and append the generated value:

complete: async ({ response }) => {
  response.headers.append('Set-Cookie', session.set('abc123'))
  return authenticatedUser
}

Append once per cookie; Stitchkit preserves separate Set-Cookie fields through both Bun and Node. Cookie/session policy remains application logic.

The error model

One error type, AppError, is shared by the contract, the server and the client. It carries a stable code, an HTTP status, and optional structured details and a hint.

Throwing errors

Throw AppError directly, or use a typed helper — the idiomatic path:

Helper Status Code
notFound(message?) 404 NOT_FOUND
badRequest(message, details?) 400 BAD_REQUEST
unauthorized(message?) 401 UNAUTHORIZED
forbidden(message?) 403 FORBIDDEN
conflict(message?, details?) 409 CONFLICT
rateLimited(message?) 429 RATE_LIMITED
appError(code, message?, details?) mapped, else 500 any code
import { notFound, badRequest } from 'stitchkit/server'

const note = db.get(ctx.params.id)
if (!note) notFound('Note not found')
if (ctx.input.title.length > 200) badRequest('Title too long', { max: 200 })

Each helper has a never return type — TypeScript knows execution stops, so the code after it is correctly narrowed.

You can subclass AppError for a domain error model (class FeatureLocked extends AppError). AppError.is(err) identifies any instance — a direct one, a subclass, even a copy from another bundle chunk or realm — by a global brand rather than instanceof, so a domain error keeps its code / details / hint on every transport (HTTP, MCP, agent). → ADR 0032.

The error envelope

AppError.toJSON() renders the public envelope every transport returns:

{ "error": { "code": "NOT_FOUND", "message": "Note not found" } }

details and hint are included when present. On HTTP this is the response body with the matching status; for an MCP or agent call the same code and details come back as a tool error. A schema-validation failure on the request is turned into a 400 VALIDATION_ERROR automatically — and it carries the offending fields as structured details.issues, so a machine client matches on them instead of parsing the text message:

path and code are the framework’s. message is not — it is issue.message as Zod produced it, so for a .refine() it is whatever the schema’s author wrote. The framework never puts a submitted value into it, and its own messages describe types rather than values (expected string, received undefined, Invalid UUID), but that is a property of today’s messages, not a guarantee anyone is in a position to make: the text belongs to whoever declared the schema. Forward path and code to an end user freely; treat message as internal unless you wrote every refine in the contract.

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "name: Invalid input\nage: Invalid input",
    "details": {
      "issues": [
        { "path": "name", "code": "invalid_type", "message": "Invalid input" },
        { "path": "age",  "code": "invalid_type", "message": "Invalid input" }
      ]
    }
  }
}

Use the exported zodIssues(error) to build the same structured list from a ZodError in your own hook.

On the client

The client parses that envelope back into an ApiError with the same code, status, details and hint, plus the response x-request-id as optional readonly traceId — see Typed client → ApiError. The error round-trips without adding correlation data to the response body.

Stitch codes vs your codes

A code is a free string — your app codes (BOT_NOT_FOUND, …) are yours and the core never models them (ADR 0002). But stitchkit itself emits a set of its own: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, METHOD_NOT_ALLOWED, CONFLICT, RATE_LIMITED, REQUEST_ABORTED, REQUEST_TIMEOUT, VALIDATION_ERROR, FILE_INVALID_PATH, FILE_OUTSIDE_ROOT, FILE_NOT_FOUND, FILE_NOT_REGULAR, FILE_INSPECTION_REJECTED, FILE_TOO_LARGE, FILE_EXISTS, REALTIME_CONTRACT_VIOLATION, APPLICATION_NOT_ACCEPTING, NOT_IMPLEMENTED, WAIT_TIMEOUT, WAIT_FAILED, DOWNLOAD_NOT_FOUND, VIEW_HTTP_ERROR, CONNECTION_TIMEOUT, CONNECTION_RESPONSE_TOO_LARGE, CONNECTION_REQUEST_FAILED, UPSTREAM_TOOL_ERROR, OPERATION_NOT_SUCCEEDED, STREAM_ITEM_INVALID, STREAM_FRAME_TOO_LARGE, STREAM_TERMINAL_MISSING, STREAM_LIFETIME_EXCEEDED, GRAMMY_WEBHOOK_NOT_ACCEPTING, SANDBOX_UNAVAILABLE, SANDBOX_INSUFFICIENT, SPILL_REFERENCE_UNKNOWN, INTERNAL_SERVER_ERROR — a set that grows in ordinary releases, each addition named in the changelog. They are published as STITCH_ERROR_STATUS (the code → status map) and StitchErrorCode (its keyof), with isStitchErrorCode() (→ ADR 0026).

This list is enumerated here for reading, and the enumeration is what goes stale — it once dropped APPLICATION_NOT_ACCEPTING, which made the map below stop compiling. Object.keys(STITCH_ERROR_STATUS) is the answer that cannot; packages/core/tests/error-registry-completeness.test.ts keeps the registry itself complete against the source.

If you translate stitch’s framework errors into your own wire codes in an onError hook, you choose how the set’s growth reaches you. Keying the map by StitchErrorCode makes it exhaustive, so a code stitch adds or renames becomes a compile error rather than an unfamiliar code on your wire — worth it when the envelope is a published contract, at the cost of an edit on those releases:

// Exhaustive on purpose: the annotation is what turns a new stitch code into a
// compile error here. Drop it (or use `satisfies` on a partial map) to let an
// unmapped code travel as itself instead.
// `Partial`, not an exhaustive `Record`: the set grows in ordinary releases, and
// a map written exhaustively stops compiling on every addition. Codes you leave
// out fall through to `unmappedCode`.
const STITCH_TO_APP: Partial<Record<StitchErrorCode, AppCode>> = {
  NOT_FOUND: 'NOT_FOUND', METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
  BAD_REQUEST: 'VALIDATION_ERROR', VALIDATION_ERROR: 'VALIDATION_ERROR',
  UNAUTHORIZED: 'UNAUTHORIZED', FORBIDDEN: 'FORBIDDEN', CONFLICT: 'CONFLICT',
  RATE_LIMITED: 'RATE_LIMITED', INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
  REALTIME_CONTRACT_VIOLATION: 'INTERNAL_SERVER_ERROR',
  FILE_INVALID_PATH: 'VALIDATION_ERROR', FILE_OUTSIDE_ROOT: 'VALIDATION_ERROR',
  FILE_NOT_FOUND: 'NOT_FOUND', FILE_NOT_REGULAR: 'VALIDATION_ERROR',
  FILE_INSPECTION_REJECTED: 'VALIDATION_ERROR', FILE_TOO_LARGE: 'VALIDATION_ERROR',
  FILE_EXISTS: 'CONFLICT',
}
onError: (ctx, err) => {
  if (AppError.is(err) && isStitchErrorCode(err.code)) {
    return jsonError(STITCH_TO_APP[err.code], err.status)   // keep stitch's status
  }
  // … your own AppError / normalizeError path
}

One application vocabulary and one wire map

defineErrors can also resolve Stitchkit’s framework codes into the same application vocabulary. A status fallback is explicit because several application codes may legally share one HTTP status:

const vocabulary = defineErrors(
  {
    VALIDATION_ERROR: { status: 400, message: 'Invalid request' },
    INTERNAL_ERROR: { status: 500, message: 'Internal error' },
  },
  {
    fallback: { 400: 'VALIDATION_ERROR', 500: 'INTERNAL_ERROR' },
    map: { FILE_TOO_LARGE: 'VALIDATION_ERROR' },
  },
)

const onError = createErrorHook({ vocabulary })
// `vocabulary` *is* the wire map: `codeMap` and `unmappedCode` cannot sit
// beside it — the type forbids the combination and the runtime refuses it.
// A `map` target must be declared under the framework code's own status
// (`NOT_FOUND` → a 404 code, never a 410 one): the wire keeps the framework
// status and carries your code, so `defineErrors` refuses the mismatch where
// it already refuses a `fallback` under the wrong status.

An explicit per-code map wins over a status fallback. With exhaustive: true, map must be a complete Record<StitchErrorCode, AppCode> at compile time. Transport boundaries identify errors with AppError.is, not instanceof, so two bundled copies still agree.

Domain errors — defineErrors

Declare each domain code, HTTP status, default message and optional structured-details schema in one immutable registry. The generated functions construct typed branded AppError instances; ordinary throw remains explicit at the call site:

import { z } from 'zod'

export const { errors, codes, definitions, isCode } = defineErrors({
  SESSION_NOT_FOUND: { status: 404, message: 'No such session' },
  QUOTA_EXCEEDED: {
    status: 429,
    message: 'Monthly quota exhausted',
    details: z.object({ retryAfterSeconds: z.number().int().positive() }),
  },
})

throw errors.SESSION_NOT_FOUND()                      // uses the declared message
throw errors.QUOTA_EXCEEDED({
  message: 'Try later',                               // overrides it here only
  details: { retryAfterSeconds: 30 },
  hint: 'Wait for the current window to expire',
})

// Construction without throwing is useful for composition or inspection.
const error = errors.QUOTA_EXCEEDED({ details: { retryAfterSeconds: 30 } })
definitions.QUOTA_EXCEEDED.status  // 429 — same source, no copied status map
definitions[code].message          // the declared text, by a `code` variable

// client — match the code, never a magic string
if (err instanceof ApiError && err.code === codes.SESSION_NOT_FOUND) { … }

message is optional: declare it once instead of repeating the sentence at every throw, and a per-call message still wins. A code without one keeps the existing fallback — AppError.message is the code itself. An empty declared message is rejected when the registry is declared, not when the error is thrown.

The tool envelope has no message field, and what the model sees depends on whether the code declares details:

HTTP / typed client MCP / agent / CLI
code with a details schema the declared message no text at all — only details and hint
code without one the declared message the same text, delivered as details.message

The model-facing envelope is { error, retryable, details?, _hint? }; for a code with no details schema the framework fills details with { message }, so declaring a message changes what the model reads there; without one it is the code itself. Put anything the model must reliably read in details or hint, not in message.

With no details schema, the options object forbids details. A required z.object makes details required; z.object(...).optional() makes it optional. Supplied details are parsed when the error is constructed, before any transport sees them. code remains literal and the parsed details type is retained on the returned AppError.

HTTP renders the complete code/status/message/details/hint. Tool transports keep their established model-facing projection (code/details/hint, without HTTP status), while invokeOrThrow recovers the exact normalized AppError. The codes and schemas remain application-owned; Stitchkit stays domain-free.

createErrorHook

createErrorHook is the code-map above, packaged — you supply a codeMap and the envelope shape, it does the normalisation (including the never-leak-an-internal-message rule for a raw throw):

const onError = createErrorHook({
  // Map the codes you have an opinion about.
  codeMap: {
    BAD_REQUEST: 'bad_request', VALIDATION_ERROR: 'bad_request',
    UNAUTHORIZED: 'unauthenticated', FORBIDDEN: 'forbidden',
    NOT_FOUND: 'not_found', METHOD_NOT_ALLOWED: 'not_found',
    CONFLICT: 'conflict', RATE_LIMITED: 'rate_limited',
    INTERNAL_SERVER_ERROR: 'internal',
  },
  // Optional: one public vocabulary entry for every other stitchkit code.
  // A resolver `(code) => ...` is also accepted for grouping code families.
  unmappedCode: 'framework_error',
  // `ctx` is the request's RuntimeContext — read `ctx.traceId` for a
  // correlation id in the envelope. Declaring it is optional.
  render: (info, ctx) => ({
    ok: false,
    error: { code: info.code, message: info.message },
    traceId: ctx.traceId,
  }),
})

createServer({ services, hooks: { onError } })

codeMap is partial: map the codes you have an opinion about. By default a stitchkit code you did not list travels as itself. Set unmappedCode to one wire-code when your public vocabulary has a catch-all, or to a function such as (code) => code.startsWith('FILE_') ? 'storage_error' : 'framework_error' when framework code families need different buckets. An explicit codeMap entry always wins. Codes your project throws itself do not belong to Stitchkit’s vocabulary, so they never pass through this fallback and remain unchanged.

One satisfies is the opt-in to the stricter deal:

codeMap: { /* … every code … */ } satisfies Record<StitchErrorCode, string>,

That makes the map exhaustive on your side, so a release that adds a code stops your build until you decide what the new code is called on your wire. Take it when your envelope is a published contract and a code surfacing in stitchkit’s spelling would violate it; leave it off when passing one through is fine. Neither choice is silent — the changelog names every added code.

Both onError and render may be asynchronous and receive the matched endpoint as their final argument. The observer is awaited before rendering, so it can resolve identity or enrich the request context even for failures raised before beforeHandle:

const onError = createErrorHook({
  onError: async (_error, _info, ctx, endpoint) => {
    await attributeFailedRequest(ctx, endpoint)
  },
  render: (info, ctx) => ({
    error: { code: info.code },
    actorId: ctx.actorId,
  }),
})

endpoint is undefined when the failure happened before route resolution. Synchronous callbacks and renderers that declare fewer parameters continue to work normally.

Invalid input (a ZodError) is classified as VALIDATION_ERROR 400 before it reaches render — a client fault is an honest 400, not a 500 — and the offending fields arrive as structured info.details.issues, so your render can surface them to a machine client without parsing the message.

If you write a bespoke onError instead of using createErrorHook, run the thrown value through the exported normalizeError to get the same classification (a raw ZodError reaches your hook untouched, so the framework can inspect it — call zodIssues(err) yourself if you want the structured fields):

import { normalizeError } from 'stitchkit/server'

onError: (ctx, err) => {
  const e = normalizeError(err)   // ZodError → VALIDATION_ERROR 400, else generic 500
  return jsonError(e.code, e.status, e.message)
}