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 readinputor files: the body has deliberately not been consumed yet.falsedenies,truepasses, 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 —resolveandinjectstill run, so a publicme/logouthandler readsctx.userIdasstring | undefinedand narrows, instead of being pushed toward a cast by apublic: objectmap. - 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 scopethrow.
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.authorizecallsresolve(ctx)fromctx.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) andbuildMcpServer’scontextinjected the identity intoctx.lifecycle.beforeHandlecallsresolveFromContext(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_dateor 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;
rawkeeps 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.tskeeps 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)
}