Upgrading stitchkit
Released migration: 0.111.0
Who must act: applications whose raw routes call parseBody on JSON larger than 1 MiB, and applications whose OAuth clients send /register or /token bodies larger than 64 KiB.
Bounded raw and OAuth bodies
parseBody reads at most 1 MiB and returns null for a larger body, where it used to read any size. A route that takes larger JSON states its limit:
// before
const body = await parseBody(req, Schema)
// after
const body = await parseBody(req, Schema, { maxBytes: 8 * 1024 * 1024 })
mountOAuthProvider reads at most 64 KiB of a /token or /register body and answers 413 with invalid_request for a larger one. A deployment whose clients register larger metadata raises the limit:
// before
mountOAuthProvider({ issuer, signingSecret, resource, codes, authorizeUser })
// after
mountOAuthProvider({ issuer, signingSecret, resource, codes, authorizeUser, maxRequestBodyBytes: 256 * 1024 })
Both limits must be a positive safe integer; anything else is refused when the route is built or called.
Released migration: 0.110.0
Interrupted process drains
Who must act: applications with exhaustive switches or schemas over LifecycleTermination or PreviousExit from stitchkit/application.
A successor that closes an abandoned draining run now reports interrupted, rather than abnormal (or startup-failed before readiness). Add an interrupted case for an unfinished shutdown. abnormal still describes an abandoned active run. Handoff and same-pid hot reload retain their meaning.
StartFact.previousDrainUpperBoundMs measures from recorded admission stop to successor start, or is null without those facts. It is an upper bound: the ledger does not observe the actual kill signal, supervisor deadline, or death time. Before: previousExit === "abnormal" included unfinished drains; after: handle previousExit === "interrupted" separately.
Released migration: 0.109.0
free CLI record leaves are lexical
Who must act: scripts that pass dotted values into a free record<string, unknown> and rely on
stitchkit guessing numbers or booleans from their spelling. Declared object leaves and whole-object
JSON keep their typed behavior.
Free record values have no schema type to justify coercion. They now preserve argv text, so values
such as 007, 1e3, true and false arrive exactly as written.
See ADR 0257.
-
Pass typed free-record values as one JSON object:
# before: produced { count: 5 } by spelling-based inference app command --data.count=5 # after: JSON says explicitly that 5 is a number app command --data='{"count":5}' -
If dotted flags are the intended interface, declare their object shape; a
z.number()orz.boolean()leaf still coerces and validates its own CLI value.
Opting into Telegram attempt fencing
Existing handle(update) callbacks and the six-method TelegramUpdateStore need no change. The
new capability is opt-in
(ADR 0256).
-
To fence a domain write, move only that handler to the explicit context-aware option:
createTelegramUpdateIntake({ store, handleAttempt: async (update, { fence, ownerLost }) => { ownerLost.throwIfAborted() await domainTransaction(async (tx) => { if (await transactionStore(tx).owns(fence, Date.now())) await apply(tx, update) }) }, }) -
A direct grammY handler remains
handle: bot.handleUpdate.bind(bot); stitchkit does not pass its attempt context into grammY’s optional webhook-envelope parameter. -
A custom store used by
handleAttemptimplementsclaimOwned,renewOwned,ownsandsettleOwnedover the full{ updateId, attempt, claimId }identity. If it also delivershandleExhaustion, implementexhaust,dueExhaustionsandacknowledgeExhaustion. RuncheckTelegramUpdateStoreagainst a fresh database; it checks base, fenced and durable rules according to the methods the store exposes. -
SQLite stores add
claim_idautomatically. PostgreSQL stores with the defaultcreateTable: truedo the same. When migrations own a PostgreSQL table, apply the returned schema before enablinghandleAttemptorhandleExhaustion:await sql.unsafe(postgresTelegramUpdateStoreSchema('telegram_updates')) // Equivalent addition for an existing table: // ALTER TABLE telegram_updates ADD COLUMN IF NOT EXISTS claim_id TEXT;Legacy
handle(update)continues to use base operations that do not referenceclaim_id, so the migration is required only when the new capability or an explicit baseclaim(..., { durableExhaustion: true })is enabled.
Released migration: 0.108.0
a request that never left is its own send outcome
Who must act: code with an exhaustive switch or Record over TelegramSendFailureReason or
over TelegramSendFailure['evidence'] (stitchkit/telegram). Code that reads retryable and
recipientUnreachable needs no change.
classifyTelegramSendFailure now returns reason: 'not-dispatched' with evidence: 'transport'
for a TelegramNotDispatchedError: the request never left, so it is retryable and repeating it
cannot duplicate a message
(ADR 0253). Only
createTelegramBotTransport throws that error; with the default fetch nothing changes at run time.
-
Add the reason and the evidence to every exhaustive mapping:
// before const code: Record<TelegramSendFailureReason, string> = { /* … */ unknown: 'E_UNKNOWN' } // after const code: Record<TelegramSendFailureReason, string> = { /* … */ 'not-dispatched': 'E_NOT_SENT', unknown: 'E_UNKNOWN', }Search pattern:
TelegramSendFailureReason,TELEGRAM_SEND_FAILURE_REASONSandevidence ===. -
Type-check: a
Recordor an exhaustiveswitchwithout the new members fails to compile. -
To use the distinction, pass
fetch: createTelegramBotTransport()to the senders that need it (see the Telegram guide).
a packaging plugin packages only its own installation
Who must act: builds that call createNativePackaging (stitchkit/files/packaging) from a
stitchkit installation other than the one the bundled entry imports — typically a build tool that
carries its own copy. Builds whose entry and plugin come from one installation are unchanged.
Such a build used to succeed and ship the other installation’s default Darwin loader, which refuses the addon on macOS at run time. It now fails, naming both versions and roots (ADR 0254).
-
Create the plugin from the
stitchkitthe entry resolves, not from the build tool’s copy:// before: the build tool's own stitchkit import { createNativePackaging } from 'stitchkit/files/packaging' // after: resolved from the project that owns the entry const { createNativePackaging } = await import( Bun.resolveSync('stitchkit/files/packaging', projectRoot) )Search pattern:
createNativePackagingin build tooling that is installed apart from the project. -
Check the artifact with
inspectNativeArtifact: a correct build readspackaged. An artifact built by 0.107.0 or earlier carries no loader marker and readsno-loaderwhatever it holds, so rebuild before relying on the check; a test of=== 'unpackaged'alone passes such an artifact.
Released migration: 0.107.0
embedded native packaging names no paths
Who must act: builds that call createNativePackaging (stitchkit/files/packaging) with
delivery: 'embedded'. Companion delivery is unchanged.
A standalone executable carries the Darwin addon inside itself, so the embedded form takes only
platform, architecture and delivery. entryPath and assetPath were required before but
changed nothing in the executable; passing them now is a type error and a schema refusal. The
ready result names the embedded addon by NativePackagingEmbeddedAsset (bytes, size,
sha256), without outputPath, since nothing is written beside the executable.
-
Drop the two paths from the embedded call:
// before createNativePackaging({ platform: 'darwin', architecture: 'arm64', delivery: 'embedded', entryPath: 'cli.js', assetPath: 'native/darwin-arm64.node', }) // after createNativePackaging({ platform: 'darwin', architecture: 'arm64', delivery: 'embedded' })Search pattern:
delivery: 'embedded'. -
Code that read
asset.outputPathfrom an embedded result readsbytes,sizeandsha256only; an annotationNativePackagingOptions/NativePackagingResulton an embedded call becomesNativePackagingEmbeddedOptions/NativePackagingEmbeddedResult. -
Rebuild every artifact made without the plugin by 0.105.0–0.106.1: it carries the build machine’s package path (see Darwin native assets in JS distributions).
Released migration: 0.106.0
a stopped leader settles as stopped
Who must act: code passed to onLeaderSettled of runNativeCommand (stitchkit/process) that
reads kind: 'error' as “the command was stopped”. Code that only handles kind: 'exit', or does
not pass onLeaderSettled, needs no change.
A command stopped by a caller abort, its timeoutMs, an exceeded output budget or a failing sink
used to settle at once as { kind: 'error', cause }. It now waits for the stop signals and settles
as { kind: 'stopped', cause, exitCode, signal }, with how the kernel reported the leader’s end
(ADR 0248).
'error' is left for a leader whose exit was never observed.
-
Where the hook handled a stop under
'error', handle'stopped'there, and take the exit from the event instead of reading the child process:// before onLeaderSettled: (event) => { if (event.kind === 'exit') record(event.exitCode, event.signal) else stopped(event.cause) } // after onLeaderSettled: (event) => { if (event.kind === 'error') return failed(event.cause) record(event.exitCode, event.signal) if (event.kind === 'stopped') stopped(event.cause) }Search pattern:
onLeaderSettledtogether withkind === 'error'orkind !== 'exit'. -
Type-check: an exhaustive
switch (event.kind)without a'stopped'case fails on the new kind. -
A hook that stops an external scope on a stopped command now runs once the leader is gone, still bounded by
cleanupTimeoutMs; no change is needed unless the leader waited for that scope to exit (it gets KILL when the grace ends either way).
Released migration: 0.105.0
CLI publication has its own entrypoint
Who must act: code that imports publishCli, CliPublicationOptions, CliPublicationPhase or
CliPublicationResult from stitchkit/cli. Everything else in stitchkit/cli — createCli, the
manifest schemas, signatures, renderCliInstaller and the updater — is unchanged.
publishCli is release infrastructure, so it now ships from the evolving entrypoint
stitchkit/cli/publish instead of the stable stitchkit/cli
(ADR 0245). Its options, result and
behaviour are the same.
-
Move the publication names to the new specifier; keep every other CLI import where it is:
// before import { publishCli, type CliPublicationOptions, verifyCliManifest } from 'stitchkit/cli' // after import { verifyCliManifest } from 'stitchkit/cli' import { publishCli, type CliPublicationOptions } from 'stitchkit/cli/publish'Search pattern:
\b(publishCli|CliPublication(Options|Phase|Result))\bin files that import from'stitchkit/cli'. -
Type-check: an import left on
stitchkit/clifails with “has no exported member”. -
stitchkit/cli/publishis evolving: read its breaking entries in each minor you upgrade across.
a command stops by its stop policy
Who must act: callers of runNativeCommand (stitchkit/process) that pass killGraceMs.
Callers that omit it need no change: the default stop is the same TERM to the group, 100 ms, KILL.
-
Replace
killGraceMs: Nwith a group stop of the same grace:// before await runNativeCommand({ executable, args, signal, killGraceMs: 500 }) // after await runNativeCommand({ executable, args, signal, stop: { target: 'group', graceMs: 500 } })Search pattern:
killGraceMs:\s*(\d[\d_]*)→stop: { target: 'group', graceMs: $1 }. -
If your own wrapper signals only the leader and waits a long grace (cooperative cancellation), replace it with
stop: { target: 'leader', signal: 'SIGINT', graceMs }(up to one hour). PasskillOnwith your shutdown signal so an application that stops sooner kills the group first. -
The result of a stopped command rejects once the group is gone, up to
graceMspluscleanupTimeoutMsafter the abort; size your caller deadlines on that sum.
a command stops what its leader left
Who must act: callers of runNativeCommand (stitchkit/process) whose command starts a helper
in its own process group that must keep running after the command returns. A daemon that detaches
with setsid (as daemon(3) does) is not in the group and needs
no change.
descendants now defaults to 'terminate-after-leader'
(ADR 0246): when the leader
exits, the members it left in the group are stopped by the command’s stop policy before the
pipes drain.
-
Find the commands that start a background helper on purpose (a
&in a shell script, a spawned child the leader does not wait for) and declare'leave'on them:// before await runNativeCommand({ executable, args, timeoutMs }) // after await runNativeCommand({ executable, args, timeoutMs, descendants: 'leave' }) -
Remove an explicit
descendants: 'terminate-after-leader', or keep it: it is now the default. -
A command whose helper inherited stdout or stderr used to wait for that helper until its deadline. It now completes when the leader exits; drop a workaround that killed the helper from
onLeaderSettled.
native packaging verifies published digests
Who must act: build scripts that call createNativePackaging (stitchkit/files/packaging) and
read asset.sourcePath, hash the addon themselves, switch over the refusal state/code, pass a
platform other than the literal 'darwin', or parse node_modules/stitchkit/native-assets.json.
The package now publishes the size and SHA256 of each Darwin addon, computed when Stitchkit is
built, in native-assets.json at formatVersion: 2. createNativePackaging reads each selected
addon once, compares it with that digest and returns the verified bytes. An installed addon that
differs refuses with NATIVE_ASSET_DIGEST_MISMATCH.
-
Replace every read of
asset.sourcePathwithasset.bytes, and delete your own re-hash of the addon: the result’ssha256is the published digest andbytesalready match it.// before const bytes = readFileSync(asset.sourcePath) if (createHash('sha256').update(bytes).digest('hex') !== asset.sha256) throw … copyFileSync(asset.sourcePath, destination) // after writeFileSync(destination, asset.bytes) -
Handle the third refusal. A
switchoverresult.stategains'mismatch'(code: 'NATIVE_ASSET_DIGEST_MISMATCH'); treat it as a failed build, never as a missing addon. It carries the differingarchitecturewithexpected(published) andactual(installed){ size, sha256 }; log both pairs. -
Pass the target platform as the literal
'darwin'.platformis a closed enum, so a free string (a target name read from configuration,process.platform) no longer type-checks, and another platform throws instead of returningunsupported. Skip packaging for non-Darwin targets before the call:// before const native = createNativePackaging({ platform: target.platform, architecture, … }) if (native.state === 'unsupported') return // after if (target.platform !== 'darwin') return const native = createNativePackaging({ platform: target.platform, architecture, … })unsupportednow means only an architecture the package has no addon for. -
Call
createNativePackagingonce per build and pass the result (itsassetsandplugin) where it is needed. Each call reads and hashes the selected addons again. -
If you read
native-assets.jsondirectly, readassets.<arch>.path,.sizeand.sha256instead of a bare path per architecture; an architecture with no entry is not published.
exclusive locks publish their owner first
Who must act: callers of withExclusiveLock (stitchkit/files) that rely on a lock file with no
readable owner being taken by age, and anything that puts lock files on a filesystem without hard
links. Every other caller needs no change.
A lock is now written to a private .lock-<id>.tmp file in the lock’s directory, then hard-linked to
the lock name. The name never exists without a complete owner record, so ownerlessGraceMs defaults
to null instead of 5 000 ms: a lock file with no readable owner is never taken because of its age.
-
If you depended on age-based reclaim of an empty lock file left by an older writer, opt in:
// before: an empty lock older than 5 s was taken by default await withExclusiveLock(path, run) // after: say so; only legacy empty files need it await withExclusiveLock(path, run, { ownerlessGraceMs: 5_000 }) -
Keep lock directories on a filesystem that supports hard links (local ext4, xfs, APFS and the like).
linkfailing withEPERMorENOTSUPfails the acquisition. -
Expect a
.lock-*.tmpsibling only after a holder was killed between writing its record and publishing it. It is not a lock; the next reclaim in that directory removes the ones whose owner is gone on this machine. Exclude the pattern from directory-size caps or backups if you have any.
one runtime-tool declaration
Who must act: code that names RuntimeToolExecution, RuntimeToolExecutionWithOutput,
RuntimeMcpToolDefinition, RuntimeMcpToolDefinitionWithOutput or ToolSurfaceDefinition; code
that annotates a definition carrying present as RuntimeToolDefinitionWithOutput<I, O> from
stitchkit/tools; imports of stitchkit/agent-runtime/harness-tools; code that compares agent
control error codes; and callers that send a JSON array as text to a string-or-array tool field.
Definitions returned by defineRuntimeTool / createRuntimeToolFactory, native CLI commands and
contract tools need no change.
A runtime tool now has one declaration, RuntimeToolDefinitionWithOutput /
RuntimeToolDefinitionWithoutOutput, exported with the same name from stitchkit/cli,
stitchkit/tools and stitchkit/tools/mcp. It carries no SDK type: the presenters are the adapter’s
extension, named as the fourth type argument. RuntimeToolDefinition is the one registration type.
-
Replace each removed name.
before after RuntimeToolExecutionWithOutput<I, O>(stitchkit/cli)RuntimeToolDefinitionWithOutput<I, O>RuntimeToolExecution(stitchkit/cli)RuntimeToolDefinitionRuntimeMcpToolDefinitionWithOutput<I, O>(stitchkit/tools/mcp)RuntimeToolDefinitionWithOutput<I, O, undefined, RuntimeMcpToolPresenters<z.output<O>>>RuntimeMcpToolDefinition(stitchkit/tools/mcp)RuntimeToolDefinitionToolSurfaceDefinition(stitchkit/tools)ToolSurfaceProjection<RuntimeToolDefinition> -
In
stitchkit/tools, a type that names a definition with presenters passes the presenter type:// before const tool: RuntimeToolDefinitionWithOutput<typeof input, typeof output> = defineRuntimeTool({ …, present }) // after: let defineRuntimeTool infer it, or name the presenters const tool = defineRuntimeTool({ …, present }) const named: RuntimeToolDefinitionWithOutput<typeof input, typeof output, undefined, RuntimeToolPresenters<z.output<typeof output>>> = tool -
An inline handler in
runtimeTools: [...]needs no wrapper: it is typed by the parsed object. To typeinputfrom the schema, construct withdefineRuntimeTool(orsatisfies RuntimeToolDefinitionWithOutput<typeof input, typeof output>) before registering. -
Replace
createAgentHarnessTools(stitchkit/agent-runtime/harness-tools) with the call it made:// before tools: createAgentHarnessTools(({ context }) => ({ services: [svc], context, lifecycle: auth })) // after import { composeToolLifecycle, mountAgent } from 'stitchkit/tools' tools: ({ context, toolFenceLifecycle }) => mountAgent([svc], { context, lifecycle: composeToolLifecycle(auth, toolFenceLifecycle) }) -
Agent control codes:
ACCESS_DENIEDisFORBIDDEN, andCONNECTION_CLOSEDis returned for a closed connection.AgentControlErrorCodelists every code.authorizenow runs once per request; after you withdraw someone’s access, callbinding.revoke(conversationId, matches?)so an open stream is re-authorized and ends with anaccess-denieddelivery. -
A string-or-array field that receives
'["a","b"]'now fails withVALIDATION_ERROR(“pathsis a list written as text”). Send the array itself. A string that is not a list the array member accepts ([preview].png) is still one plain string.
flattened unions keep declaration order
Who must act: consumers that use flattenUnionInput: true and keep snapshot tests, golden
files or provider prompt caches of flattened tool schemas.
A flattened union now lists its properties, enum values and branch labels in the order they first
appear across the variants, not alphabetically. Declaration order often encodes priority, so the
model now reads the order you wrote. Alternatives of different kinds (anyOf) keep their
canonical order, independent of variant order.
- Regenerate every snapshot or golden file of a flattened tool listing (
buildToolManifest,listToolNames, MCPtools/list, Agent tool schemas) and review the diff: only ordering moves. - Expect one provider prompt-cache miss per flattened tool after the upgrade.
- To keep an order you prefer, declare the variants and fields in that order.
connection limits are validated
Who must act: callers that pass timeoutMs or maxResponseBytes to a connection and relied
on an invalid value silently becoming the default.
stitchkit/tools/connections throws a RangeError when timeoutMs is not a positive integer
within timer range (at most 2 147 483 647) or maxResponseBytes is not a positive safe integer.
Before 0.104.0 a value of 0, a negative number, a fraction or an out-of-range number fell back to
the default without a message.
// Before: { timeoutMs: 0 } meant "use the default" by accident.
// After: omit the option to take the default, or pass a real limit.
{ timeoutMs: 30_000 }
one canonical JSON serializer
Who must act: tests that call serializeSurfaceValue from stitchkit/testing, and code that
puts a value nested deeper than 100 levels into agent store events, archives or projections
through canonicalAgentJson.
canonicalJson from stitchkit/primitives is the package’s one canonical serializer. It
admits dense arrays, plain objects, strings, booleans, null and finite numbers; an undefined
object member is omitted. It refuses everything else with a CanonicalJsonError, a TypeError
whose reason says why: 'depth', 'nodes' and 'bytes' are the limits you set, while
'cycle', 'negative-zero' and 'not-json' say the value is not JSON data.
serializeSurfaceValue is removed. It produced the same bytes without limits:
// Before
import { serializeSurfaceValue } from 'stitchkit/testing'
const text = serializeSurfaceValue(manifest)
// After: the limits default to 100 levels, 100 000 nodes and 1 MiB; raise them for a large manifest
import { canonicalJson } from 'stitchkit/primitives'
const text = canonicalJson(manifest, { maxBytes: 16 * 1024 * 1024 })
canonicalAgentJson in stitchkit/agent-runtime is canonicalJson with the node and byte
limits at their maximum. A value both accepted has the same bytes as before, so stored digests
and archives still match: like z.json() before it, the store hashes -0 as 0 and drops an
own __proto__ member (the public canonicalJson refuses -0 and keeps __proto__). What
changed is the refusal table: an undefined object member used to fail and is now omitted, and
a value nested deeper than 100 levels is now refused.
// Before: canonicalAgentJson({ a: undefined }) threw a ZodError
// After: canonicalAgentJson({ a: undefined }) === '{}'
// canonicalAgentJson(nestedBeyond100Levels) throws CanonicalJsonError (reason 'depth')
directory inbox entries
Who must act: producers that drop hard links into an inbox directory, and callers that pass
accept the output of a transforming schema.
A file dropped into the inbox directory with more than one link is set aside as invalid
(detail managed file has multiple links). Another name can still rewrite such a file after it
was validated, so it is never trusted. Write each entry as its own regular file: a temporary
name in the same directory, then a rename onto <name>.json.
accept takes the schema’s input type, stores that input as given and parses it with the
schema on delivery. A schema without a transform has the same input and output type and needs
no change. With a transform, pass the input:
const schema = z.object({ amount: z.string().transform(Number) })
// Before: accept({ source, key, entry: { amount: 42 } }) // typed as the output; `z.string()` refused it
// After: accept({ source, key, entry: { amount: '42' } }) // delivered as { amount: 42 }
A name that is already in rejected/ no longer stops the pass: identical bytes are the entry
already set aside, different bytes are kept as rejected/<name>.<16 hex digits>.
journal startup scan
Who must act: operators that read status.recovery right after createDiagnosticJournal to
find damaged rows in the middle of a file.
Opening a journal reads only the last line of the active file and of every retained
generation (startupScan: 'tails', the default). A crash tears the end of a file, so a torn tail
is still reported and still rotated away, and opening no longer parses and validates every
line of every generation. It still reads each file once in large blocks to count lines, so the time
grows with the bytes retained, at read speed. Pass startupScan: 'full' to read and validate every line as before:
// Before: every open parsed every line of every generation.
// After: the same full scan, on request.
const journal = await createDiagnosticJournal({ path, eventSchema, limits, startupScan: 'full' })
journal startup quarantine
Who must act: operators of a journal that is a source of truth and must not start over
damaged retained files; code that constructs DiagnosticJournalRecoveryError or reads its
recovery; protocols that embed DiagnosticJournalRecoveryStatusSchema, and their readers.
A diagnostic journal needs no change.
Opening a journal no longer refuses to start over a torn active file with maxFiles: 1, a
generation name that is not a regular file or a retained file it cannot read. It renames the file
to <file>.quarantined-<epoch> beside the journal, starts, and lists the move in
getStatus().recovery.quarantined. Quarantined files are outside retention and never deleted;
every later open lists them again, without a reason, until you remove them.
-
A journal whose damage must stop the process declares the old behaviour:
// Before: refusing was the only behaviour. await createDiagnosticJournal({ path, eventSchema, limits }) // After: await createDiagnosticJournal({ path, eventSchema, limits, onStartupRefusal: 'fail' }) -
Branch on the refusal’s
reasoninstead of its message or error class. Every startup refusal about retained evidence is aDiagnosticJournalRecoveryError, including the former plainErrorfor a non-file generation and the rawEACCESfor an unreadable one (now incause):// Before: catch (error) { if (error instanceof DiagnosticJournalRecoveryError) use(error.recovery) } // After: catch (error) { if (error instanceof DiagnosticJournalRecoveryError && error.reason === 'torn-without-retention-slot') use(error.recovery) }recoveryis present only fortorn-without-retention-slot. The constructor takes one options object:new DiagnosticJournalRecoveryError({ reason, file, recovery?, cause? }). -
A protocol that embeds
DiagnosticJournalRecoveryStatusSchemacarries the new optionalquarantinedarray (at most 32 entries,reasonoptional) andquarantinedUnlistedcount. The schema is.strict(): upgrade every reader that parses it with its own copy before a writer on this version sends a status with either field, or bump your protocol version.
error constructors take named options
Who must act: code that constructs an AppError or an ApiError with anything beyond its
code, and classes that extend AppError and call super(code, message, status, …). Code that
only throws the notFound, badRequest, unauthorized, forbidden, conflict and
rateLimited helpers, or only reads code, status, details, hint, traceId,
retryable and cause from a caught error, needs no change.
AppError (stitchkit/contract, stitchkit/server, stitchkit/node) and ApiError
(stitchkit) keep the code as the one positional argument. Every other value is a named
option, so a call site reads without the signature open. new AppError(code) and
new ApiError(code) are unchanged. The compiler finds every old call: a string or number
in the second position is a type error.
-
AppError: movemessage,status,details,hint,traceIdandretryableinto one object after the code.statusdefaults to 500 andmessageto the code.// before throw new AppError('CONFLICT', 'Selection is stale', 409, { revision: 7 }) throw new AppError('BUSY', 'Try later', 503, undefined, undefined, undefined, true) // after throw new AppError('CONFLICT', { message: 'Selection is stale', status: 409, details: { revision: 7 } }) throw new AppError('BUSY', { message: 'Try later', status: 503, retryable: true }) -
A subclass passes the same object to
super:// before super('FEATURE_LOCKED', 'locked', 403) // after super('FEATURE_LOCKED', { message: 'locked', status: 403 }) -
ApiError: the old order wascode, status, details, message, hint, traceId, errorOptions, retryable. Name each value;statusdefaults to 0 and the formerErrorOptionsargument is now thecauseoption.// before new ApiError('RATE_LIMITED', 429, undefined, 'slow down', 'wait', 'trace-1', { cause }, true) new ApiError('VALIDATION_ERROR', 0, { issues }, 'bad input') // after new ApiError('RATE_LIMITED', { status: 429, message: 'slow down', hint: 'wait', traceId: 'trace-1', retryable: true, cause }) new ApiError('VALIDATION_ERROR', { status: 0, details: { issues }, message: 'bad input' }) -
Both classes accept the standard
cause, whicherror.causereturns; omit the option to leave the property absent.
Released migration: 0.104.0
JS bundles carrying Darwin native capabilities also need the delivery migration first affected in 0.103.13. Consumers upgrading from 0.103.12 or earlier must apply it even if their TypeScript API is unchanged.
CLI runtime-tool construction
Who must act: callers writing a schema-dependent handler or MCP/AI present directly
inside an annotated CliConfig.runtimeTools or CliInvokerConfig.runtimeTools object.
Native commands and values returned by defineRuntimeTool / createRuntimeToolFactory
remain valid. Execution, auth, validation and the invocation result envelope keep their behavior.
Construct a CLI-only managed definition with the neutral schema-aware type before registration:
import type { CliInvokerConfig, RuntimeToolExecutionWithOutput } from 'stitchkit/cli'
import { z } from 'zod'
const input = z.object({ text: z.string() })
const output = z.object({ size: z.number() })
// Before: handler receives the heterogeneous registry context.
const config: CliInvokerConfig = {
name: 'app',
runtimeTools: [{
name: 'measure', description: 'Measure text',
identity: { serviceName: 'text', action: 'measure', method: 'POST' },
transports: ['CLI'], input, output,
handler: ({ input }) => ({ size: String(input.text).length }),
}],
}
// After: handler input and output derive from the construction schemas.
const measure = {
name: 'measure', description: 'Measure text',
identity: { serviceName: 'text', action: 'measure', method: 'POST' },
transports: ['CLI'], input, output,
handler: ({ input }) => ({ size: input.text.length }),
} satisfies RuntimeToolExecutionWithOutput<typeof input, typeof output>
const nextConfig: CliInvokerConfig = { name: 'app', runtimeTools: [measure] }
For a shared MCP/AI tool, construct the definition with defineRuntimeTool from
stitchkit/tools, including its typed SDK present callbacks, then pass the returned value
to runtimeTools. Install the corresponding SDK peers for that integration. A CLI-only
consumer needs stitchkit, zod and its ordinary Node type tooling.
RuntimeToolExecution names the heterogeneous registration contract. Call a registered
operation through createCli or createCliInvoker; its erased handler input is never
because the managed runner owns schema validation before execution. For a managed void
definition, use RuntimeToolDefinitionWithoutOutput<typeof input>.
MCP construction, state stores and delivery
Who must act: schema-dependent runtime tool literals written directly inside MCP mount
configuration, MCP presenters returning malformed content or metadata, custom StateStore
implementations, file-store consumers using retired lock
options, broadcast consumers reading halt or retrying unknown send outcomes, and consumers
that validate HTTP error envelopes with a closed schema. Applications collecting raw tool
causes from console.error must configure an internal tool hook sink.
MCP-only code can import the existing server factories from stitchkit/tools/mcp and
construct its schema-aware tool before heterogeneous registration:
import { buildMcpServer, type RuntimeMcpToolDefinitionWithOutput } from 'stitchkit/tools/mcp'
import { z } from 'zod'
const input = z.object({ value: z.number() })
const output = z.object({ doubled: z.number() })
// Before: runtimeTools: [{ input, output, handler, present }]
const double = {
name: 'double', description: 'Double a number',
identity: { serviceName: 'numbers', action: 'double', method: 'POST' },
input, output,
handler: ({ input }) => ({ doubled: input.value * 2 }),
present: { mcp: ({ doubled }) => ({ content: [{ type: 'text', text: String(doubled) }] }) },
} satisfies RuntimeMcpToolDefinitionWithOutput<typeof input, typeof output>
const server = buildMcpServer({ serverInfo: { name: 'numbers', version: '1' }, services: [], runtimeTools: [double] })
Full MCP/AI integrations keep defineRuntimeTool from stitchkit/tools; those typed values
remain accepted by the MCP leaf and CLI. The leaf requires the MCP SDK and Zod, with no AI peer.
RuntimeMcpPresentation preserves the official SDK’s required content and typed _meta
fields. A presenter returning {} must return { content: [] }; text blocks require string
text, and metadata must be an object. structuredContent and isError remain framework-owned.
This restores SDK checks that an indexed mapped type previously erased.
Unexpected tool causes remain in hooks.onToolError and the error field of
hooks.afterToolCall. Protocol stderr carries safe errors. MCP resolver failures use those
same observers without executing the handler or its lifecycle again. HTTP’s default
unexpected-error logging retains its behavior.
A custom state adapter’s update must invoke transition(current, context) while it still
owns the serialized transaction. context.assertHeld() checks the actual transaction or lock
generation and refuses after completion, rollback or ownership loss. A no-op guard cannot
satisfy this contract. Existing transition callbacks may keep one parameter. For the owning
file adapter, replace { staleLockMs, retryMs, lockTimeoutMs } with { lockTimeoutMs }:
the remaining budget controls acquisition waiting, never expiry of a live transaction.
Unknown legacy PID locks and historical temporary files are preserved; recover them only
after independently proving their owner is gone.
Broadcast consumers change report.halt.reason checks to the closed halt.kind union:
retry-after, transient, permanent or ambiguous. A custom MTProto adapter can classify
an acknowledged flood refusal as { kind: 'retry-after', retryAfterMs: wait }; the exact
provider wait is respected. A wait over maxRetryDelayMs halts with the recipient pending.
Unknown write outcome becomes uncertain; restarting the same broadcast does not send it
again. A retry classification must certify that the attempt did not apply the external effect.
HTTP errors gain optional boolean error.retryable; framed contract streams use the same
field. Update closed consumer schemas before producers declaring it. false on 502 and
true on 409 remain explicit recommendations through the remote tool path. An absent field
retains prior status-derived defaults; the field does not change the HTTP client’s retry policy.
The existing seventh ApiError constructor argument remains ErrorOptions; retryability is
the new eighth argument.
Caller cancellation now reaches implementRemote’s existing HTTP call. Applications bind
signals to createCli / createCliInvoker and cooperative handlers observe ctx.signal.
REQUEST_ABORTED is nonretryable and exits 130. A disconnected caller does not prove that
an origin ignoring cancellation or an external provider stopped; keep resource leases until
that work actually completes.
Released migration: 0.103.13
Darwin native assets in JS distributions
Who must act: Bun builds that import stitchkit/server, stitchkit/files or
stitchkit/process (Linux-only server apps included) and use --outfile, copy only
outputs[0], or package a single file; custom single-file/archive packagers and installers
delivering native process/files capabilities.
First affected publication: 0.103.13, through 0.104.2. It shipped as a patch; its
changed delivery graph requires this migration despite unchanged API signatures.
In those versions every Bun bundle that reaches the lock or process-identity code,
stitchkit/server included, carries both Darwin .node addons as extra outputs on every
operating system. bun build --outfile then fails with
cannot write multiple output files without an output directory, and --outdir writes the
addons next to a Linux artifact. Releases after 0.104.2 resolve the default loader at run time,
so these bundles are one JS file with no .node output again. The static loader exists only
in a build that uses the stitchkit/files/packaging plugin.
# Before: one JS file was assumed to be the entire distribution.
bun build src/native.ts --target=bun --outfile=dist/native.js
# After: retain every output, with its relative path.
bun build src/native.ts --target=bun --outdir=dist --entry-naming=native.js
For Bun.build, copy all result.outputs, not just the JS entry or outputs[0].
A single-file JS distribution cannot embed a native .node addon automatically.
An archive may contain JS plus companion assets, materialized together before execution.
Custom output layout uses the evolving build-only packaging contract in the
native IO guide.
Three deliveries have different rules: an ordinary npm install retains the package’s
native assets; a JS distribution must ship its complete output graph; a Bun compiled
executable embeds the matching addon and may still use --outfile. In 0.103.13–0.104.2 a plain
bun build carries the addon without a plugin. Later releases load it from a bundle only when
the build uses createNativePackaging (delivery: 'companion' for JS, 'embedded' for a
compiled executable). Without the plugin, Bun may still copy the addon’s bytes into a compiled
executable, but the loader never uses them, so their presence proves nothing.
What an unpackaged bundle does depends on the release:
- 0.105.0–0.106.1: the bundle carries the absolute path of the package on the build machine,
because the loader read
__dirname. It looks for the addon there, so it works only on that machine, and the path itself leaks into a distributed artifact. Rebuild with a later release. - 0.107.0 and later: no build path is in the artifact, and the first call that needs the
addon reports the Darwin backend
unavailablewith stagepackaging.
Only macOS is affected, and only process identity (observeProcessInstance,
probeProcessOwner), exclusive locks and contained Agent file operations load the addon. The
managed file boundary, atomic writes and the chunk spool never do. The
native IO guide is the canonical recipe and
shows how to check an artifact by its loader without running it on a Mac. Check the resulting
artifact offline outside the build tree and node_modules, including process identity,
live-owner lock refusal, dead-owner recovery and contained IO. A missing or corrupt addon
remains unavailable, never evidence that an owner died.
CLI: repeated options are refused
Who must act: wrappers, aliases and scripts that append --json, --profile X or an
application global to arguments the user may already have given, and anything that passes the
same global twice.
First affected publication: 0.103.13. It shipped as a patch; the change in grammar needs
this migration.
A repeated option that is not an array field is an argument error:
--json --json and --profile a --profile b fail with --json was passed 2 times. Before
0.103.13 the last value won, for framework globals, application globals and repeated
kind: 'other' fields alike. Array fields, and untyped passthrough fields that keep their
raw-list grammar, still repeat.
# Before: a wrapper appended its default and the last value won.
mytool deploy --profile staging "$@" --profile production
# After: build the argument list once, so each option appears once.
mytool deploy --profile "${PROFILE:-production}" "$@"
Dotted leaves such as --filter.name follow the same rule as their parent field. Declare a
field as an array when repetition is meant.
Released migration: 0.103.0
Diagnostic journal startup preserves torn evidence and inspects archives
Who must act: operators using maxFiles: 1 who can encounter a torn active
file, or whose retained files are unreadable by the writer. Clean single-file
journals continue to work; successful startup now requires read access to every
retained generation.
import { createDiagnosticJournal } from 'stitchkit/application/diagnostic-journal'
// Before: a torn active file could be discarded during startup.
await createDiagnosticJournal({ ...config, limits: { ...config.limits, maxFiles: 1 } })
// After: retain the torn file while the fresh writer starts in a new active file.
await createDiagnosticJournal({ ...config, limits: { ...config.limits, maxFiles: 2 } })
With single-file retention, a torn active file throws DiagnosticJournalRecoveryError
from stitchkit/application, carrying recovery observations and releasing its lock
without deleting evidence. Inspection I/O errors also fail startup and release the
lock; restore filesystem access rather than treating that failure as a bad JSON row.
Normal rotation retains the configured finite eviction policy.
For archive inspection, replace ad hoc line parsing with readDiagnosticJournal
from the same filesystem leaf. Handle its frame | anomaly stream explicitly;
missing files, permissions and unsafe paths still throw. See the
journal guide.
Released migration: 0.102.0
view_file receives an array, not an array encoded as text
Who must act: callers sending paths as a JSON-encoded array string, or
matching the former media-extension refusal for that input. Managed MCP,
Agent and CLI surfaces now reject it as VALIDATION_ERROR before IO; raw MCP
reports an input-validation error. Correct single paths and arrays need no change.
// Before
{ paths: '["image.png"]' }
// After
{ paths: ['image.png'] }
// One file may also be a string
{ paths: 'image.png' }
The same rule applies to an array element containing another encoded array.
Do not change generic string-union coercion: path strings remain strings.
A legitimate filename such as [preview].PNG is still accepted. Local
unsupported-extension errors now include the caller’s path and extension;
match the error code rather than the former generic message.
Released migration: 0.101.0
TelegramInitDataUser is TelegramUser
Who must act: code that names the type TelegramInitDataUser from
stitchkit/telegram. The record is the same; its name now says it is any
Telegram user, since parseTelegramUser(ctx.from) returns it for a Bot API user
too.
// Before
import type { TelegramInitDataUser } from 'stitchkit/telegram'
function ensureAccount(user: TelegramInitDataUser) {}
// After
import type { TelegramUser } from 'stitchkit/telegram'
function ensureAccount(user: TelegramUser) {}
TELEGRAM_BOT_TOKEN_PATTERN matches the secret half only
Who must act: code that uses TELEGRAM_BOT_TOKEN_PATTERN to find or cut a
whole token. It now matches what follows <bot id>:, so a masking replace keeps
the bot’s id — 123456:[redacted]. Masking needs nothing any more: redact and
every logger on it mask bot tokens by shape; remove the pattern from
sensitiveUrlPatterns, and use redactTelegramBotToken(text) for a string of
your own.
// Before
createJsonLogger({ sensitiveUrlPatterns: [TELEGRAM_BOT_TOKEN_PATTERN] })
text.replace(new RegExp(TELEGRAM_BOT_TOKEN_PATTERN.source, 'g'), '[redacted]')
// After
createJsonLogger({})
redactTelegramBotToken(text)
Released migration: 0.100.0
watch capacity and session/live/delivery adoption
Who must act: watch consumers exceeding 1024 hub sources, 1024 hub subscribers, 1024 client keys or 128 listeners per key. These previously unbounded collections now refuse excess admission. Choose explicit positive finite limits, for example:
// Before: no hub-wide capacity bounds.
createWatchHub({ read, watchable });
// After: explicitly budget a larger workload.
createWatchHub({ read, watchable, maxSources: 4096, maxSubscribers: 4096 });
// Client limits are configured independently.
createWatchClient({ ...clientConfig, maxKeys: 2048, maxListenersPerKey: 256 });
The remaining steps apply only to applications opting into session lifetimes, scoped watched reads, managed subscriptions or multi-action notification delivery. Existing clients within the limits and single-send outboxes need no migration.
Replace local session generation counters with one createSessionScope; share it
with createSessionCredentials. Guard transport attempts, complete asynchronous
operations and final synchronous delivery. Route credential persistence through
one coordinator. Keep provider-specific error classification and bounded command
retry in the application. Coordination is per client instance.
Upgrade both watch endpoints before enabling session-scoped watches: strict older
servers reject the new optional WatchKey.instance. Server-verified scope controls
sharing; the client instance is only routing identity. Bind exact watched handles
to session-qualified cache keys. Existing delta/reconnect behavior stays in place.
For applications needing more than 1024 hub sources/subscribers or client keys, set
explicit finite capacity options. Enable a reconciliation interval when lost hints
must recover without a reconnect.
For multi-action outboxes, enqueue new items with a versioned delivery plan and
return small JSON receipts from send. Existing queued single-send items should
be drained before enabling a delivery executor on that queue, or migrated under
the store’s atomic transition. When a new executor version ships, list the old
version’s projection in delivery.retiredVersions until its queued items drain;
a plan version with no executor waits, reported to onError as
NotificationPlanVersionError (from 0.100.1).
Make projection idempotent. Provider idempotency/reconciliation remains necessary
for a crash between remote success and checkpoint. These APIs are available starting with 0.100.0.
exclusive lock recovery
New lock and reclaim-guard records include owner.process: kernel boot identity,
process birth token and namespace. readDiagnosticJournalLockDiagnosis(error)
adds identity to distinguish matched lifetime evidence, PID reuse, a different
boot, an absent PID, a legacy record or unavailable identity. Legacy alive only
means the PID is present; it does not prove that the recorded writer still exists.
Who must act: deployments recovering a legacy lock with a reused live PID,
or bundling file-lock consumers into a standalone Darwin executable. Legacy
records cannot safely infer their missing boot/start identity from acquiredAt.
Stop all writers to that resource, verify exclusive ownership, preserve the old
lock for incident evidence outside the active lock path, and restart with the
new package. Never delete a lock merely because it is old. New acquisitions
write the modern record; no journal data migration is needed.
Darwin distributions must retain the package’s architecture-matched native
.node asset for process birth measurement, as for contained filesystem access.
If native/proc identity cannot be read, new locks explicitly record null and
recovery refuses rather than treating unknown ownership as death. From 0.100.1 a
Linux kernel without time namespaces (before 5.6) records the PID namespace alone
instead of null, so its locks recover like any other. Existing
refuse policy and the separately configured ownerless-file grace are unchanged.
Released migration: 0.99.0
structural alternatives in flattened tool schemas
Who must act: applications using flattenUnionInput: true with schema
walkers, snapshots or provider restrictions that assume there is no nested
anyOf. Applications without that assumption need only update the dependency.
Colliding object/array fields retain their complete structural alternatives:
// Before: loses both field vocabulary and array items.
{ type: ['object', 'array'] }
// After: structural alternatives live inside the property schema.
{ anyOf: [
{ type: 'array', items: {
type: 'object', properties: { id: { type: 'string' } }, required: ['id'],
} },
{ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
] }
Alternatives come in kind order, independent of variant order. The same shape
covers every collision of different kinds, scalars included: string against
string[] becomes { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }] }. A nullable field keeps null in each alternative’s type, and
differing nested object unions remain nested anyOf inside the alternatives. Update
snapshots to this order.
Traverse each nested alternative when inspecting schemas and regenerate schema
snapshots. Verify the actual manifest with the chosen provider. Keep flatten
enabled when a top-level discriminated union needs to be advertised as an
object; do not erase items or introduce consumer-specific schema patches.
The root object, discriminator enum, requiredness hints and strict runtime
contract validation are unchanged. See ADR 0205.
Released migration: 0.98.0
automatic stdin availability
Who must act: CLI applications or scripts relying on automatic stdin routing from a producer that can take more than 250 ms to write its first byte.
Automatic routing now probes for the first byte for 250 ms. An empty open pipe leaves the required field unset and normal validation reports its name. Once data arrives, the entire stream is read until EOF without a total deadline.
# before: may now fail if the producer starts late
slow-producer | myapp generate
# after: provide the value explicitly after the producer completes
myapp generate --prompt "$(slow-producer)"
For large input that should stay in a pipe, supply a CliConfig.stdin reader
that waits for the producer and EOF; this existing hook is not wrapped in the
availability probe. A custom reader owns its limits and error handling.
- remains literal data. TTY and empty EOF produce ordinary validation errors;
explicit arguments skip stdin. JSONL stream/batch commands, MCP/serve and
long-running handlers have no new deadline. See ADR 0203.
Released migration: 0.97.0
schedule delivery
Who must act: consumers of createAgentScheduleService, exhaustive switches on
AgentSchedule.state, and operators opening existing agent-runtime SQLite stores.
- Stop old scheduler processes and back up SQLite before startup. The store migrates transactionally to v4; old binaries cannot open it. Rollback uses the backup.
- Handle
failedas a terminal schedule state. To refuse future delivery (including recurring delivery), return{ status: 'terminal', reason: 'destination closed' }. The occurrence remains unadvanced;schedule/failedcarriesterminal: true. - A throw or
{ status: 'retry', reason: 'temporarily unavailable' }retries the same key after 1, 2, 4, 8, 16, 32, then 60 seconds.nextAtremains the original due time;retryAt,attemptsandlastErrorexpose retry diagnostics. Success clears them. - Resolve
voidonly after durable consumer admission. Honor the suppliedsignal: dispatch has a 30-second deadline and a 60-second lease. Durable deduplication byidempotencyKeyis required even after abort or crash; do not implement sleeps in dispatch. - Stale/cancelled attempts emit no
schedule/firedorschedule/late. Observe the cancellation event instead of relying on a synthetic cancelled firing.
// before
async function dispatch(request) {
if (await destinationClosed(request.conversationId)) throw new Error('closed')
await admit(request)
}
// after: use this callback as createAgentScheduleService({ dispatch, ... })
async function dispatch(request) {
if (await destinationClosed(request.conversationId)) {
return { status: 'terminal', reason: 'destination closed' }
}
await admit({ ...request, signal: request.signal }) // durably deduplicates idempotencyKey
}
Released migration: 0.96.0
resource-context-admission
One breaking change in stitchkit/application: ManagedResourceContext gains a
required admission. Every context the kernel hands a resource already carries
it; only a context built by hand needs it.
-
Give a hand-built resource context an
admission. Who must act: a project whose tests call a resource’s phase directly with a context object they wrote themselves (resource.close?.({ applicationId, signal, … })). The type checker names each one:// before await resource.close?.({ applicationId: 'test', signal, now, reportHealth, use }) // after await resource.close?.({ applicationId: 'test', signal, now, reportHealth, use, admission: { acquire: () => null, acquireWhenAccepting: () => new Promise(() => {}) }, })A test that needs real admission starts the resource inside
createApplicationinstead. -
Nothing to do for grammY polling, but know what changed. Who must act: nobody.
grammyPollingResourcenow admits updates by batch: it does not fetch while the application is not accepting, and finishes the batch in hand beforebot.stop(). A project that wrote its owngetUpdatestransformer for this (waiting onapp.admissionbefore returning a batch) deletes it; keeping both waits twice and is harmless.
Released migration: 0.95.0
One breaking change in stitchkit/application: the process lifecycle ledger
counts downtime as the window nobody answered in, and gains the draining
phase and the startup-failed outcome. A ledger file written by an earlier
version loads unchanged — the new unavailableAt reads as null.
-
Read downtime from the ready fact. Who must act: a project that reads
downtimeMsfrom astartedfact. It is gone fromStartFact; the number it carried isprocessGapMs. The downtime now arrives with the new run’s readiness:// before ledger.subscribe((fact) => { if (fact.type === 'started') report(fact.previousExit, fact.downtimeMs); }); // after ledger.subscribe((fact) => { if (fact.type === 'started') previousExit = fact.previousExit; if (fact.type === 'ready') report(previousExit, fact.downtimeMs); }); -
Handle the new values. Who must act: a project that switches exhaustively over
LifecycleTermination(new:draining,startup-failed),PreviousExit(new:startup-failed) orProcessLifecycleFact(new:DrainingFact,type: 'draining'). The type checker names each switch. An open run isactiveordraining; a check written astermination === 'active'to mean “still running” becomestermination === 'active' || termination === 'draining'.TransitionReadyInputis renamedTransitionRunInput— the same fields, now also taken bytransitionProcessDraining; rename the import. -
Record the drain and the readiness when driving the ledger by hand. Who must act: only a project that calls
createProcessLifecycleLedgermethods itself instead of mountinglifecycleLedgerResource. CallrecordDraining()where the application stops admitting work, before it drains; without it downtime is counted from the recorded stop. CallrecordReady()when the run serves; a run stopped without it now endsstartup-failed, notclean.
Released migration: 0.94.0
One breaking minor that consolidates: tool options move into one group, and
names that were exported from two entrypoints keep only their owner. Every step
is an edit a type checker or defineContract points at; the few behaviour
changes that need no edit are under Changed and Fixed in the changelog.
-
Move endpoint tool options into
tool. Who must act: every project with an endpoint that setstoolName,ui,annotationsormcp. Run the codemod from a checkout of this repository, then typecheck:bun packages/core/scripts/codemod-tool-group.ts --check path/to/your/src # list bun packages/core/scripts/codemod-tool-group.ts path/to/your/src # rewrite// before { method: 'GET', path: '/', desc: 'List', toolName: 'user_list', mcp: { inputRequired } } // after { method: 'GET', path: '/', desc: 'List', tool: { name: 'user_list', mcp: { inputRequired } } }The codemod leaves two kinds of endpoint for a person and prints where: one built with a spread (
{ ...base, toolName }), and one exposed on HTTP only, whose tool options never reached a tool — delete them there. Anything left over fails at startup, by name — a key moved into the group under its old name (tool: { toolName }) included:Contract "users": endpoint "list" sets `toolName` — tool options live in `tool` since 0.94.0: use tool.nameexposedoes not move.MethodDef— what hooks and runtime tools see — keepstoolName,ui,annotationsandmcpat the top level, so hook code does not change. A tool view is declared only withwithToolView, which now puts it intool.view. -
Change imports of moved names. Who must act: projects importing any of these; the compiler lists every site.
Name From To RuntimeToolTransport,ToolSurfaceTransport,ToolInvokerTransportstitchkit/tools,stitchkit/tools/invoker,stitchkit/tools/connectionsToolTransportfromstitchkit/contractcreateCli,defineCliCommand,CliConfig,CliSurfaceSource,CliPresentationPolicyConfig,CliWaitConfig,ExitCodeMap,CliCommand*stitchkit/toolsstitchkit/cliparseSSE,ParseSSEOptionsstitchkit/serverstitchkitrunAgentStoreConformance,AgentStoreConformance*,createAgentRace*,AgentRace*stitchkit/testingstitchkit/agent-runtime/testingcreateLocalStepDurability,LocalStepDurability,LocalStepDurabilityOptions,StepDurabilityLedgerstitchkit/agent-runtimestitchkit/tools -
Regenerate a surface snapshot only if it reports drift. Who must act: a project whose committed snapshot has an object with integer-like keys (
"9","10") — they are now sorted like every other key. Every other snapshot, CLI manifest signature and agent-store archive is byte-identical.
Released migration: 0.93.0
-
Regenerate every committed surface snapshot, once.
manifestVersionis3and each operation row gainedmcp— the rounds it declares before it runs. Most rows getnull, so the first diff is large and mechanical. Who must act: anyone with a committed snapshot. A snapshot on the older version is refused by name rather than failing a schema literal:Surface snapshot is manifestVersion 2; this build writes 3. Regenerate it and review the diff — the format changed, so the first regeneration is expected to be large.// before — a declared round left no trace, so declaring or removing one // passed review invisibly manifest.operations[0] // after manifest.operations[0].mcp // null | { inputRequired: [{ key, message, schema }] } // | { inputRequired: 'resolved-per-call' }A project that kept its own test for “declared
inputRequiredimplies a configured state key” can drop it: the snapshot holds that link now. -
Nothing else to do. Native commands that declare
outputbecame invokable in process; a name that answeredNOT_FOUNDfromcreateCliInvokerin 0.92.0 may now resolve. If an application relied on native commands being unreachable there, stop passing them to the invoker.
Released migration: 0.92.0
-
RequestEvent.method,pathandstatusCodeare optional. Work that did not arrive over a transport now records their absence instead of inventing values — a background loop used to writemethod: 'AGENT', which is not a verb. Who must act: anyone whoseRequestEventSinkConfig.writeputs these into NOT NULL columns, or whose filter readsevent.method.// before write: (event) => db.audit.create({ data: { method: event.method, path: event.path, status: event.statusCode } }) // after — a job has none of the three; `kind` says which kind of row it is write: (event) => db.audit.create({ data: { kind: event.kind, name: event.name ?? null, method: event.method ?? null, path: event.path ?? null, status: event.statusCode ?? null, } })A filter written as
event.method !== 'GET'keeps jobs (undefined !== 'GET'), which is usually what you want. To exclude them deliberately:event.kind === 'request' && event.method !== 'GET'. -
applyCliUpdateneeds the manifest when given a trust root. The signature covers the manifest and every asset digest together, so an asset alone cannot be verified. Who must act: only callers adopting signature verification; the unsigned call is unchanged.// before await applyCliUpdate({ asset }) // after await applyCliUpdate({ asset, manifest: check.manifest, trust }) -
An MCP continuation minted before this release is refused once. The signed round state now carries a fingerprint of the question plan, checked on every round, so a plan that moved between rounds is a refusal rather than a different question asked under the previous one’s key. A conversation already in flight at deploy time has no fingerprint and gets
INVALID_REQUEST_STATE; the host re-asks and the second attempt succeeds. Who must act: nobody — but expect a small number of these in the minutes after a deploy, and do not read them as tampering.
Released migration: 0.91.0
-
STITCH_ERROR_STATUSgainedNOT_IMPLEMENTED(501). An exhaustive error vocabulary must name it — without the rowdefineErrors({ mapping: { exhaustive: true } })stops compiling and then throws at startup. Nothing else in this release requires action.// before map: { …, WAIT_TIMEOUT: 'timeout' } // after map: { …, NOT_IMPLEMENTED: 'not_implemented', WAIT_TIMEOUT: 'timeout' }The code is thrown only by the new dev-only
onMissingHandler: 'stub'policy; a vocabulary that is not exhaustive is unaffected.
Released migration: 0.90.0
-
stitchkit.watch.valueis a discriminated union onkind—full,delta,unchanged— and every frame carriesfingerprint. Both ends must come from the same major. A client older than this release reads adeltaframe as a value ofundefined, silently; upgrade the hub and the pages that talk to it together, or setdeltaMemoryBytes: 0on the hub until they are.Applications using
createWatchClientneed no code change: it rebuilds the value and hands the listener the whole answer as before. Code that readsWatchValueFrame.valuedirectly — a hand-written subscriber, a test double — narrows onkindfirst.// before: frame.value // after: if (frame.kind === 'full') hold(frame.value); else if (frame.kind === 'delta') hold(applyWatchDelta(held, frame.delta)); // 'unchanged' leaves what you hold standing -
AttachedWatcher.opentakes an optional third argument,have. A custom server binding that forwardsstitchkit.watch.openshould pass the payload’shavethrough; omitting it costs a whole value on every reconnection and is otherwise harmless.
Released migration: 0.89.0
-
Custom
AgentRuntimeStoreadapters implementseedConversationInput; customAgentRuntimeStoreDriveradapters implementseeds.loadandseeds.createtogether with atomic, ordered history insertion. Use the maintained Prisma example and runrunAgentStoreConformanceagainst the adapter. Built-in memory and SQLite stores already implement the contract; SQLite migrates to schema version 3. -
OpenRouter usage normalization consumes each completed SDK step’s
usage, includingraw, rather than aggregate usage. Missing counters stayunavailable.// before: normalizeOpenRouterUsage(await result.usage, metadata) // after: for (const step of await result.steps) { const usage = normalizeOpenRouterUsage(step.usage, step.providerMetadata); // Persist the step's measured usage with its provenance. } -
Durable step bodies return lossless JSON. Convert dates explicitly, reject invalid numbers upstream, and return
nullafter an effect-only operation.await context.step('send', async () => { await sendIdempotently(); return null; });First execution, replay and concurrent callers receive detached values. Persistence does not make external effects transactional; the host retains idempotency and recovery.
How to move a consuming project from one stitchkit version to another — including across many versions at once (a project frozen on an old version, then jumped forward). The process is mechanical: stitchkit marks every breaking change in one place and one format, so you can recover the full migration from the version diff.
The one rule that makes this work
A release that breaks a public API leads its CHANGELOG.md entry with a
### ⚠️ Breaking changes section (exact heading), each item carrying a
before → after snippet. A version with no such section is purely
additive — adopting it changes nothing in your code. (See
AGENTS.md → Breaking changes.) So the list is recoverable
mechanically, and the package recovers it for you:
bunx stitchkit@latest upgrade # or: npx stitchkit@latest upgrade
Run it inside the project that depends on stitchkit. It reads your installed
version out of node_modules, reads the changelog shipped inside the
package, and prints every breaking section your range crosses, oldest first,
each with its Who must act line — the list this guide’s migrations expand
on — and then the fixes of that range that name what they touch. Nothing to clone, nothing to be told: --from / --to override either end
of the range if you want a different one.
So upgrading is: read the ### ⚠️ Breaking changes of every version above your
current one up to your target, and apply each snippet.
Does an item touch your project?
Every breaking item since 0.104.0 carries one machine line, **Affects:**, naming what it changes
in a form that is read without guessing from prose:
**Affects:** `stitchkit/files/packaging` createNativePackaging(delivery: 'embedded')
**Affects:** `stitchkit/contract`, `stitchkit/server` AppError; `stitchkit` ApiError
**Affects:** `stitchkit/process` behaviour
**Affects:** `stitchkit/agent-runtime/harness-tools` *
Targets are separated by ; . Each starts with one or more backticked entrypoints, then lists the
exports it changes (an optional parenthesised qualifier names the option or member), or says
behaviour for a change of semantics no import name reveals, or * for every import of a removed
entrypoint. stitchkit upgrade reads your project’s imports (--cwd, default .; TypeScript, JavaScript,
Vue and Svelte files; not node_modules, and not a dist, build, out or coverage folder
beside a package.json, which is build output) and gives each item one verdict:
- affects this project — with every
path:linethat imports a named export, or a namespace; - not used here — nothing imported matches;
- behavioural — check by hand — with the files that import the entrypoint;
- not declared — check by hand — an item from before the line existed, with the files that import the entrypoints it leads with.
A fix that names what it touches gets the same verdicts. Since 0.110.7 every ### Fixed item that
leads with an entrypoint carries the **Affects:** line, and upgrade prints them in a second
section, Fixes that touch this project (fixes in --json): a fix to runNativeCommand is
listed with the path:line that imports it, or as not used here. Fixes of earlier releases that
carry no line are not judged; one line counts them (fixesWithoutAffects in --json).
--json prints the same verdicts per item for a CI step. The scan reads static import and
export … from declarations, bare imports and import() / require() of a literal specifier; a
specifier built at run time is invisible to it, and so is a file of another kind (.astro,
.mdx). A directory or file the process may not read is
listed under Not scanned (unscanned in --json) instead of ending the plan: the verdicts do
not cover it.
Released migration: 0.88.0
Two mechanical edits, and only if your project builds these values rather than only reading them.
A completed step event now carries the provider’s response identity, and the field is required:
// before
{ type: 'step-finished', step: 0, usage }
// after
{ type: 'step-finished', step: 0, usage, response: { id: generationId } }
Every event the runtime emits already has it, so a sink that only reads them needs no change. A fixture, a test double or a pipeline that re-validates events persisted by an earlier version does: give the older records a response identity, or keep parsing them with the schema they were written under.
If you implement the whole AgentRuntime interface — usually a mock — add the new method:
// before
const fake: AgentRuntime = { submit, interrupt, recover, close }
// after
const fake: AgentRuntime = { submit, interrupt, abandon, recover, close }
No data migration is involved, and nothing in the store changes shape.
Released migration: 0.87.0
Only if your project implements AgentConversationReader itself. A message page
now names which of its messages compaction removed:
// before
return { items, ...(nextCursor && { nextCursor }) }
// after
return { items, compacted: [], ...(nextCursor && { nextCursor }) }
An empty array is the honest answer for a reader that pages only the active
history; it is what the SQLite reader returns unless the caller asks for
includeCompacted. Calling the reader needs no change, and no data migration is
involved.
Released migration: 0.86.0
Only if you branch on AgentTerminalReason or show it to a person. Three
failures that used to arrive as provider_failure now arrive as themselves:
// before
if (run.terminalReason === 'provider_failure') showOutage()
// after
switch (run.terminalReason) {
case 'provider_failure':
showOutage()
break
case 'storage_conflict':
// Another writer took this run's revision, or the store refused the write.
retryOrReport()
break
case 'output_rejected':
// Your own `protocol.acceptTerminal` refused the finished message.
reportRejectedAnswer()
break
case 'runtime_failure':
// The runtime, or a check the SDK ran on what it was handed, failed
// before the provider was reached.
reportInternal()
break
}
A run that never reached the provider can no longer report provider_failure,
so an alert keyed on that value stops firing for causes that were never the
provider’s.
If you implement AgentRuntimeStore directly, add the event-ledger surface:
const store: AgentRuntimeStore = {
// existing normalized operations
appendEvent,
readEvents,
exportConversation,
importConversation,
}
If you implement an AgentRuntimeStoreDriver (Prisma, Postgres, any external
database), the ledger is yours to keep: events is a required member, and it
needs a table.
// before
const driver: AgentRuntimeStoreDriver<Tx> = { transaction, head, runs, admissions, history, scanRecoverable }
// after
const driver: AgentRuntimeStoreDriver<Tx> = {
transaction, head, runs, admissions, history, scanRecoverable,
events: {
// One row per event; `seq` is per conversation, starts at 1, and is
// assigned inside the caller's transaction so concurrent appends cannot
// share or skip a number.
append: async (tx, draft) => {
// Two transactions that both read max(seq) and both insert max + 1
// collide on the primary key; under SERIALIZABLE the snapshot predates
// any lock you take here. Treat a unique violation on this table as a
// serialization conflict and re-run the whole transaction — see
// `runTransaction` in examples/agent-store-prisma/adapter.ts.
const seq = (await tx.event.aggregate({ _max: { seq: true }, where: { conversationId: draft.conversationId } }))._max.seq ?? 0
await tx.event.create({ data: { ...draft, seq: seq + 1, payload: JSON.stringify(draft.payload) } })
return { ...draft, seq: seq + 1 }
},
// Ordered by `seq`, bounded by `fromSeq`/`toSeq`/`limit`; `nextSeq` when
// more remain.
list: async (tx, input) => { /* see examples/agent-store-prisma/adapter.ts */ },
},
}
The table carries eventId (unique), conversationId, seq, schemaVersion,
kind, occurredAt, ignorable and payload. runAgentStoreConformance
from stitchkit/testing now exercises it — contiguous seq under twenty
concurrent appends, bounded reads, the ignorable flag, a byte-stable export
— so run it against your driver before deploying. Expect one more insert per
run operation (runtime/transition) and one per model step
(provider/request) on the hot path; there is no opt-out.
If you operate a built-in SQLite store file:
-
Back the file up. The first open by 0.86.0 migrates it to schema 2 in one transaction and there is no way back: a 0.85.x package refuses a migrated file with
Unsupported Stitchkit agent-runtime SQLite schema version 2. -
Check the build for FTS5 before deploying:
SELECT sqlite_compileoption_used('ENABLE_FTS5');A build without it is refused at open with
requires FTS5 support. On the machine this was verified, Bun 1.3bun:sqliteand Node 24.18node:sqliteboth carry it; verify your own build rather than assuming — the table of supported runtimes says Node ≥ 22.5, and FTS5 there is not verified here. -
Know what the migration wrote. Each existing conversation becomes one
runtime/baselineevent atseq 1, dated when the migration ran, withasOfinside the payload naming the last message it describes. Event search and projections address that conversation’s pre-migration history as one snapshot atseq 1; event-level precision begins with the first event after the migration.
If you subscribe to AgentRuntimeEvent and switch over event.type
exhaustively, add the transient attempt-reset case — drop the run’s
transient text — or the reducer stops compiling; a reducer that ignores it
keeps a failed attempt’s partial text on screen.
New in this release, not a migration: child runs cascade only when the manager
is given to the runtime —
createAgentRuntime({ ...config, children: createSqliteAgentChildManager({ sqlite, spawn }) })
— and the SQLite companions take the store handle ({ sqlite }), whose
database and transaction they share.
Released migration: 0.85.0
Only if you implement AgentRuntimeStore directly. Add the new durable
operation mutation beside the existing run mutations:
// before
const store: AgentRuntimeStore = {
checkpointRunAssistant,
commitRunTerminal,
// ...
}
// after
const store: AgentRuntimeStore = {
checkpointRunAssistant,
recordRunOperation,
commitRunTerminal,
// ...
}
It must atomically replace AgentRun.lastOperation, increment the run revision
and conversation snapshot version, and enforce the supplied owner, fencing
token and expected revision. Adapters built with createAgentRuntimeStore
already receive the reference implementation; no driver method is added.
What your range does, and does not, do for you
A caret range ("stitchkit": "^0.71.0") is a real gate: it resolves < 0.72.0,
so a plain install picks up every patch — fixes, new API, no code changes —
and never crosses a minor. Crossing one is always something you chose.
An exact pin ("stitchkit": "0.71.0") is a different arrangement, and it is
easy to mistake for a safer version of the same one. It does not opt you out
of breaking changes; it opts you out of every change, patches included. No
install will move you, nothing will warn you, and the gap grows quietly — a
project pinned exactly for a few months is typically several minors behind and
has had no signal at all that this is so. That is a legitimate choice, but it
makes one thing your job rather than the resolver’s:
- Check deliberately.
npm view stitchkit versionagainst your pin, on whatever cadence suits the project. Nothing else will raise it. - Then upgrade across the whole gap at once, exactly as below: apply the
### ⚠️ Breaking changesof every version above your pin up to your target, in order. A pin held across four minors is four migrations, not one, and three of them may be a single snippet each.
The mechanical part is identical either way. Only the noticing differs, and an exact pin moves it onto you.
Released migration: 0.84.0
Only if you call createAgentCodingTools.
rg -n "createAgentCodingTools"
1. Backslashes are refused. If anything in your system hands the tools a Windows-style path,
it now gets FORBIDDEN. Convert to / at the boundary:
// before: read_file({ path: 'src\\index.ts' }) // reached openat as two segments
// after: read_file({ path: 'src/index.ts' })
This is the half that matters most: until now a backslash bypassed your required
authorize({ operation, path }) callback, not just authorizePath. A rule denying
credentials/token.txt refused that spelling and served — and overwrote — the same file spelled
credentials\token.txt. If your policy denies specific files, re-read your audit logs for
backslash spellings.
2. Denying a directory now denies everything under it — and a policy must admit every directory
on the way to a file, the way POSIX needs +x on each directory in a path.
If your policy is a DENY-list, it now does what it read as:
authorizePath: ({ path }) => path !== 'credentials'
// before: hid `credentials` from glob, served `credentials/token.txt` to read_file
// after: refuses `credentials/token.txt`, `credentials/nested/deep.txt` and writes under it
If you relied on the old behaviour — a directory hidden from discovery but readable directly — you were relying on a defect, and the two halves of one callback disagreeing. Name the paths you actually want reachable instead.
If your policy is an ALLOW-list, widen it to the directories it leads through, or nothing under them is reachable:
// before: allowed src/index.ts, because only the leaf was ever asked
authorizePath: ({ path }) => path.startsWith('src/')
// after: `src` itself is asked first and refused, so the file is refused too
authorizePath: ({ path }) => path === 'src' || path.startsWith('src/')
The workspace root is not asked about — it is the boundary the tools already own.
3. The policy is called more often. A path of depth N costs N+1 calls (., then each ancestor,
then the leaf), outermost first, stopping at the first refusal. A policy that logs or meters will
see a different sequence, and will no longer see the leaf once an ancestor has refused.
Released migration: 0.83.0
Two things, both mechanical, and only if you touch an observability sink.
rg -n "createObservability|createApplicationEventSink|createApplicationSnapshotSink|createAgentRuntimeEventSink|createAgentObservability"
1. flush() returns Promise<boolean>. await sink.flush() needs no
change. Only a declared type does:
// before
const flushAudit: () => Promise<void> = observability.flush
// after
const flushAudit: () => Promise<boolean> = observability.flush
2. ObservabilityDrainReport carries drained. The framework builds it, so
reading code is unchanged. A hand-built report — a test double, or one persisted
and re-parsed through ObservabilityDrainReportSchema — needs the field:
// before
{ request, tools, total, durationMs }
// after
{ request, tools, total, durationMs, drained: true }
Then take the thing this release exists for. Every drain now accepts
{ timeoutMs?, signal? }, so a shutdown can give the audit drain the same
deadline it gives every other step:
const report = await observability.close({ timeoutMs: 5_000 })
if (!report.drained) {
logger.warn('audit drain incomplete', {
unwritten: report.total.received - report.total.filtered - report.total.completed,
})
}
The bound ends the waiting, not the writes: a sink’s write is handed no
cancellation, so an outstanding one keeps running against whatever you close
next. Racing close() against your own timer looks equivalent and is not — it
discards the report along with the wait.
Released migration: 0.82.0
One thing, and only if you mount MCP or agent tools from a createTrackingContract contract.
rg -n "createTrackingContract"
The event ingest (track, POST /events) is now expose: ['HTTP'], which is
what its sibling bootstrap always declared. Before, it declared nothing — and
an endpoint with no expose is a tool on MCP and AGENT by default, so the
mounted surface carried a track_<prefix> tool. Since this contract is built
inside the framework, an application that had made every tool opt-in for the
endpoints it authors could not have opted this one out.
If you pin a tool-surface digest or count, it moves by one; re-record it.
If you assert on tool names, track_<prefix> is gone. If nothing in your
project mounts tools from this contract, there is nothing to do — the HTTP
routes, the client and the schemas are unchanged.
Un-declaring the tool was never the intent, so there is no option to keep it. If an agent in your system genuinely needs to record something, give it your own endpoint with your own schema: the browser ingest trusts a client-minted event id and a visit lease, and an agent holds neither.
Released migration: 0.81.0
Path literals own string params.
An endpoint whose path contains :id or a terminal *rest no longer needs a
duplicate params: z.object({ …string() }) declaration. Remove schemas that do
nothing beyond repeating path names:
// before
{ path: '/projects/:projectId/*filePath',
params: z.object({ projectId: z.string(), filePath: z.string() }) }
// after
{ path: '/projects/:projectId/*filePath' }
Keep an explicit schema when it validates or coerces. It must cover every name
in the path — an explicit schema that misses one now fails at defineContract
with the path and the missing field named. Client calls and handler contexts
infer the string fields from the literal, so a call that omitted one becomes a
compile error instead of building a URL with an unresolved segment. Measured
across three consuming applications before release (373 endpoints with path
segments), no explicit schema missed a field: the migration is deleting
schemas that only repeated the path, or nothing.
Released migration: 0.80.0
One thing, and only if you parse an async-operation snapshot.
rg -n "createAsyncOperationSnapshotSchema"
progress on a parsed snapshot used to be unknown. The factory built its
object shapes with a conditional spread, and the narrowing inside it widened
your schema back to a bare ZodType, so the emitted declaration threw the type
argument away. It is now the output of the schema you configured.
const schema = createAsyncOperationSnapshotSchema({
progress: z.object({ done: z.number(), total: z.number() }),
failure: z.object({ code: z.string() }),
})
const snap = schema.parse(body)
if (snap.phase === 'running') {
// before: `snap.progress` was `unknown` — this needed a cast or a narrow
// after: it is `{ done: number; total: number } | undefined`
console.log(snap.progress?.done)
}
If you cast it, delete the cast — it was working around the erased type. If you assigned something else to it, the compiler now refuses, and it was already a value the schema rejected at run time.
If you pass explicit type arguments, the no-progress form takes one now, not two: the factory is two overloads. Pass none.
// before: createAsyncOperationSnapshotSchema<undefined, typeof Failure>({ failure: Failure })
// after: createAsyncOperationSnapshotSchema({ failure: Failure })
Nothing about parsing moves. A snapshot configured without progress still
strips an unexpected progress rather than refusing it — that behaviour is why
this is overloads and not one normalised key.
If you compile with skipLibCheck: false, this is also the release that
makes stitchkit/tools/contract compile at all: it carried five TS2344
errors, one per phase, from the same conditional spread.
Released migration: 0.79.0
One thing, and only if you assert on the whole snapshot.
rg -n "getSnapshot\(\)|projectApplicationStatus|status\(\)" --glob '*.test.ts' --glob '*.spec.ts'
ApplicationSnapshot and ApplicationStatusProjection each gained a
restarting field — ids in the snapshot, a count in the projection. A reader
that names the fields it wants is unaffected. A test that compares the whole
object with toEqual fails until it adds the field:
// before
expect(body).toEqual({ id, lifecycle: 'ready', /* … */ resources: { total: 1, ready: 1, degraded: 0, failed: 0 } })
// after
expect(body).toEqual({ id, lifecycle: 'ready', /* … */ resources: { total: 1, ready: 1, degraded: 0, failed: 0 }, restarting: 0 })
It is zero except while a restart is running, and then it names the subtree being replaced. That is the point: a snapshot taken mid-restart used to be indistinguishable from a resource that had failed on its own, so a dashboard read a planned replacement as an outage.
If you watch a hub across a restart, nothing to change, but the behaviour is
different and better: closing a hub now tells its subscribers unavailable /
source-unavailable instead of dropping them, so a page stops showing a value
that will never update again.
If you need a tool contract in a browser, stitchkit/tools/contract now
carries the async-operation helpers, its schemas and the view-file input/output
without the runtime. stitchkit/tools still exports all of them.
Released migration: 0.78.0
Two mechanical edits. Find them both:
rg -n "createDecisionPipeline|createDiagnosticJournal" --glob '*.ts' --glob '*.tsx'
1. createDecisionPipeline takes a deadline.
// before
createDecisionPipeline([policyA, policyB])
// after
createDecisionPipeline([policyA, policyB], { policyTimeoutMs: 2_000 })
Pick a number your slowest policy comfortably beats. There is no default on purpose: a policy that never settles hangs every caller of the operation it guards, and the framework has no basis for choosing that number for you.
Past the deadline — and on a throw, and on a non-decision — the pipeline raises
DecisionPolicyError naming the policy, with the trace so far. If you already
catch that for a bad return value, you now catch two more cases with it.
2. createDiagnosticJournal moved.
// before
import { createDiagnosticJournal } from 'stitchkit/application'
// after
import { createDiagnosticJournal } from 'stitchkit/application/diagnostic-journal'
Only the factory moved. DiagnosticJournalConfig, every
DiagnosticJournal*Schema and readDiagnosticJournalLockDiagnosis stay where
they were.
If you restart resources, nothing to change — but three things that did not
work now do: a managed schedule can be restarted, a restarted keyspace accepts
writes again, and a restarted managed server is actually shut down at exit. If
you pass managedServerResource a server instance, a restart is now refused
by name; pass a factory (server: (context) => …) to restart it.
Released migration: 0.77.0
Two type renames, and only if you named them. Nothing runtime moved.
rg -n "EventDecision|EventUndecided" --glob '*.ts' --glob '*.tsx'
// before
import type { EventDecision, EventUndecided } from 'stitchkit/live'
// after
import type { PolicyDecision, UndecidedOutcome } from 'stitchkit/live'
The shapes are identical — { outcome: 'allow' } | { outcome: 'deny', reason } | { outcome: 'defer' }
and 'allow' | 'deny'. If you only ever return decisions from listeners and never named the
type, the search above finds nothing and there is nothing to do.
They were renamed because createDecisionPipeline (new in this release) votes with exactly the
same three outcomes, and two identical types under two names is the thing that makes a search for
either one return half the truth.
Released migration: 0.76.0
One change, and only if you hand createWatchClient a transport you wrote yourself.
rg -n "createWatchClient" --glob '*.ts'
If the transport: you pass is a bound realtime client — bindRealtimeClient(...) or
createRealtimeClient(...) — there is nothing to do: it already carries
onConnectionChange. If it is an object you assembled, add the fourth member:
onConnectionChange(listener: (connected: boolean, reason?: string) => void): () => void
It is required because the client recovers through it: on a drop it publishes
unavailable to subscribers and forgets what was opened, and on a fresh
connection it re-opens every key that still has a listener. Without it a watch
stays “open” on the client after a server restart while the hub remembers
nothing, and the face stops updating without saying so.
Released migration: 0.75.0
Two mechanical renames and one option that has to move. Nothing about a passing request changes.
1. A route group’s onRequest
rg -n "hooks:\s*\{[^}]*onRequest" --glob '*.ts'
Any match inside a groups: [...] entry must move. The hook was accepted and
never dispatched, so the behaviour you have today is “no hook”; the fix is to
decide which one you meant:
// refuse before dispatch, for the whole server
createServer({ groups, hooks: { onRequest: gate } });
// gate this group once the endpoint is known
groups: [{ pathPrefix: '/admin', services, hooks: { authorize: gate } }]
A group that still declares onRequest fails at startup with that message.
2. The SQLite boundary type
rg -l "AgentRuntimeSqlite(Database|Statement|Value)" | xargs sed -i \
-e 's/AgentRuntimeSqliteDatabase/SqliteDatabase/g' \
-e 's/AgentRuntimeSqliteStatement/SqliteStatement/g' \
-e 's/AgentRuntimeSqliteValue/SqliteValue/g'
Type-only: the same three methods, the same structural compatibility with
bun:sqlite and a node:sqlite wrapper, imported from the same entrypoints.
initializeAgentRuntimeSqlite keeps its name — it really is the agent runtime’s
schema.
3. Nothing else
stitchkit/live, the watch hub, the keyspace and the trust fence are all
additive. Adopt them when you want them; the guide is
Live data.
Released migration: 0.74.0
One change, and it only reaches you if you catch a refusal the client raised — one it made while planning the request, before anything was sent. A server refusal is unchanged.
If you match on the text of a client-side refusal
A missing path param, a missing scoped prefix key, a non-flat field in a GET input, a missing or
invalid multipart file: each used to arrive as a plain Error carrying only a sentence. They now
arrive as an ApiError in the same shape a server validation failure uses.
// before
catch (e) {
if (/Missing path param/.test(e.message)) …
}
// after
catch (e) {
if (e.code === 'VALIDATION_ERROR' && e.status === 0) … // refused here, nothing was sent
}
// e.details.issues[0].path === 'id'
status is what separates the two worlds, and it is not new — 0 has always meant this never
reached the server, as REQUEST_ABORTED and REQUEST_TIMEOUT already used it:
code |
status |
|
|---|---|---|
| the client refused your arguments | VALIDATION_ERROR |
0 |
| the server refused your arguments | VALIDATION_ERROR |
400 |
details.issues is the same { path, code, message } array on both, so one rendering serves both —
and zodIssues / ZodIssueSummary are now importable from stitchkit itself, not only from
stitchkit/server.
If you are on createHttpClient and your call site relied on a synchronous throw
This is the sharp one, because it is invisible in a diff. Those refusals used to be thrown
synchronously on the Ky-backed client — before the call returned a promise — so
api.upload({}).catch(handler) never reached the handler, while the same mistake on the bare-fetch
client rejected normally. They now reject on both.
// before, on createHttpClient only
try {
api.upload({}) // threw here
} catch (e) { … }
// after, on both transports
await api.upload({}).catch(handler)
A try/catch around an awaited call keeps working. A try/catch around an un-awaited call no
longer catches anything — which was already true on the other transport.
Not a migration, but worth knowing: a missing multipart file used to be reported as
UNKNOWN_ERROR, the code that means this client cannot tell you what happened, on the one refusal
where it is certain nothing was sent. → ADR 0148 carries the reasoning, including why argument
validation stays on the server.
Released migration: 0.73.0
Two breaking changes. Nothing you send to a server changed, and only one of these can reach a call site the compiler already checks.
If you call withOptions from a call site that lost its types
withOptions takes one argument on an endpoint with no contract input, two when
there is one. Passing two to a no-input endpoint used to succeed and silently
drop the options: the request went out uncancelled, the caller still received
REQUEST_ABORTED — cancellation is decided from the signal it was handed, not
from what the transport did — and the server ran the operation to its own
deadline. Every symptom then points at the transport, and the investigation goes
there. It now throws a TypeError naming the endpoint and the correct shape.
// before — the options were dropped and the request was never cancelled
api.ping.withOptions({}, { signal })
// after
api.ping.withOptions({ signal })
A typed call site already refused the wrong arity, so this reaches only code that
does not have the types: a Record<string, …> view of the client, a hand-written
double, a dynamic dispatch. The guard counts arguments and never reads them, so a
method handed to a callback API behaves exactly as before.
If you match on ApiError.message
An ApiError that nothing explained used to carry API Error: ${code} — a
string that reads like an explanation while only restating the code, so a caller
could not tell an origin that explained a failure from one that said nothing. It
now says which it is.
// before
err.message === 'API Error: INVALID_INPUT'
// after
err.message === 'INVALID_INPUT (no message supplied)' // better: match err.code
Match err.code instead: it is the contract, and it did not change. An error
carrying a real message is untouched. An empty message now counts as no message,
for the same reason — an empty string is not an explanation either.
If a diagnostic journal lock file is already on disk
One operator step, and only on a host that has been renamed since its lock was written.
A lock written before this version carries no machine identity, so there is nothing to compare and
the host name is all that is left. If the name still matches, the lock reclaims exactly as it always
did and there is nothing to do. If the name has changed, the lock is unattributable and is refused
— the same refusal this release exists to end, except that this particular file predates the fix and
cannot be repaired from the inside. Delete it once:
rm <journal path>.lock # only when the host was renamed since the file was written
Locks written from this version on carry the machine identity, so the situation cannot recur. To see
which case you are in without guessing, read the refusal instead of the file:
readDiagnosticJournalLockDiagnosis(err) returns attribution: 'unattributable' for exactly this
one, and 'another-machine' for a lock that genuinely belongs elsewhere and must not be deleted.
Two related fixes need no migration, but change what you will see. An error the
transport raised through createHttpClient now carries the transport’s own text
in message and the original error as cause, where before it carried neither
and filed the text under details.message alone; if you followed the retry rule
in the client guide — inspect the adapter’s cause, retry only
when it proves dispatch did not happen — that rule was not executable on this
transport and now is. And the client guide now states which direction validation
runs: responses are checked against output, arguments are not checked
before being sent.
Released migration: 0.72.0
Nothing you pass changed. Both items are about types you read or build, and both stop the compiler rather than surprising you at runtime.
If you read perKey off BoundedAdmissionPolicy
perKey is now a union: the flat ceiling it always was, or { maxKeys, limits }
where the ceiling is resolved from the key. Declaring one is unchanged; reading
one needs a narrowing.
// before
const ceiling = policy.perKey?.maxConcurrent
// after
const ceiling =
policy.perKey && !('limits' in policy.perKey) ? policy.perKey.maxConcurrent : undefined
A shape carrying members of both branches — { maxKeys, maxConcurrent, limits } —
is refused. In 0.72.0 and 0.72.1 that refusal came only from
createBoundedAdmission, not from tsc: an excess-property check against a
union admits any property some member declares, so the mixed shape typechecked
and threw when the admission was built. From 0.72.2 each branch declares the
other’s members as never, so it is a compile error. Nothing about the runtime
changed — both branches always refused it — but on a path your tests do not
construct, the earlier versions would have told you in production.
If you build a snapshot or a status by hand
CreditWindowSnapshot gained waiting (producers parked in the new waiting
acquire) and DiagnosticJournalStatus gained lock. Both are produced by the
framework, so reading them is unaffected — but a test double stops compiling:
// before
const snapshot: CreditWindowSnapshot = { state: 'open', capacityBytes: 100, /* … */ }
// after
const snapshot: CreditWindowSnapshot = { state: 'open', capacityBytes: 100, /* … */ waiting: 0 }
Released migration: 0.71.0
The Agent coding tools. Two of the three changes are visible to the compiler; the third is the one to read carefully, because nothing will point at it.
If you implement authorize
The operation union gained edit, list and glob and lost patch. An
exhaustive matcher stops compiling and the compiler shows you every arm. A
matcher with a default branch does not, and that is the dangerous case:
// before — and after this upgrade, silently wrong in both directions
if (request.operation === 'patch') return reviewPatch(request)
return true // now also authorizes edit, list and glob
// …or: return false // now also kills edit_file
// after
switch (request.operation) {
case 'edit': return reviewEdit(request) // the old `patch` payload, unchanged
case 'list':
case 'glob': return true // or your own policy
// …existing read / write / search / shell / artifact-read arms
}
write gained createsDirectories — the workspace-relative directories the
call would create, outermost first, reported before anything is created. A
host that wants to refuse implicit directory creation now can.
If you call apply_patch
It is edit_file, and it is one call:
// before
const read = await readFile({ path })
await applyPatch({ path, baseSha256: read.sha256, oldText, newText, dryRun: true })
await applyPatch({ path, baseSha256: read.sha256, oldText, newText, dryRun: false })
// after
await editFile({ path, oldText, newText })
expectedSha256 is optional and still refuses a stale base with CONFLICT when
you pass it; edit_file returns the resulting sha256, so a chain of edits
never needs to re-read. If you key an approval policy or a UI label on the string
apply_patch, update the key — nothing will fail loudly.
If you match on INTERNAL_SERVER_ERROR from a coding tool
Ordinary outcomes no longer arrive that way. A missing file is NOT_FOUND, an
existing file without overwrite is CONFLICT, an ambiguous snippet is
CONFLICT with the occurrence count, a path outside the root is FORBIDDEN.
Code that treated any coding-tool failure as an internal fault will now see
codes it did not before; code that showed the model an empty error now has a
sentence and a hint to show it. Host-level causes are unchanged and still
scrubbed.
If you write files into new directories
Nothing to change: write_file creates missing parents inside the root. Read
createdDirectories in the result if you want to notice a typo — a path that
was a failure before is now a successful write into a new tree.
If you want a step to know its context budget
Opt in by reading it; nothing is injected for you:
loop: {
prepareStep: ({ contextUsage }) => {
const used = contextUsage?.usedTokens
if (used?.provenance === 'unavailable') return {} // no step has landed yet
const fraction = (used?.value ?? 0) / (contextUsage?.contextWindow ?? 1)
// …render it wherever you put it
return {}
},
}
Put it at the tail of the conversation rather than in the system instructions unless you have a reason: changing the system prompt on every step invalidates the provider’s prefix cache for the whole conversation, and on a long run that is a multiple of the input cost.
Released migration: 0.70.0
Descriptor-backed Agent filesystem containment
Built-in coding file/search tools and createAgentHarnessFileResources now require a runtime
that can address an opened directory descriptor: Linux /proc/self/fd, or the packaged macOS
Node-API backend added in 0.70.1. This is what keeps a mutable parent rename or outside-symlink replacement from changing
the authorized target between validation and the actual effect.
No call-site change is needed on those platforms. The 0.70.0 notes incorrectly inferred macOS and
FreeBSD support from /dev/fd; macOS is restored by 0.70.1 and FreeBSD remains unsupported. On
Windows or another platform without that
boundary, move the built-in filesystem operations to a supported worker or replace them with
application-owned tools backed by an equivalent native handle API. They fail closed rather than
falling back to path spelling. run_command remains separately available under its explicit
executable alias and authorization policy; this change does not claim an executable sandbox.
Released migration: 0.69.0
Direct coding-tool operation names
createAgentCodingTools no longer prefixes durable operation identity with coding_, and the
unguarded exact-string edit tool is gone. Update approval maps, presenters and tests together:
// before
{ coding_read_file: 'approved', coding_search: 'approved', coding_patch_file: 'user-approval' }
// after
{ read_file: 'approved', search_files: 'approved', apply_patch: 'user-approval' }
The full mapping is coding_read_file → read_file, coding_write_file → write_file,
coding_search → search_files, coding_patch_file → apply_patch, coding_shell → run_command,
coding_read_artifact → read_output and harness_read_resource → read_resource. Replace
coding_edit_file with read_file followed by guarded apply_patch; read_file now returns the
required SHA-256. If the host declares no executable aliases, expect run_command to be absent.
Durable Agent approval message variants
If a renderer, store adapter or export pipeline exhaustively switches over
AgentMessage.role, add the tool branch. If it switches over
AgentMessagePart.type, add tool-approval-request and tool-approval-response.
They are durable provider-continuation evidence: preserve them in storage and provider history;
a UI may render them as approval state or intentionally omit their visual row.
// before
const unreachable: never = message.role
// after
if (message.role === 'tool') renderToolContinuation(message.parts)
else renderExistingRole(message)
No data migration is needed. The schema still accepts all earlier records, and applications that do not exhaustively branch over these unions compile unchanged.
Before you bump, if you implement an agent store
One step, and it is mechanical. If your project has an AgentRuntimeStore — a
Prisma adapter, an in-memory one, anything — run the conformance kit against it
on the version you are leaving, then again after the bump:
import { runAgentStoreConformance } from 'stitchkit/testing'
await runAgentStoreConformance({ createStore: () => yourStore })
The kit picks the conversation identities itself and hands them over before the first mutation, so a store whose runtime rows hang off an application-owned conversation row can provision the parents — and take them away again:
await runAgentStoreConformance({
createStore: (context) => {
for (const conversationId of context.conversationIds) createConversationRow(conversationId)
return yourStore
},
cleanup: (context) => {
for (const conversationId of context.conversationIds) deleteConversationRow(conversationId)
},
})
cleanup runs exactly once, after the scenario, whether it passed or failed —
and a failure in it never replaces the scenario’s own.
Green before and red after tells you the contract grew and where, in one run,
instead of one failure at a time in production. Green both times means the
upgrade owes you nothing on that surface — which is the usual answer if you
implement AgentRuntimeStoreDriver and compose the aggregate with
createAgentRuntimeStore(driver), the supported shape (→ ADR 0111).
Flow (agent or human)
-
Find the current version. In the consumer: the resolved
stitchkitinbun.lock(authoritative), ornode_modules/stitchkit/package.json. The range inpackage.json(^0.6.0) is intent, not the installed truth.A
file:link ("stitchkit": "file:…") means the consumer tracks a local checkout, not a published version — its effective version is whatever that checkout’spackage.jsonsays, and a plaininstallwill not relink it after the local version moves (bun install --forcedoes). Prefer a real^x.y.zrange for reproducibility. -
Pick the target. Latest published (
bun pm view stitchkit version) or a specificx.y.z. -
Read the breaking sections in range. In stitchkit’s
CHANGELOG.md, for every version> currentand<= target, read its### ⚠️ Breaking changes. Versions without that section are additive — skip them. (Fast scan:grep -n "Breaking changes" CHANGELOG.md.) -
Apply each migration — the before → after snippet tells you exactly what to change at each call site. There are no deprecation shims to lean on; the old shape is gone, so every site must move.
Then read this file’s
## Released migration: X.Y.Zsection for each of those versions. That is the half the changelog does not carry: what else stops working because of the change, and what changes silently rather than at compile time — projected history returning fewer messages, a dashboard field going blank.bun run checkin step 6 cannot see either. -
Bump and install.
bun add stitchkit@<target>(or update the range), thenbun install. Note the caret:^0.7.0is< 0.8.0, so crossing a breaking minor is always an explicit version bump, never automatic. -
Verify.
bun run check(or per-package typecheck) — TypeScript catches the removed/renamed/retyped surfaces. Then a runtime smoke (typecheck ≠ runtime): bootstrap the server, one HTTP request, and any feature you rely on (Socket.IO connect, an MCP tool call, a multipart upload, …).
Released migration: 0.68.0
Make Unix transport selection explicit outside Bun
The legacy unix option remains a Bun convenience, but on Node or another
runtime it now fails before dispatch. Replace it with the owned adapter and close
that adapter with the application:
// before — unsafe outside Bun: an unsupported fetch could dial baseUrl over TCP
const http = createHttpClient({ baseUrl, unix: '/run/service.sock' })
// after — Bun and Node; every dispatch and redirect stays on the socket
import { createUnixClientTransport } from 'stitchkit/server' // or stitchkit/node
const transport = createUnixClientTransport({ socketPath: '/run/service.sock' })
const http = createHttpClient({ baseUrl, fetch: transport.fetch })
// during shutdown
await transport.close()
Do not automatically replay possibly-dispatched: a timeout or connection loss
after bytes left the process does not prove that a write did not happen.
Choose tolerant stream parsing explicitly
parseNDJSON and parseSSE now throw on malformed JSON, invalid UTF-8 and an
over-limit line. The default line ceiling is 1 MiB. If a feed deliberately skips
bad records, retain that policy explicitly:
// before — malformed input disappeared implicitly
parseNDJSON(response)
// after — ordinary fail-closed path
parseNDJSON(response, { maxLineBytes: 256 * 1024 })
// after — deliberately tolerant path
parseNDJSON(response, { onParseError: (raw, error) => report(raw, error) })
Extend exhaustive framework-error handling
If a switch makes StitchErrorCode exhaustive, add
STREAM_ITEM_INVALID, STREAM_FRAME_TOO_LARGE,
STREAM_TERMINAL_MISSING and STREAM_LIFETIME_EXCEEDED. A partial application
status map needs no change (ADR 0105).
The admission/channel APIs and endpoint stream descriptor are additive; raw
responses and raw streamingRoute remain supported.
Released migration: 0.67.0
Three application-kernel changes. Two of them fix silent failures, so the most important part of this migration is not what the compiler points at — it is the two things that change with no compile error at all: when your server is created, and which shutdown budget a signal spends.
If you gave managedServerResource a thunk
It is now called during start, after the resource’s dependencies are ready —
the reading the type always suggested. Before, it was called on the way down,
so app.start() resolved, the snapshot said healthy and ready, and nothing
was bound to the port.
If your application looked like this, it was never listening:
// before — resolves clean, listens on nothing
managedServerResource({ id: 'http', dependsOn: ['database'], server: () => createServer(config) })
Nothing to change: the same code now binds the port. Check your startup logs for a healthy report you never actually verified with a request.
If you used the spread workaround — this resource’s phases over your own
start — it keeps working, and you can now delete it:
// before
let handle: ManagedServerHandle<T> | null = null
const shutdown = managedServerResource({ id: 'http', server: () => handle! })
const http = defineManagedResource({
...shutdown,
dependsOn: ['database', 'socket-io'],
start: () => { handle = createServer(config) },
})
// after
const http = managedServerResource({
id: 'http',
dependsOn: [database, socketIo],
server: () => createServer(config),
})
An already-created handle is adopted exactly as before.
If you passed shutdown to bindProcessSignals
bindProcessSignals used to fill in the schema’s defaults for every budget you
omitted, which made createApplication({ shutdown }) unreachable on the signal
path — the one path production stops through. An application declaring five
seconds took thirty-five.
Declare the budget once, on the application, and delete the repetition:
// before — the same two numbers in two places, and only one of them applied
const app = createApplication({ id: 'svc', resources, shutdown: SHUTDOWN_BUDGET })
bindProcessSignals(app, { shutdown: SHUTDOWN_BUDGET })
// after
const app = createApplication({ id: 'svc', resources, shutdown: SHUTDOWN_BUDGET })
bindProcessSignals(app)
Check this even if you change nothing. If you declared a budget on the
application and did not repeat it on the binding, your process has been stopping
on 30 s / 5 s and will now stop on what you declared. Compare it against the
supervisor timeout that watches it — kill_timeout, TimeoutStopSec — because
that number was probably calculated from the declaration.
Passing shutdown to the binding still works and still wins, key by key: one
key overrides that key alone and leaves the other at the declaration.
If you read resource.dependsOn
Its type widened from readonly string[] to readonly ManagedResourceDependency[]
— string | ManagedResource — so a dependency can be declared as the resource
itself. Declaring stays compatible; reading needs one call:
// before
const ids: readonly string[] = resource.dependsOn ?? []
// after
import { managedResourceDependencyId } from 'stitchkit/application'
const ids = (resource.dependsOn ?? []).map(managedResourceDependencyId)
If you thread a handle through a module-local
This is the pattern the change exists to remove, and it is opt-in — nothing breaks if you keep yours:
// before
let socket: SocketHandle | null = null
const socketIo = defineManagedResource({
id: 'socket-io',
start: async () => { socket = await createSocketServer(config) },
})
const http = defineManagedResource({
id: 'http',
dependsOn: ['socket-io'],
start: () => {
if (!socket) throw new Error('socket is not initialized') // unreachable
server = createServer({ socket })
},
})
// after
const socketIo = defineManagedResource({
id: 'socket-io',
start: async () => ({ value: await createSocketServer(config) }),
})
const http = defineManagedResource({
id: 'http',
dependsOn: [socketIo],
start: (context) => {
server = createServer({ socket: context.use(socketIo) }) // SocketHandle
},
})
Declare the dependency with the resource when you intend to read from it —
that is the form use can type. use refuses a resource missing from
dependsOn, and refuses one that published no value; the second refusal is a
compile error too.
Released migration: 0.66.0
Three changes, and only one of them is a feature. The other two are shapes that would have had to break later: a vocabulary that described one fact with two words, and a store whose only read was the whole conversation.
The compiler points at most of it. Two things change with no compile error — what a total says about its own provenance, and the fact that token counts are now validated where they were not.
An input that joins a run in flight
If you matched exhaustively on AgentTerminalReason
'absorbed' is new. A run ends this way when a run already in flight took its
input on and answered it; its state is 'superseded', and absorbedIntoRunId
names the run that has the answer.
// after
switch (run.terminalReason) {
// …
case 'absorbed':
// no assistant message of its own — follow run.absorbedIntoRunId
break
}
A run record with terminalReason: 'absorbed' and no absorbedIntoRunId is
refused at parse time, and so is absorbedIntoRunId on any other reason.
If you render or export runs
An absorbed run has no assistant message. Anything that assumes “every
terminal run has one” needs the absorbed case — the answer is on the run
absorbedIntoRunId names, and store.loadRun resolves it.
If you want the policy
// after
runs: { inputPolicy: 'inject' }
It was withdrawn in 0.65.0 and is back with the ordering corrected: the
absorption commits with the terminal record, not at the step boundary. Read
An input that joins a run in flight in the agent-runtime guide before turning
it on — in particular what happens when the absorbing run does not complete, and
how it composes with coalescePending.
If you implement a store driver
Nothing to do, but know what changed underneath: one terminal commit can now
save two run records, and they must land in the same transaction. A driver
that persists one of the pair fails runAgentStoreConformance.
A run read without its conversation
If you implement AgentRuntimeStore by hand
Two members to add. If your adapter is an AgentRuntimeStoreDriver passed to
createAgentRuntimeStore, there is nothing to do — the driver already had
everything both need.
// after
loadRun(input: { conversationId: string; runId: string }): Promise<AgentRunView | undefined>
listActiveRuns(conversationId: string): Promise<readonly AgentRun[]>
AgentRunView is { snapshotVersion, run, assistant? }. assistant is the
retained terminal answer, so it is present exactly when the run has ended and
absent while it is live — a store that returns a live run’s draft here lets the
terminal path resolve a run that has not finished. listActiveRuns orders by
createdAt then id, and must not report a run that has ended.
runAgentStoreConformance covers both, including the boundary cases.
If you have a store double in your tests
The runtime now reads loadRun where it used to read loadSnapshot. A double
that simulates a condition — a run drifting to another owner, a stale fencing
token — must apply it to both reads, or the code under test will not see it.
This is not hypothetical: it turned one of this repository’s own fixtures into
an infinite retry loop, which is how the bounded retry below was found.
Nothing else changes
loadSnapshot behaves exactly as before, and still returns the whole
conversation — as does every mutation result. What it costs, and what bounds it,
is now written down in Reading a conversation in the agent-runtime guide.
One provenance vocabulary, and integral tokens
If you match on 'measured'
A total now says computed, because it is a sum this code performed rather
than a count it took — the same rule AgentUsage has always applied to a run’s
spend. Two values change with no compile error:
// before
if (result.totalTokens.provenance === 'measured') { /* exact */ }
// after
if (result.totalTokens.provenance === 'computed') { /* exact, and derived */ }
It affects AgentHistoryBudgetResult.totalTokens and
ComposedAgentPrompt.instructionTokens. A per-message or per-section count is
still measured — only the totals moved. When any part was estimated the total
is still estimated: an estimate survives arithmetic, and that is the weaker
claim, so it wins.
If you produce token counts
They are validated now, in the places they were not. A fractional count throws where it used to flow into the context-window arithmetic:
// refused from this release on
estimateTokens: (text) => ({ value: text.length / 4, provenance: 'estimated' })
// after
estimateTokens: (text) => ({ value: Math.ceil(text.length / 4), provenance: 'estimated' })
The same applies to ComposeAgentPromptOptions.estimateFallback and
.historyTokens, to AgentPromptBudget.toolSchemas / .attachments /
.providerOverhead, and to AgentPromptBudget.contextWindow /
.reservedOutput, which must now be non-negative safe integers.
AgentUsageValue.value is z.int() too, which matters if you build usage
records by hand or in a store double. A figure arriving from a provider is
not thrown — normalizeSdkUsage and the OpenRouter adapter turn a non-integer
into { provenance: 'unavailable' }, so a run that already answered is not
failed over its own bookkeeping.
The new export
AgentProvenanceSchema / AgentProvenance is the whole vocabulary:
provider-reported, measured, computed, estimated, unavailable. Each
surface declares its subset, so nothing widened — AgentUsageValue still refuses
measured and AgentTokenCount still refuses provider-reported. Use it when
you want one switch over the question instead of two.
Released migration: 0.65.0
The largest migration of the pre-1.0 line, and most of it is the compiler pointing at things. One item is a feature withdrawal and two change behaviour with no compile error at all.
A projection a provider accepts, and a policy withdrawn
If you compact, or use system-note, you were broken and now are not
No code change to adopt the fix — but check your own code if you call
projectAgentHistory yourself. System and summary records no longer appear in
messages; they come back in system and belong in the provider’s instructions
channel, which is the only place ai accepts them:
// before — the provider refuses this outright
const messages = await projectAgentHistory(snapshot.messages)
streamText({ model, messages })
// after
const { messages, system } = await projectAgentHistoryDetailed(snapshot.messages)
streamText({
model,
instructions: system.map((content) => ({ role: 'system', content })),
messages,
})
If you use createAgentRuntime, this is handled for you.
inputPolicy: 'inject' is withdrawn
// before
runs: { inputPolicy: 'inject' }
// after
runs: { inputPolicy: 'queue' }
'absorbed', AgentRun.absorbedIntoRunId and AgentRuntimeStore.absorbQueuedRun
go with it. A driver built on AgentRuntimeStoreDriver needs no change.
Records must agree with themselves
If you construct AgentRun values — a store double, a fixture, a migration —
derive the state instead of setting it:
// after
import { runStateForTerminalReason } from 'stitchkit/agent-runtime'
AgentRunSchema.parse({ …run, terminalReason: reason, state: runStateForTerminalReason(reason) })
A terminal state with no reason, a queued run carrying one, and policy_stop
without a terminalPolicyName are now all refused at parse time.
Enum and field changes the compiler will point at
AgentTerminalReason:'tool_failure'removed (never produced),'context_overflow'added — this runtime’s own refusal when the prompt does not fit, which used to reportprovider_failure.AgentRunState:'absorbed'removed.AgentRunMetrics.usageis required.AgentHistoryBudgetDecision['reason']:'superseded'→'unspeakable'.
Two behaviour changes with no compile error
loop.idleTimeoutMsnow defaults to 60 000. A run whose provider stream goes quiet for a minute ends astimeoutinstead of holding the lane forever. Passnullfor the old behaviour, and think about why you want it.advanceAgentRuntimeEventCursorstops returninggapfor durable events. If you reload a conversation ongap, you were reloading after essentially every run. Transient events still report it, and there it is real.
Released migration: 0.64.0
Two changes, and only one of them can break a build. Nothing was removed.
An event that says which kind it is
Narrow AgentRunEvent on type
// before
for (const event of events) record(event.usage?.cost?.value ?? 0)
// after — and the compiler will point at every site
for (const event of events) {
if (event.type !== 'run-terminal') continue
record(event.usage.cost.value ?? null) // `usage` is present; unknown says so
}
step is now required on step-finished, terminalReason on run-terminal,
and usage on both. queueWaitMs exists only on run-started. Nothing was
removed — the fields that were optional because a different kind of event
lacked them are now simply on the kinds that have them.
Import AgentRunTerminalEventSchema (or the sibling schemas) if you construct
events in tests.
If you implement AgentRuntimeStore directly, move to the driver
Not urgent and nothing breaks today — but the aggregate is no longer the supported target, so its future growth will not be announced as breaking (→ ADR 0111). The supported shape is one line:
const driver: AgentRuntimeStoreDriver<TransactionClient> = { /* six primitives */ }
export const store = createAgentRuntimeStore(driver)
Run runAgentStoreConformance against your adapter before and after any bump —
see Before you bump, if you implement an agent store above.
Released migration: 0.63.0
Four changes to what a running system reports and how an input reaches a run
in flight. Nothing moves an export except one added store member, and only an
application implementing AgentRuntimeStore directly has to touch code.
A spend figure that survives, and an input that joins
AgentRuntimeStore has a ninth member
Only if you implement the aggregate interface directly — an adapter built on
AgentRuntimeStoreDriver needs no change, and the public conformance kit covers
the new operation.
// after
absorbQueuedRun(input: AbsorbQueuedRun): Promise<AgentStoreMutationResult>
It moves a queued successor’s inputs into the run already answering, in one
mutation, and marks the successor absorbed. Two run records change in one
transaction — if your driver persists them one at a time, persist both.
A provider failure says so
// before — an upstream error arrived as a policy stop with no policy
if (terminal.reason === 'policy_stop') retryLater()
// after
if (terminal.reason === 'provider_failure') retryLater()
if (terminal.reason === 'provider_stop') { /* a length cap or content filter */ }
policy_stop now always carries the policyName that caused it. If you switch
exhaustively on AgentTerminalReason or AgentRunState, add 'provider_stop'
and 'absorbed'.
partial changed meaning
It used to tell you which event kind you were holding. It now tells you whether the figure beside it is a confirmed total:
// after — true when the provider never reported the run finished
if (metrics.partial) treatAsFloor(metrics.usage)
Terminal events for runs that were superseded, interrupted, timed out or failed
before the provider finished now report partial: true where they reported
false.
Checkpoint metrics are a running total
// wrong, and now wrong by more than it used to be
const spent = checkpoints.reduce((total, c) => total + (c.metrics.usage?.cost?.value ?? 0), 0)
// right
const spent = checkpoints.at(-1)?.metrics.usage?.cost?.value
You can read a run’s spend back from the store
AgentRun.usage is written at every checkpoint and with the terminal record, so
a dropped observability event no longer loses the number, and a process that dies
mid-stream leaves behind what it had already spent.
Released migration: 0.62.0
Two groups of behaviour changes. Nothing moves an export — the surface is strictly additive — and every item changes what a running system reports or sends, which is what this heading is for. One of them changes a number you may already be billing against.
A run reports what it spent
Three changes to what the runtime says about cost and tokens. Nothing moves an export; all three change numbers a running system reports, and one of them changes a number you may already be billing against.
Multi-step cost was under-reported and is now summed
No code change is needed to get the fix — but check any predicate that reads
provenance:
// before — accepted a number that was one step's cost, not the run's
if (usage.cost?.provenance === 'provider-reported') bill(usage.cost.value)
// after — a sum stitchkit performed says so
if (usage.cost && usage.cost.provenance !== 'unavailable') bill(usage.cost.value)
'computed' means stitchkit added up provider-reported parts. It is not a guess
— 'estimated' is the word for that — but it is deliberately not
'provider-reported', because that label is what a caller filters on when it
wants a figure it can bill against unchanged, and a sum is not one.
Token totals moved with it. The AI SDK’s totalUsage is a sum it performed
over per-step provider figures — not a run total any provider handed over — so
labelling it provider-reported was the same overstatement. A run total on a
terminal event is always computed. If you want a figure with the provider’s
own word on it, read step-finished: each step carries what that call reported.
usage is always present on a terminal event
// before — absent when the run ended before the provider's `finish`
const spent = event.usage?.cost?.value ?? 0 // silently 0 for a real spend
// after — present, and it says what it does not know
event.usage?.cost?.provenance === 'unavailable' // we spent, and cannot say how much
Keep the optional chaining: one AgentRunEvent shape covers run-started,
step-finished and run-terminal, so usage stays optional on the type. The
guarantee is about terminal events, and a schema shared with run-started
cannot express it.
Do not read unavailable as zero. A run aborted mid-stream has spent real
money that nobody has counted; a run that never reached the provider has not.
Both used to look the same and now do not.
A losing executor reports its own spend
An execution that loses the terminal compare-and-swap now emits an operator
run-terminal event, because it ran and it spent. If a sink treated those events
as “runs this process committed”, that is no longer true — AgentRuntimeResult.metrics
is still undefined for a losing executor and remains the way to tell.
The delivery terminal event is unchanged and still fires only for the winner,
so nothing delivers a turn twice.
An interrupted answer stops passing as a finished one
Two behaviour changes, one shared cause: a run ended by a newer input used to leave its half-written answer in the conversation with no sign that it was cut off, and the next request to the provider carried it as an ordinary assistant turn.
The projection marks an interrupted turn
Nothing to change to adopt the fix — the default is the fixed behaviour. What to check is whether the marker is the right form for your surface, and the question that decides it is not “was the run interrupted” but “did anyone see what it produced”.
// after — pick the form; the default is 'assistant-marked'
createAgentRuntime({ history: { interruptedAssistant: 'system-note' } })
- The user pressed stop and the text was on their screen — keep
'assistant-marked'. The assistant turn is the truthful record of what the human read, and the model should stay consistent with it. - The partial never reached anyone — a surface that sends nothing until the
run is done — prefer
'system-note'. An assistant turn in provider history is a commitment the model stays consistent with; a system line is context.
If you pass history.project you own the projection outright and none of this
applies — but the same question does.
A run ended by a newer input can now say so
// before
runs: { inputPolicy: 'interrupt' } // ends the run, keeps its partial answer
// after — for a surface where a follow-up message invalidates the answer
// in flight rather than merely stopping it
runs: { inputPolicy: 'supersede' } // ends the run, discards its partial answer
inputPolicy also accepts (input) => policy, which is how one application
gives two conversation surfaces different rules without the runtime learning
which is which.
A superseded run terminates with terminalReason: 'superseded', state
'superseded' and an assistant message of status 'superseded'. If you switch
exhaustively on any of those enums, add the arm — that is the part of this
release that can break a build rather than a behaviour. The record itself is
kept: it is excluded from the projection, not deleted, so an operator can still
see what was thrown away.
Released migration: 0.61.0
Three behaviour changes between versions. None moves an export — the surface is strictly additive — and all three change what a running system does, which is what this heading is for.
A failed start() now drains before it rejects
The rollback of a failed startup used to close every resource with a zero budget: it returned almost at once, by severing requests the server had already accepted. It now spends the application’s shutdown budget, so a request already in flight is answered rather than killed.
What to check. Nothing, if a failed startup has nothing in flight — the
rollback still returns immediately. The case to think about is a request that
never finishes: a hung upstream, a client ignoring a close frame, a streaming
subscription. Under the default 30s grace and 5s force, a start() that used to
reject in milliseconds can now take 35 seconds to reject.
If a fast failure matters more than draining — a supervisor waiting to restart,
a boot check in CI — declare a smaller budget. The same field is the default for
shutdown() with no options, so this is one decision, not two:
// before
createApplication({ id: 'app', resources })
// after
createApplication({
id: 'app',
resources,
shutdown: { gracePeriodMs: 5_000, forceTimeoutMs: 1_000 },
})
The budget is a real bound: a close that never returns is abandoned when it
runs out and reported as a close failure, and the startup error remains the
cause of the AggregateError start() rejects with. → ADR 0107
A refused realtime frame now answers its sender
A frame that fails the receiver’s args schema used to be dropped where it
landed. If the event carries an acknowledgement, the receiver now answers it
with a reserved envelope and the sender’s request() rejects at once with
RealtimeRequestRejectedError. → ADR 0106
What to check before you upgrade one half of a distributed pair. Look at the
ack schemas on the OLDER peer:
// safe: a contract-first acknowledgement refuses the envelope, so the older
// peer raises RealtimeRequestInvalidAcknowledgementError at once instead of
// waiting out its deadline. Different error, still an error, and sooner.
ack: z.object({ stored: z.boolean() })
// NOT safe: a schema that validates nothing accepts the refusal AS A VALUE.
// The older peer reads a refusal as a successful acknowledgement — silently.
ack: z.unknown()
ack: z.looseObject({})
If any acknowledgement on the older side is permissive, tighten it before the rollout, or upgrade both halves together.
Also: the receiver now invokes the peer’s raw acknowledgement callback for a refused frame, including when the peer is a plain Socket.IO client. That callback previously could not run on a refused frame and now can.
RealtimeRejectedEvent['reason'] gained 'rejected-by-peer'. If you switch
over it exhaustively with an assertNever default, that stops compiling — add
the case.
Two smaller behaviour changes, easy to miss
streamSSE’s cancel no longer awaits the generator. Teardown is now
unordered relative to request completion. If your generator releases a resource
in return()/finally — a temp file, a pooled connection — and anything
downstream assumed that had finished by the time the response settled, it no
longer has. Release in the generator’s own finally and do not depend on the
ordering.
A socket.io-client peer that cannot load no longer kills the process. With
onConnectError configured, the failure is delivered there with
terminal: true instead of crashing. If your handler logs and moves on, you now
have a live process whose client will never connect, where a supervisor used to
restart it. Treat terminal: true as fatal if that is what you want.
reportHealth inside start is no longer discarded
Becoming ready assigned healthy unconditionally, throwing away whatever a
resource reported during start. It is now kept, and only a resource that
reported nothing is assumed healthy.
How to find what this touches: grep your start bodies for every
reportHealth call, not only the ones reporting degraded. The old
unconditional assignment was also a repair — a resource that reported
'unhealthy' early in start and never corrected itself was quietly fixed up
on the way to ready.
Two cases, and they need opposite fixes.
1. A resource that is genuinely expected to start degraded — up, but still
dialling something external. required defaults to true, and readiness
requires every required resource to be healthy, so such a resource now refuses
the whole startup where before its report vanished. That is the invariant
working as intended; what changed is that it can be reached. Say what it is:
// before: started, and its report was discarded
defineManagedResource({ id: 'dialling', start: ({ reportHealth }) => reportHealth('degraded') })
// after: says what it is, and does not gate the application
defineManagedResource({ id: 'dialling', required: false, start: ({ reportHealth }) => reportHealth('degraded') })
2. A resource that reported 'unhealthy' early and became healthy later —
a pessimistic report before a connection settled. Here required: false is the
wrong fix: it would hide a real failure. Report the recovery instead:
start: async ({ reportHealth }) => {
reportHealth('unhealthy')
await connect()
reportHealth('healthy') // ← previously unnecessary; now it is the fix
}
Also check your health endpoint. An optional resource reporting
non-healthy during start now moves the application aggregate to degraded,
where before it stayed healthy. A readiness probe that maps degraded to a
non-200 will flip on upgrade — and a supervisor that restarts on that will loop.
The refusals now say which of the two happened: a resource that was never
healthy is told it “is not healthy” and pointed at required: false; one that
was healthy and stopped is told it “lost readiness” and pointed at
onResourceFailure.
Released migration: 0.60.0
close() says what it achieved
AgentRuntime.close() returned Promise<void> and its contract promised three
things that cannot hold together. It now returns
{ settled, timedOut, remaining }.
Nothing breaks if you ignore the value:
await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
What DOES change is any code that relied on the old sentence “close() never
returns while a run is still in flight” while naming a forceTimeoutMs. That
was never true — the force budget exists precisely to stop waiting — so if your
process exits straight after close(), read the result:
const closed = await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
if (!closed.settled) {
// `closed.remaining` runs were still in flight. They stay recoverable
// through `scanRecoverable`; close never marks them terminal on its own.
logger.warn({ remaining: closed.remaining }, 'exiting with runs in flight')
}
If you want the old guarantee, omit forceTimeoutMs: that is the one
combination in which close() cannot return with a run in flight — and its wait
is unbounded, which is the trade.
A TypeScript consumer that wrote const done: void = await runtime.close() is
the only shape that stops compiling.
one name per concept
AgentModelDeclaration was a bare alias of AgentModelDescriptor, so the same
type reached consumers under two exported names:
// before
defineModelRegistry<Record<string, AgentModelDeclaration>>({ … })
// after
defineModelRegistry<Record<string, AgentModelDescriptor>>({ … })
A find-and-replace covers it; there is no behavioural change. Kept name is
AgentModelDescriptor, the one paired with AgentModelDescriptorSchema and
returned by registry.descriptor().
Two names that look like the same case and are not being merged:
SocketEventMap aliases Socket.IO’s EventsMap so a vendor’s name stays out of
our signatures, and AgentRun.ownerId holds the same value a runtime publishes
as runtimeEpoch on its events. The latter is one identity in two roles — “who
owns this run” and “which runtime emitted this event” — and both declarations
now say so. Renaming either would make the fencing comparison read worse.
a reachable public surface
Three exports that the public API required and did not provide, plus one option it accepted and ignored.
// before — identified by string, because the class was exported nowhere
if (error instanceof Error && error.name === 'AgentRuntimeConflictError') …
// after
import { AgentRuntimeConflictError } from 'stitchkit/agent-runtime'
if (error instanceof AgentRuntimeConflictError) …
ActivityTokenBrand is exported, so ActivityProjection can be implemented by
a test double. STITCH_ERROR_STATUS gained APPLICATION_NOT_ACCEPTING (503) —
only an exhaustive satisfies Record<StitchErrorCode, …> map stops compiling,
and the fix is one line. (GRAMMY_WEBHOOK_NOT_ACCEPTING joined it in 0.60.1,
under the same rule — see ADR 0105. This section briefly said the opposite: that
an adapter’s code stays out of the registry. It does not, and leaving it out was
the worse of the two, because isStitchErrorCode then answered false and the
code reached the wire spelled stitchkit’s way, past both codeMap and
unmappedCode.)
application.shutdown() no longer accepts retryAfterSeconds. Delete it from
the call — the kernel never read it. If you meant the HTTP Retry-After a
draining server sends, that lives on managedServerResource({ retryAfterSeconds }),
where it always did the work.
one shutdown vocabulary
// before
await runtime.close({ drainTimeoutMs: 30_000, forceTimeoutMs: 5_000 })
// after
await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
The rename is mechanical. The behaviour change under it is not, and it is the
reason the rename waited: two combinations were traps.
close({ drainTimeoutMs }) with no force budget aborted the runs and returned
without waiting for them to settle — so naming a budget gave a weaker
guarantee than naming none — and close({ forceTimeoutMs }) with no drain
budget never read the force budget, leaving an unbounded wait. Both now behave
as their names say.
Superseded. This section once ended “and
close()never returns while a run is in flight”. That was never true with a force budget — the budget exists to stop waiting — andclose()now returns{ settled, timedOut, remaining }instead of promising it. See Released migration: 0.60.0 → close() says what it achieved, above.
If your shutdown path measured how long close() took, expect it to take
longer in exactly the case where it used to return early — that is the fix, not
a regression. Defaults stay per-surface: ShutdownOptions.gracePeriodMs
defaults to 30 seconds, and the runtime’s omitted budget still means “abort
immediately”, the behaviour close() has always had.
unresolved attachments are omitted
history.unresolvedFile defaulted to text, and the placeholder it produced
carried the storage reference:
// before — the provider received your object key
// "[attachment: s3://bucket/tenants/42/invoice.pdf]"
history: {}
// after — omitted entirely by default; ask for a placeholder explicitly
history: { unresolvedFile: 'text' } // "[attachment: invoice.pdf]"
Two things to check. If a prompt relied on the model seeing something where an
unresolved file was, set unresolvedFile: 'text' — behaviour otherwise changes
silently, since an omitted part produces no error. And if any stored transcript
or provider log contains the old placeholder, it contains your storage layout;
treat those as disclosed. The error policy still names the reference, because
it is thrown into your process rather than sent upstream.
one bounded recoverable scan
AgentRuntimeStore had two scans: a mandatory unbounded one and an optional
paged one. The runtime only ever called the optional one, so implementing the
interface as written produced a store that threw on its first recover():
// before — the mandatory member was dead, the needed one was optional
{
scanRecoverable: () => loadEveryRecoverableSnapshot(),
scanRecoverablePage: ({ cursor, limit }) => page(cursor, limit),
}
// after — one member, the bounded signature the driver already used
{ scanRecoverable: ({ cursor, limit }) => page(cursor, limit) }
Delete the unbounded implementation rather than porting it: loading every
recoverable conversation to start is the shape 0.59.0 and ADR 0101 moved away
from, and nothing calls it now. If you built on createAgentRuntimeStore() you
have nothing to do if you only pass it to the runtime — it implements the
bounded page for you from the same driver member. If you CALL it yourself, the
member changed shape: scanRecoverable() took no argument and returned
snapshots; it now takes { cursor?, limit } — limit is required — and returns
one page of descriptors.
// before: const stale = await store.scanRecoverable()
// after: const { items, nextCursor } = await store.scanRecoverable({ limit: 100 })
published application status
createApplicationHealthHandler and createApplicationOperationalHandlers no
longer serialise the whole ApplicationSnapshot. They publish
ApplicationStatusProjection — the verdict plus resource counts:
// before — the response named every resource and its dependency edges
const { resources } = await fetch('/status').then((r) => r.json())
resources[0].dependsOn
// after — the topology is read in-process, where it always belonged
app.getSnapshot().resources[0].dependsOn
The consequence to check is not compilation — it is whatever already consumes
these routes. A dashboard that drew the dependency graph from /status, or an
alert keyed on admission.pending, goes blank rather than red: the fields are
absent, not zero. Both are available from getSnapshot(), so the fix is to read
them in the process that owns the application and publish them on a channel you
control. If a route was reachable from outside your network, treat the previous
payload as disclosed and rotate nothing but assume the topology is known.
Released migration: 0.59.0
Normalized agent runtime persistence
AgentRuntimeStoreDriver no longer reads and rewrites a lifetime AgentStoredState JSON
aggregate. Migrate that row and its recoverable/archive projections once:
- Copy
conversationIdandversioninto the bounded runtime head. - Write every
AgentStoredState.runs[]entry as one normalized run record. - Write every
AgentStoredState.admissions[]entry as one admission receipt and copy its canonical input into that receipt. - For terminal runs, retain the canonical assistant on the run record before deleting any old history/archive rows.
- Replace the recoverable projection with an index over normalized run
state. - Cut the adapter over atomically; there is no compatibility driver or dual-write mode.
// before
createAgentRuntimeStore({
state: { load, compareAndSwap },
history: { load, loadById, apply },
scanRecoverable,
})
// after
createAgentRuntimeStore({
head: { load, compareAndSwap },
runs: { load, loadByAssistantMessageId, loadMany, listActive, save },
admissions: { load, loadByInputMessageId, create },
history: { load, apply },
scanRecoverable,
})
AgentRuntimeHeadSchema, AgentStoredRunSchema and AgentAdmissionReceiptSchema replace
AgentStoredStateSchema and AgentAdmissionIdentitySchema. Run
runAgentStoreConformance() against the migrated adapter before switching production traffic.
If an application implements AgentRuntimeStore directly, its duplicate result must also include
the canonical run and the retained assistant for a terminal run.
Released migration: 0.58.0
The default history projection rejects invalid chronology
projectAgentHistory no longer forwards records a provider contract cannot
accept. A completed assistant record before the first user message, and an
assistant record whose tool calls have no matching results, are omitted with an
inspectable decision instead of being sent upstream:
// before — a leading assistant record was forwarded as-is
projectAgentHistory(messages)
// after — opt in explicitly, and only where the provider contract permits it
projectAgentHistory(messages, { leadingAssistant: 'allow' })
Two consequences the changelog does not spell out. First, the projection can now return fewer messages than the history holds, so any assertion or metric that compared projected length against stored length will move; read the detailed projection instead — it reports what was omitted and why, which is the supported way to see the difference. Second, the omission is silent to the provider but not to you: if a conversation suddenly loses its leading context, the decision record is where that shows up, not the transcript.
Operator events redact internalCause by default
Raw provider and tool failures no longer travel in operator events unless the sink asks for them:
// before — internalCause was present
createAgentObservability({ write })
// after — an explicit operator-only opt-in
createAgentObservability({ write, includeInternalCause: true })
The consequence to check before upgrading: any dashboard, alert or log
processor keyed on internalCause goes blind the moment you upgrade, and it
goes blind quietly — the field is absent, not empty. Set the flag on the
operator sink you own. Product delivery stays redacted regardless of the flag;
this option cannot widen what reaches a user.
Released migration: 0.57.0
Duplicate admission results carry the complete identity
Custom AgentRuntimeStore adapters must persist and return the input and
assistant identities associated with an idempotency key:
// before
return { outcome: 'duplicate', runId, snapshot }
// after
return { outcome: 'duplicate', input, inputMessageId, runId, assistantMessageId, snapshot }
Prefer replacing the custom aggregate reducer with createAgentRuntimeStore();
its admission record and transaction driver implement this contract
automatically. Historical note: 0.59.0 reshaped this driver again, so an adapter
crossing both versions should read that section first and migrate once.
AgentRuntimeEvent adds a post-commit admission variant
Add it to any exhaustive publisher switch. Its assistant is either the pending
placeholder for a new assignment or the canonical persisted assistant for a
duplicate:
case 'admission':
await persistProductProjection(event.input, event.run, event.assistant)
break
The consequence for an exhaustive switch written without a default branch is a
compile error, which is the point. The consequence for a switch that has a
default is worse and silent: post-commit admissions fall into it and are
projected as an unknown event. Grep for publisher switches before upgrading.
Released migration: 0.56.0
Surface manifests are version 2
operation.tools could not describe a role-selected MCP surface, a different
Agent or CLI selection, or an advertised extend schema, so projections moved
out of the canonical operation:
// before
manifest.operations[0].tools.MCP
// after
manifest.toolSurfaces.find((s) => s.transport === 'MCP' && s.surface === null)?.tools
// before
buildSurfaceManifest({ mcpSurfaces: { admin: { services, extend } } })
// after
buildSurfaceManifest({ mcpSurfaces: { admin: { services } }, mcpPreparation: { extend } })
A committed snapshot is regenerated once, deliberately: manifestVersion is
2, and ConformanceTransport gained REALTIME, so any exhaustive
Record<ConformanceTransport, …> must handle it.
FILE_* codes joined the error registry
StitchErrorCode gained FILE_INVALID_PATH, FILE_OUTSIDE_ROOT,
FILE_NOT_FOUND, FILE_NOT_REGULAR, FILE_INSPECTION_REJECTED,
FILE_TOO_LARGE and FILE_EXISTS. Only exhaustive maps break:
// before — compiled while the registry had no file codes
const copy: Record<StitchErrorCode, string> = { …, RATE_LIMITED: '…' }
// after — add the seven managed-file codes (or use Partial<Record<…>>)
Unexpected IO stays scrubbed as INTERNAL_SERVER_ERROR: the new codes are the
caller-safe ones only.
ScopedAuthHook is nominal
A hand-written function shaped like an auth hook is no longer assignable — identity now comes from the factory, so scope ownership and the inferred context cannot drift apart:
// before — a structural stand-in
const auth: ScopedAuthHook<Scopes> = async (ctx, endpoint) => { … }
// after — create it, then compose domains
const auth = createAuthHook({ resolve, rules })
const composed = composeAuthHooks({ hooks: [auth, billingAuth], defaultScope: 'public' })
Managed-file inspectors also run on reads
An inspector is no longer write-only, and it has a finite default deadline
(15 s). Make it read-aware and idempotent — a read carries no
declaredMediaType:
// before
inspect: ({ declaredMediaType }) => inspectDeclaredType(declaredMediaType!)
// after
inspect: ({ prefix, declaredMediaType, signal }) =>
inspectBytes(prefix, { declaredMediaType, signal })
Set inspectionTimeoutMs explicitly when 15 seconds is the wrong budget.
Direct async-operation binding needs a wire-stable ID
A direct binding reuses the start output as the follow-up wire input, so the ID
schema must parse to itself (z.input equals z.output, no transform,
coercion, default or overwrite). Anything else is parsed twice:
// before — a transform silently ran on start and again on every follow-up
defineAsyncOperationContract({ binding: 'direct', id: z.string().transform(Number) })
// after — keep the wire shape, adapt explicitly
defineAsyncOperationContract({
binding: 'adapted',
id,
adapters: { idFromStart, inputFor },
})
Released migration: 0.55.0
Peer-free implementRemote
implementRemote now has one canonical, optional-peer-free owner. This keeps
MCP SDK and AI SDK modules out of CLI bundles that only proxy HTTP calls:
// before
import { implementRemote } from 'stitchkit/tools'
// after
import { implementRemote } from 'stitchkit/remote'
Managed file boundary and strict auth returns
Create one boundary during application bootstrap and pass the capability, never a per-call directory or host path:
import { createManagedFileBoundary } from 'stitchkit/files'
const files = await createManagedFileBoundary({ root: '/srv/app-files' })
// before
defineDownloadTool({ defaultDir: '/srv/app-files', resolveUrl, ...common })
defineUploadTool({ upload: (path) => provider.uploadFile(path), ...common })
defineViewFileTool({ baseDir: '/srv/app-files', ...common })
// after
defineDownloadTool({ files, resolveUrl, ...common })
defineUploadTool({ files, upload: ({ bytes }) => provider.upload(bytes), ...common })
defineViewFileTool({ files, ...common })
Downloaded path is now relative to the boundary and MIME metadata is named
mediaType. Update raw mountDownload/mountUpload/mountViewFile configs the
same way. Auth predicates must explicitly return true, false, or a plain
object of context fields; replace accidental undefined fallthroughs with the
intended boolean.
Released migration: 0.53.0
Realtime emit returns boolean instead of void
Every call site compiles and behaves exactly as before — the return value
is new information (true = accepted by the transport, false = dropped
while the browser client was disconnected), not a behavior change. What breaks
is implementing the interfaces: a test mock or app-side adapter of
SocketIOClient / RealtimeClient / ValidatedRealtimeSocket /
RealtimeServer whose emit returns void no longer typechecks.
// before — a void mock satisfied the interface
const mock: Pick<RealtimeClient<S, C>, 'emit'> = { emit: () => {} }
// after — report acceptance (true is what a live server-side emit reports)
const mock: Pick<RealtimeClient<S, C>, 'emit'> = { emit: () => true }
While migrating, consider replacing hand-rolled if (client.connected) guards
with the new honest surface: check client.emit(...) === false, or observe
drops centrally with onDroppedEmit.
Released migration: 0.50.0
The factory’s scope union now covers per-endpoint overrides
createContractFactory<Scope>() already required a typed scope on the
contract. It now holds a per-endpoint scope override to the same union. Nothing
to migrate unless an override is outside the union — which was always a bug: the
scope reached no auth rule, and createAuthHook threw
[stitchkit] auth: no rule for scope "…" on the first request to it.
const { defineContract } = createContractFactory<'public' | 'user' | 'admin'>()
defineContract({ prefix: 'posts', scope: 'user' }, {
// before: compiled; failed at request time
// after: compile error naming the scope (TypeScript even suggests 'admin')
purge: { method: 'DELETE', path: '/all', desc: 'Purge', scope: 'admn', output },
})
Fix the typo, or widen the factory union if the scope is real. Contracts built
with plain defineContract are unaffected.
A declared defineErrors message is now used
ErrorDefinition accepts message. Nothing to migrate unless a registry already
wrote that key: it type-checked before (excess-property checking does not fire
through a const generic) and was ignored, so the code itself went on the wire.
const { errors } = defineErrors({ GONE: { status: 410, message: 'Long gone' } })
// before: errors.GONE().message === 'GONE'
// after: errors.GONE().message === 'Long gone'
If a registry carried message as a note to the reader rather than as
user-facing text, either fix the text or drop the key. A code with no details
schema also shows that text to a model (its tool details is { message }).
Optional: adopt createScopedImplement
If the app declares one superset handler context because different scopes inject
different fields, replace it with a scope map. This is additive — createImplement
still works.
// before — one context for every scope; `ctx.userId` is typed even in a
// `public` handler, where the runtime never injects it
interface AppContext extends RuntimeContext { userId: string; isAdmin: boolean }
export const implement = createImplement<AppContext>()
// after — each handler typed by its endpoint's effective scope
export const implementFor = createScopedImplement<{
public: object
user: { userId: string }
admin: { userId: string; isAdmin: boolean }
}>()
'public' must be a key (a contract with no scope is 'public'). Write
endpoints inline in the contract literal: an endpoint hoisted into a variable
widens its scope to string and is reported as undeclared.
Released migration: 0.49.0
The server handle became managed
createServer() and serveNode() return a handle that owns admission, HTTP
drain, realtime closure and one deadline-bounded runtime stop:
// before
server.stop()
await socket.io.close()
// after
await server.shutdown({ gracePeriodMs: 30_000 })
The runtime-specific instance stays reachable at .runtime, so an escape hatch
you already rely on does not disappear — but code that closed transports itself,
in its own order, is now racing the handle. Delete the manual closes rather than
keeping both; the handle’s result tells you what it drained and what it forced.
Socket.IO mounts through the whole handle
// before
createServer({ websocket: socket.websocket, rawRoutes: [socket.route] })
// after
createServer({ socket })
One owner for the route, the WebSocket attachment and the closure. For a raw Bun
lane, keep the composed websocket handler and pass socket beside it.
Bun native routes are gone
Native routes run before the Fetch handler, so they bypassed managed admission — which means they also bypassed shutdown, logging and observability, and that is why they had to go rather than be wired up:
// before
createServer({ routes: { '/health': () => Response.json({ ok: true }) } })
// after
createServer({ rawRoutes: [{ method: 'GET', path: '/health', handler: () => Response.json({ ok: true }) }] })
The handshake policy takes a Web Request
// before
createSocketIOServer({ serverOptions: { allowRequest: (req, done) => done(null, allowed(req)) } })
// after
createSocketIOServer({ allowRequest: (request) => allowed(request) })
The Node-shaped callback is gone, and the policy is now composed with shutdown admission on both runtimes: a handshake arriving during drain is refused for you.
Released migration: 0.48.0
Typed-client request options move to .withOptions
Generated endpoint methods reserve their ordinary call signature for contract
variables. This keeps them directly assignable to callback APIs whose runtime
supplies its own second context argument, including react-query-kit and
TanStack Query. Move imperative cancellation to the callable’s explicit method:
// before — endpoint with arguments
await api.create({ name: 'Max' }, { signal })
// after
await api.create.withOptions({ name: 'Max' }, { signal })
// before — endpoint without arguments
await api.health({ signal })
// after
await api.health.withOptions({ signal })
Direct query and mutation composition remains unchanged:
createMutation({ mutationFn: api.create })
createQuery({ queryKey: ['search'], fetcher: api.search })
There is no positional-options alias. Ordinary generated methods ignore extra
runtime callback arguments; only .withOptions reads ClientRequestOptions.
Released migration: 0.47.0
HTTP auth moves to the pre-body authorize phase
Move the HTTP wiring of createAuthHook from beforeHandle to authorize.
This lets Stitchkit reject an unauthorized JSON or multipart request after path
parameter validation but before reading a body chunk. Keep application
preconditions that depend on validated input in beforeHandle.
const auth = createAuthHook({ authenticate, authorize })
// before
createServer({ services, hooks: { beforeHandle: auth } })
// after
createServer({ services, hooks: { authorize: auth } })
Tool transports already receive parsed input, so their wiring does not move:
createMcpHandler({ services, lifecycle: { beforeHandle: auth } })
If a custom HTTP authorization hook read ctx.input, ctx.files or raw body
state, split it: identity/scope checks belong in authorize; validated payload
preconditions belong in beforeHandle or the domain service.
Multipart uses a typed descriptor and ctx.files
Replace every string multipart declaration, top-level maxUploadBytes and
ctx.file. The descriptor is now the only source of request, per-file,
cardinality and declared media-type policy.
// before
upload: {
method: 'POST',
path: '/',
multipart: 'file',
maxUploadBytes: 25 * 1024 * 1024,
}
upload: ({ file, input }) => store(file, input)
// after
upload: {
method: 'POST',
path: '/',
multipart: {
maxRequestBytes: 25 * 1024 * 1024,
files: {
file: {
maxBytes: 20 * 1024 * 1024,
contentTypes: ['image/*', 'application/pdf'],
},
},
},
}
upload: ({ files, input }) => store(files.file, input)
Multiple files are repeated under one multipart field name and arrive in the same order:
files: {
attachments: { multiple: true, maxFiles: 8 },
}
await api.upload({ attachments: [firstFile, secondFile] })
// handler: files.attachments is File[]
For direct-to-storage delivery, set delivery: 'stream' and implement the
endpoint with defineMultipartStream. A receiver must consume its Web Stream
and return { value, cleanup }; the final handler sees only receiver values.
There is no deprecated overload or buffered compatibility path under the old
contract shape.
Worked example — frozen on 0.3, jumping to 0.7
bun.lock→ consumer resolvesstitchkit@0.3.x.- Target:
0.7.0. - Scan CHANGELOG
### ⚠️ Breaking changesfor 0.4.0 … 0.7.0 → none (every release was additive — new exports, an extra hook argument, opt-in fields). - Nothing to migrate.
bun add stitchkit@^0.7.0,bun install.bun run checkgreen → runtime smoke → done. New surfaces (STITCH_ERROR_STATUS,serveFile,scopePrefixes,afterToolCall’sMethodDef,maxUploadBytes) are available to adopt, not required.
Released migration: 0.46.0
REALTIME_CONTRACT_VIOLATION joined the error registry
Realtime contract failures use the framework error model instead of a bare
ZodError, so an exhaustive map stops compiling until the code is added:
// before
{ …, INTERNAL_SERVER_ERROR: 'internal' } satisfies Record<StitchErrorCode, string>
// after
{ …, INTERNAL_SERVER_ERROR: 'internal', REALTIME_CONTRACT_VIOLATION: 'internal' } satisfies Record<StitchErrorCode, string>
Only an exhaustive map breaks. Since 0.56.1 codeMap itself is partial, so a
map without the satisfies keeps compiling and lets the code travel as itself.
RealtimeRejectedEvent.error is an AppError
// before
onRejected: ({ error }) => error.issues
// after
onRejected: ({ error }) => error.details?.issues // the ZodError moves to error.cause
The envelope gained reason and fault. The consequence worth checking: code
reading .issues directly does not fail to compile if the handler is loosely
typed — it silently reads undefined. Grep for .issues on rejection handlers.
CLI construction refuses reserved names
A contract field or tool named json, wait, quiet, dry-run, help,
version, wait-timeout or output-dir now throws while the CLI is built,
instead of being silently shadowed:
// before: app schedule_job --wait 2h → {"path":"2h"}, exit 0
// after: building a CLI over a contract with a "wait" field throws
This one fires at startup, not at call time, so an application shipping such a field crashes on boot after the upgrade. That is deliberate — the old behaviour corrupted arguments silently — but it means the upgrade is not safe to deploy without building the CLI once locally.
createToolLogger writes to stderr
stdout is the JSON-RPC channel of a stdio MCP server, and the previous
console.info default corrupted it. Pass log to redirect if your process
collected tool logs from stdout.
Released migration: 0.44.0
MCP TypeScript SDK v2 and protocol 2026-07-28
This is a hard cut: there is one Stitchkit API and no v1 aliases. Applications that expose MCP install the split server package; applications that implement an MCP host or run client E2E install the split client package.
bun remove @modelcontextprotocol/sdk
bun add @modelcontextprotocol/server@^2 ai@^7
# MCP hosts and client-side E2E only:
bun add -d @modelcontextprotocol/client@^2
Direct SDK imports move to the split packages too:
// before
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
// after
import { McpServer } from '@modelcontextprotocol/server'
MCP Apps additionally install @modelcontextprotocol/ext-apps. That adapter may
carry its own isolated v1-era transitive/peer relationship while the ecosystem
finishes its cutover; it does not permit application code to import the removed
monolithic SDK. Application-owned MCP server and client code uses the v2 split
packages exclusively.
// before
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { createMcpHandler } from 'stitchkit/tools'
const handleMcp = createMcpHandler({
serverInfo,
auth,
services,
sessionMode: 'stateless',
})
rawRoutes: [{ method: 'ALL', path: '/mcp', handler: handleMcp }]
// after
import { Client } from '@modelcontextprotocol/client'
import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools'
const mcp = createMcpHandler({
serverInfo,
auth,
services,
legacy: 'serve',
})
rawRoutes: [createMcpHttpRoute({ path: '/mcp', handler: mcp })]
// graceful shutdown: await mcp.close()
Keep the owned handle and close it from the same shutdown path as the HTTP server. A minimal runtime smoke should list tools before shutdown and prove the closed handler no longer serves requests:
const mcp = createMcpHandler(config)
const route = createMcpHttpRoute({ path: '/mcp', handler: mcp })
const beforeClose = await route.handler(listToolsRequest)
if (!beforeClose.ok) throw new Error('MCP list-tools smoke failed')
await mcp.close()
Remove @modelcontextprotocol/sdk, sessionMode, McpSessionMode, session
stores and all Mcp-Session-Id handling. HTTP is always request-isolated and
stateless. legacy: 'serve' (default) lets the official SDK negotiate supported
pre-2026 stateless clients on the same endpoint; legacy: 'reject' makes it
modern-only. This is not a stateful compatibility transport.
Output shape depends on the negotiated protocol era
Stitchkit always validates the handler result against the declared output schema. The negotiated MCP era determines only its wire representation:
| Negotiated era | Non-object structuredContent |
|---|---|
MCP 2026-07-28 |
the exact schema-valid JSON root: array, scalar or null |
| supported legacy era | the official SDK codec adapts it to { result: value } |
Object roots keep their object shape in both eras. Do not change every consumer
expectation to the modern shape while legacy: 'serve' remains enabled. Pin
both protocol versions in the consumer’s transport E2E and assert the boundary
explicitly:
await expectToolOutputForProtocol('2026-07-28', ['a', 'b'])
await expectToolOutputForProtocol('2025-11-25', { result: ['a', 'b'] })
async function expectToolOutputForProtocol(
protocolVersion: '2026-07-28' | '2025-11-25',
expected: unknown,
) {
const client = await connectConsumerMcpClient({ protocolVersion })
const result = await client.callTool({ name: 'list_notes', arguments: {} })
expect(result.structuredContent).toEqual(expected)
await client.close()
}
connectConsumerMcpClient represents the consumer’s real HTTP or stdio setup;
configure its official client with versionNegotiation.mode.pin so the test
cannot silently negotiate a different era.
The stdio helper now returns an owned lifecycle handle:
// before
await createStdioMcpServer(config)
// after
const stdio = await createStdioMcpServer({ ...config, legacy: 'serve' })
await stdio.close()
OAuth client registration is now one explicit policy object. CIMD is secure and enabled by default; DCR is disabled unless supplied:
// before
mountOAuthProvider({ ...oauth, clients, codes, refreshTokens })
// after
mountOAuthProvider({
...oauth,
clientRegistration: {
preRegistered: { get: clients.get },
// optional: dcr: { register: clients.register, get: clients.get }
},
codes,
refreshTokens,
})
A URL client id must be HTTPS and serve a document whose client_id exactly
matches that URL, with explicit redirect_uris and
token_endpoint_auth_method: 'none'. Do not keep a consumer-side metadata
fetcher or DCR fallback; Stitchkit owns SSRF-safe resolution and caching.
Multi-round input is opt-in on the operation and does not change Agent, CLI or ordinary HTTP handlers:
const ConfirmationSchema = z.object({ confirmed: z.boolean() })
mcp: {
inputRequired: [{
key: 'confirmation',
message: 'Confirm this action',
schema: ConfirmationSchema,
}],
}
Configure multiRound.state on the MCP server with a key of at least 32 bytes
and a stable authenticated principal. Read accepted typed content from
ctx.mcpInput.confirmation. Multiple declarations run in array order, the
aggregate remains exactly typed by key, keys must be unique, and declarations
must fit multiRound.serving.maxRounds (default 10). Do not execute a
destructive side effect before the complete aggregate is accepted. A modern
request missing the declared elicitation capability receives JSON-RPC error
code -32021. The official per-request legacy HTTP bridge cannot issue
server-to-client elicitation, so it returns a deterministic failed tool result;
multi-round input is never silently treated as complete.
Compatibility matrix
| Host / transport | Tools and Apps | Multi-round input | Continuity |
|---|---|---|---|
2026-07-28 HTTP |
yes | yes | request-isolated |
| supported legacy stateless HTTP | yes | unsupported result | request-isolated |
2026-07-28 stdio |
yes | yes | one process connection |
| supported legacy stdio | yes | official SDK bridge | one process connection |
| Agent / CLI / ordinary HTTP | unchanged | not exposed | unchanged |
Subscriptions, cross-request progress and resumable stateful SSE are not implemented or advertised.
Consumer checklist
-
Replace the monolithic SDK with
@modelcontextprotocol/server@^2and, only for hosts/tests,@modelcontextprotocol/client@^2. -
Replace raw MCP route wiring with
createMcpHttpRoute; retain and close the returned handler/stdio handle during shutdown. -
Delete all session mode, event-store and session-id code.
-
Move OAuth client policy under
clientRegistration; publish CIMD or enable DCR explicitly. -
Make
authorizeUserreturn the exact consented scope subset. The framework validates that it is a subset of the request before saving the authorization code:// before authorizeUser: async () => ({ userId }) // after authorizeUser: async (_req, request) => ({ userId, approvedScopes: request.scope?.split(' ') ?? [], }) -
Snapshot
listToolNames, run one contract tool, one runtime tool, any raw multimodal tool and every MCP App resource you use. -
Exercise modern HTTP and stdio with protocol
2026-07-28; exercise legacy only iflegacy: 'serve'is part of your support policy. -
Run the consumer’s typecheck and runtime gates. A browser/HTTP-only consumer must continue to work without either MCP package.
Historical breaking migrations through 0.44.0
HTTP observability now completes inside the framework handler instead of a nested fetch wrapper. Configure request and tool sinks explicitly:
// before
const audit = createAuditHook({ write })
createServer({
services,
wrapFetch: (handler) => wrapInRequestContext(audit.http(handler)),
})
mountAgent(services, { hooks: audit.toolCall })
// after
const observability = createObservability({
request: { write, includePayload: true },
tools: { write },
})
createServer({ services, observability: observability.request })
mountAgent(services, { hooks: observability.toolCall })
Body capture changed from always-on for body methods to opt-in. Set
includePayload: true only when the request sink needs the sanitized JSON body.
There is no createAuditHook or audit.http compatibility path;
wrapInRequestContext remains only for custom fetch pipelines.
Tool introspection now accepts one object-shaped contract/runtime surface. Stop calling the internal contract collector or merging a locally converted runtime manifest:
// before
buildToolManifest(services.flatMap((service) => collectTools(service, 'AGENT')))
listToolNames(services)
summarizeTransports(services)
// after
const surface = { services, runtimeTools }
buildToolManifest({ ...surface, transport: 'AGENT' })
listToolNames(surface)
summarizeTransports(surface)
ToolNameEntry adds kind: 'contract' | 'runtime'. TransportSummary is now
{ contractServices, runtimeTools, totals, sources }; replace services and
perService reads with the explicit counts and mixed-source breakdown. There
is no positional overload and no buildRuntimeToolManifest: Stitchkit owns the
combined order, transport filtering, canonical presentation schema and
cross-origin collision checks.
defineErrors now uses one Zod-first definition object and returns constructors
instead of positional throwers. Add explicit throw, move message/details/hint
into one options object, and declare a details schema when that code carries
structured context:
// before
const { errors } = defineErrors({ QUOTA_EXCEEDED: 429 })
errors.QUOTA_EXCEEDED('Try later', { retryAfterSeconds: 30 }, 'Wait')
// after
const { errors, definitions } = defineErrors({
QUOTA_EXCEEDED: {
status: 429,
details: z.object({ retryAfterSeconds: z.number().positive() }),
},
})
throw errors.QUOTA_EXCEEDED({
message: 'Try later',
details: { retryAfterSeconds: 30 },
hint: 'Wait',
})
There is no positional overload. A code without details forbids them; use an
optional object schema when the details object itself is optional. Read status
and schemas from the frozen definitions registry instead of maintaining a
parallel map.
Managed MCP runtime tools are now declared as immutable data. Move protected
registrar calls to runtimeTools; rename deliberate raw SDK registration to
rawTools. There is no registrar alias:
// before — protected
createMcpHandler({
services,
nativeTools: ({ registerTool }) => registerTool(preview),
})
// after — protected and prepared with the rest of the surface
createMcpHandler({ services, runtimeTools: [preview] })
// before — deliberate raw SDK opt-out
nativeTools: ({ rawServer }, auth) => mountRaw(rawServer, auth)
// after — still a deliberate raw SDK opt-out
rawTools: (server, auth) => mountRaw(server, auth)
When identity selects from a bounded set, replace a repeatedly prepared
services(auth) factory with a finite registry:
createMcpHandler({
surfaces: {
admin: { services: allServices, runtimeTools: [preview] },
member: { services: memberServices, runtimeTools: [preview] },
},
selectSurface: (auth) => auth.isAdmin ? 'admin' : 'member',
})
Keep direct services(auth) / runtimeTools(auth) only for genuinely
unbounded definitions; Stitchkit intentionally does not cache arbitrary auth
values.
Contract success bodies are now determined by the presence of output, not by
the handler’s runtime value. A nullable output returns JSON null with status
200; undefined with a declared output and non-null data without an output
schema are contract violations:
// nullable JSON data: 200 with body `null`
session: {
method: 'GET', path: '/session', desc: 'Current session',
output: SessionSchema.nullable(),
}
// bodyless operation: 204 with no body
logout: {
method: 'POST', path: '/logout', desc: 'End the session',
}
Add an output schema to every handler that returns data. Omit output and
return nothing for bodyless operations; runtime tools follow the same rule and
type no-output handlers as void.
createToolInvoker now separates immutable registry preparation from per-call
runtime state. Move source/context/lifecycle/hooks/output-strip reporting from
the factory config to the third invocation argument. Use invokeOrThrow when a
nested operation should preserve the normalized AppError instead of returning
a model-facing failure envelope:
// before
const invoker = createToolInvoker(services, {
transport: 'AGENT', context: { identity }, lifecycle, hooks,
})
const result = await invoker.invoke(name, args)
// after
const invoker = createToolInvoker(services, { transport: 'AGENT' })
const result = await invoker.invoke(name, args, {
context: { identity }, lifecycle, hooks,
})
const data = await invoker.invokeOrThrow(name, args, {
context: { identity }, lifecycle, hooks,
})
There is no static runtime-config overload: request identity must not be retained by a reusable compiled registry.
Entity cache handlers now require the cached list shape and CRUD policies. Move
listKey under list, make detail keys event-aware, and state the list-item
identity/projection explicitly:
// before
createEntityCacheHandlers<Entity>({
getId,
listKey: ['entities'],
detailKey: (id) => ['entities', id],
})
// after
createEntityCacheHandlers<Entity, EntityListItem>({
getId,
getListItemId: (item) => item.id,
toListItem: (entity) => ({ id: entity.id, name: entity.name }),
list: {
key: ['entities'],
shape: 'paginated',
createAt: 'start',
updateMissing: 'skip',
},
detailKey: (event) => ['entities', event.id],
})
Choose array, paginated, infinite-array or infinite-paginated to match
the actual cached data. A dynamic list.key / detailKey receives a
discriminated event and can derive scoped keys from the created/updated entity
or deleted payload. Add compare only when the backend has a canonical order;
the framework does not guess it or mutate pagination metadata.
Protected native MCP operations now use the transport-neutral runtime tool
definition. Return the schema-owned value from the handler and move MCP content
or metadata into present.mcp; structuredContent and isError are
framework-owned:
// before
registerTool({ input, output, handler: async () => ({
content: [{ type: 'image', data, mimeType: 'image/png' }],
structuredContent: { assetId },
}) })
// after
const preview = defineRuntimeTool({
name: 'render_preview', description, identity, input, output,
handler: async () => ({ assetId, data }),
present: {
mcp: (result) => ({
content: [{ type: 'image', data: result.data, mimeType: 'image/png' }],
}),
},
})
runtimeTools: [preview]
The removed NativeMcp* types have no aliases. Use RuntimeToolDefinition,
RuntimeToolIdentity, RuntimeToolHandlerContext and
RuntimeMcpPresentation. The same definition can now be passed to
mountAgent(services, { runtimeTools: [preview] }); add present.agent only
when the model needs rich text/file content instead of the neutral JSON result.
Trailing wildcards must be named consistently across the path and params schema:
// before
path: '/app/:slug/*'
params: z.object({ slug: z.string(), '*': z.string() })
ctx.params['*']
api.app({ slug: 'foo', '*': 'a/b' })
// after
path: '/app/:slug/*filePath'
params: z.object({ slug: z.string(), filePath: z.string() })
ctx.params.filePath
api.app({ slug: 'foo', filePath: 'a/b' })
Bare wildcards have no compatibility alias; raw routes use the same named form.
Expected-401 matchers
HttpClientConfig.authEndpoints is removed. Replace manual path prefixes with
the operations whose 401 response is expected:
// before
createHttpClient({ baseUrl, authEndpoints: ['/api/auth/'] })
// after
createHttpClient({
baseUrl,
suppressUnauthorizedFor: contractEndpointMatchers(authContract, ['login', 'verify']),
})
There is no implicit /auth/ suppression. Omit suppressUnauthorizedFor when
every 401 should emit the global unauthorized event.
The 0.37 migration
Tool presentation is no longer an executable Zod parser. Replace the removed
flatten helpers with the JSON Schema compiler, and choose the explicit
MountableTool surface when using the advanced collection API:
// before
const flat = flattenUnionsDeep(zodSchema)
mountable.schema
// after
const flat = flattenToolJsonSchema(
z.toJSONSchema(zodSchema, { target: 'draft-07', io: 'input' }),
)
mountable.presentationSchema // model/MCP/manifest JSON Schema
mountable.argumentSchema // executable CLI argument adapter only
Contract and native handlers keep their original Zod schemas; MCP and agent SDK
adapters now forward raw arguments so defaults, coercions, refinements and
transforms execute exactly once inside Stitchkit. There are no compatibility
exports for flattenDiscriminatedUnion, flattenUnionsDeep or
MountableTool.schema. → ADR 0050
Tool-call hooks now take one options object. Migrate all three callbacks; there are no positional overloads:
// before
beforeToolCall: (toolName, args, context, endpoint) => {}
afterToolCall: (toolName, args, result, durationMs, context, endpoint, error) => {}
onToolError: (toolName, error, context, endpoint) => {}
// after
beforeToolCall: ({ toolName, args, context, endpoint }) => {}
afterToolCall: ({ toolName, args, result, durationMs, context, endpoint, error }) => {}
onToolError: ({ toolName, error, context, endpoint }) => {}
MCP schema validation
The standalone validator now takes one object and live MCP configs carry the
same rules under schemaValidation. Migrate every positional call and every
onIncompatibleSchema field; there is no old-shape overload:
// before
validateMcpSchemas(services, 'throw', logger, { requireTypedProperties: true })
createMcpHandler({ services, onIncompatibleSchema: 'throw' })
// after
validateMcpSchemas({ services, policy: 'throw', logger, requireTypedProperties: true })
createMcpHandler({ services, schemaValidation: { policy: 'throw' } })
Put extend and flattenUnionInput beside services, not inside
schemaValidation. The handler applies the profile to the exact prepared schema
it advertises. Add requirePortableFormats: true when every custom JSON Schema
format must be rejected before a client sees it.
Native MCP registration also changed shape in 0.37. Move protected tools to the framework registrar; keep an SDK-raw tool only by naming the opt-out:
// before
nativeTools: (server, auth) => server.registerTool(name, config, handler)
// after — lifecycle, hooks and schema policy apply
nativeTools: ({ registerTool }, auth) => registerTool({
name, description, identity, input, output, handler,
})
// after — intentionally raw
nativeTools: ({ rawServer }, auth) => rawServer.registerTool(name, config, handler)
If a tool hook annotated endpoint as MethodDef, remove that annotation or
use OperationIdentity: native operations have service/action/scope/method but
no HTTP path.
Node-facing server types
stitchkit/server remains Bun-concrete: an explicitly annotated RawRoute
still receives BunServer. stitchkit/node no longer drags Bun declarations
into a Node project. Its raw routes default the host server to unknown, and
its Socket.IO handle exposes only Node capabilities:
// before — Node entry still leaked Bun-only fields and ambient types
const route: RawRoute = { handler: (_req, ctx) => ctx.server?.upgrade(...) }
const socket = await createSocketIOServer(config)
socket.websocket
// after — name a custom embedding host only when one exists
const route: RawRoute<MyHostServer> = {
handler: (_req, ctx) => useHost(ctx.server),
}
const socket = await createSocketIOServer(config)
socket.io
socket.attach(nodeHttpServer)
Node consumers can remove @types/bun unless another dependency independently
requires it.
Managed server shutdown
createServer() and serveNode() now return the same structural managed
lifecycle. Replace every direct runtime stop and parallel Socket.IO close:
// before — split ownership
const socket = await createSocketIOServer(config)
const server = createServer({
services,
websocket: socket.websocket,
rawRoutes: [socket.route],
})
server.stop()
await socket.io.close()
// after — one owner and one total deadline
const socket = await createSocketIOServer(config)
const server = createServer({ services, socket })
const result = await server.shutdown({ gracePeriodMs: 30_000 })
On Node, keep the same socket field and replace handle.close() with
handle.shutdown(). Runtime-specific diagnostics move under handle.runtime;
do not use it as a second shutdown path. Standalone CLI/tools that create a
Socket.IO handle without an HTTP server call await socket.close().
If Bun Socket.IO shares the port with a raw lane, keep the explicit composition but let the server mount the Socket.IO route:
createServer({
services,
socket,
websocket: composeWebSocketHandlers([
webSocketLane({ match: isRaw, handlers: rawHandlers }),
socketIoLane(socket.websocket),
]),
rawRoutes: [rawUpgradeRoute],
})
Move native Bun routes entries to rawRoutes. Native routes run before the
Fetch handler and therefore cannot participate in admission or drain. Wire
SIGTERM/SIGINT in the application; the first signal starts shutdown(), and
a later signal may abort the same controller. Close MCP, databases and queues
after the server result—those resources remain application-owned.
Move a handshake policy from the Node-only callback shape inside
serverOptions to the runtime-neutral top-level policy. It receives a Web
Request, may be async, and returns whether to admit the handshake:
// before
serverOptions: { allowRequest: (request, done) => done(null, allowed(request)) }
// after
allowRequest: (request) => allowed(request)
Your handlers may be returning more than the contract declares
stitchkit validates every handler’s return value against the endpoint’s output
schema and passes on the parsed result — so any field the schema does not
declare is silently removed. That is deliberate (the contract is the published
shape of the response), but when you are moving a live API onto stitchkit it is
invisible: TypeScript does not reject excess properties, nothing logs it, and the
client just receives fewer fields.
While migrating, turn the diagnostic on:
createServer({ services, warnOnOutputStrip: true }) // off by default
Every removed key is logged as a dot-path with the endpoint that produced it
(notes.get: secret, nested.alsoSecret). Tool transports strip identically —
mountMcp / mountAgent take onOutputStrip: (toolName, paths) => …. Read the
list, then either widen the contract or stop returning the field, and turn the
flag back off: it is for the migration window, not for production.
Tool names may shift between versions
Derived tool names are part of your public surface — an MCP client config or an agent prompt refers to them by string. Before and after any upgrade that touches name derivation, diff them mechanically:
import { listToolNames } from 'stitchkit/tools'
console.log(JSON.stringify(listToolNames({ services, runtimeTools }), null, 2))
listToolNames never throws on an illegal name — that is deliberate, so it can
show you the offending row when a mount would refuse it. Pin it in a snapshot
test and a shift fails your build instead of your clients.
When you author a breaking change in stitchkit
You are on the other side of this flow — see
AGENTS.md → Breaking changes & migration. In short: it is
allowed; write the ### ⚠️ Breaking changes block with a before → after snippet,
bump the minor (pre-1.0), and migrate the controlled consumers in the same pass.
Where the migration section goes while the version has no number
Write it here, immediately under the flow above, as
## Unreleased migration: <short slug>. The slug matters: several unreleased
migrations may sit side by side, and each one belongs to whoever wrote it. Do
not reuse an existing Unreleased migration heading for a different change —
that is how the 0.57.0 migration was lost, overwritten by the next author before
anyone promoted it.
At release, the release commit promotes every Unreleased migration heading into
one ## Released migration: X.Y.Z, each former heading becoming a ###
subsection under it. This is the same move the changelog makes when [Unreleased]
becomes ## [X.Y.Z], and it happens in the same commit.
A release carrying ### ⚠️ Breaking changes and no matching
## Released migration: X.Y.Z is refused by bun scripts/release-plan.ts — in
pre-push and again in the publishing workflow. The check starts at 0.44.0;
breaking versions older than that are covered by the summary sections near the
end of this file.