Migrating from 0.6.x to 0.7.0

What changed between Routecraft 0.6.0 and 0.7.0, and how to update.

0.7.0 is the durable release. A capability can defer mid-pipeline and continue days later from another transport, an agent can do the same in the middle of a conversation, and a running instance can be driven, watched and reached over HTTP: from a terminal, from an editor, or from another instance. Almost all of that is additive. What follows is every change that can break a 0.6 project, in the order most projects meet them.

  1. Time options lose their Ms suffix and take Duration. intervalMs is interval, timeoutMs is timeout, and so on; a stale name fails the build with RC5003 naming the replacement. Numbers keep working, and '5m' is now accepted everywhere. Section 1.
  2. Listeners are declared once, under servers. http.port / http.host and mcp.port / mcp.host are gone, listener events are renamed, the per-route http({ auth }) option is removed, mcpPlugin over HTTP needs an explicit resource.url in production, and MCP and ACP mounts refuse a browser Origin or an unknown Host with 403 until you list them. Section 2.
  3. Deferral is new. Nothing to migrate: 0.6 had no way to pause an exchange durably. Everything that moved during the canary is in the canary notes. Section 3.
  4. Lifecycle. A context is single-use, plugin:started moves to the new start() phase, Route.signal changes meaning, and shutdown drains before it abandons. Section 4.
  5. Agents and tools. currentTime() and randomUuid() are removed, checkpointId is deferralId, agent files load by frontmatter name, and craft chat is gone. Section 5.
  6. MCP. A dropped exchange is an error result, the advertised output schema is enforced, and primitive outputs carry structuredContent. Section 6.
  7. The http() client. A 10 MB body cap, a narrower body type, and a lower-case content-type no longer doubles the header. Section 7.
  8. forward() carries the caller's headers, which a strict target may now reject. Section 8.
  9. html() text extraction keeps whitespace and escaped markup. Section 9.
  10. Smaller breaks. .input() validation throws RC5065 rather than RC5002, timer({ exactTime }) and timePattern removed, ops.auth: false meaningful, thenable and callable schemas validate, ArkType deferral digests change, .retry() reaches continuations. Section 10.

Routes built only from the DSL with framework adapters typically need section 1 (the rename is mechanical, and the build tells you where), section 2 if they serve HTTP or MCP, and section 3 if they defer. The rest is for adapter authors, store authors and advanced integrations, plus the notes for anyone who ran a 0.7.0 canary (section 11). Section 12 lists what is new, for context.


1. Time options: the Ms suffix is gone

Every option a route author writes that means a span of time now takes Duration, which is number | "5m": a bare number is milliseconds, as before, and a string names its unit. The Ms suffix came off every such option, because a name ending in Ms that accepts "5m" lies. Values the framework reports are unchanged: durationMs on events, backoffMs on route:retry:attempt and ageMs on ops responses stay millisecond numbers, because they are data rather than configuration.

SurfaceOldNew
timer()intervalMsinterval
timer()delayMsdelay
timer()jitterMsmaxJitter
cron()jitterMsmaxJitter
http()timeoutMstimeout
mail()pollIntervalMspollInterval
mail({ reconnect })baseDelayMsbaseDelay
mail({ reconnect })maxDelayMsmaxDelay
.timeout().timeout(timeoutMs: number).timeout(duration: Duration)
.delay().delay(delayMs: number).delay(duration: Duration)
.retry()backoffMsbackoff
.retry()maxBackoffMsmaxBackoff
.circuitBreaker()windowMswindow
.circuitBreaker()cooldownMscooldown
.debounce()waitMswait
.debounce()maxWaitMsmaxWait
.batch()flushIntervalMsflushInterval
.sample()intervalMsinterval
.cache()ttl: numberttl: Duration (name unchanged)
.dedupe()ttl: numberttl: Duration (name unchanged)
defineConfigshutdown.timeoutMsshutdown.timeout
defineConfigservers.<name>.shutdownGraceMsservers.<name>.shutdownGrace
defineConfigtelemetry.sqlite.eventFlushIntervalMstelemetry.sqlite.eventFlushInterval
defineIndicatormaxAgeMsmaxAge
mcpPluginrestartDelayMsrestartDelay
mcpPlugintoolRefreshIntervalMstoolRefreshInterval
shell() / shellPlugin()timeout: numbertimeout: Duration (name unchanged)
@routecraft/testingroutesReadyTimeoutMsroutesReadyTimeout
@routecraft/testingdelayBeforeDrainMsdelayBeforeDrain
.throttle()per: ThrottleTimeUnitper: ThrottleTimeUnit | Duration (widened)

Existing numeric values keep working under every new name, so the migration is a rename and nothing else.

Before (0.6.x):

import { craft, noop, timer } from "@routecraft/routecraft";

craft()
  .id("poll")
  .from(timer({ intervalMs: 60_000 }))
  .retry({ maxAttempts: 3, backoffMs: 1000, maxBackoffMs: 10_000 })
  .to(noop());

After (0.7.0):

import { craft, noop, timer } from "@routecraft/routecraft";

craft()
  .id("poll")
  .from(timer({ interval: "1m" }))
  .retry({ maxAttempts: 3, backoff: "1s", maxBackoff: "10s" })
  .to(noop());

Four things beyond the rename:

  • A stale name is refused at runtime, not only by TypeScript. timer({ intervalMs: 60_000 }) from plain JavaScript, through an as any, or through an options object spread in used to run silently on the 1000ms default. Every authored options surface now rejects a key ending in Ms, the options removed for their own reasons (timer({ exactTime }), timer({ timePattern }), retry({ exponential })), and the name a straight desuffixing would guess (timer({ jitter }) and cron({ jitter }), which point at maxJitter), each with RC5003 naming what to write instead. The context's own config (shutdown, each servers.<name>, telemetry.sqlite) is checked in the constructor.
  • jitter on timer() and cron() is maxJitter, not the bare jitter a straight desuffixing would give: each delay is drawn uniformly from [0, maxJitter), and .retry({ jitter }) is a fraction in [0, 1], which stays as it was. One name meaning two types on two operations is the defect this change closes.
  • .throttle({ per }) accepts a Duration. The unit words keep their meaning, and a bare number is milliseconds as everywhere else a Duration is accepted: per: 60 is a 60ms window. Write per: "60s" or per: "minute".
  • craft start --timeout accepts a duration string (--timeout 30s); a bare number is still milliseconds.

2. Servers and mounts

A listener is declared once under servers and selected by name from every surface that serves HTTP: routes, MCP, ACP and the ops surface can share one port on distinct paths, or each gets its own.

Before (0.6.x):

import { defineConfig } from "@routecraft/routecraft";
import { mcpPlugin } from "@routecraft/ai";

export default defineConfig({
  http: { host: "0.0.0.0", port: 8080 },
  plugins: [mcpPlugin({ transport: "http", host: "0.0.0.0", port: 8081 })],
});

After (0.7.0):

import { defineConfig } from "@routecraft/routecraft";
import "@routecraft/ai"; // augments CraftConfig with mcp

export const craftConfig = defineConfig({
  servers: { public: { host: "0.0.0.0", port: 8080 } },
  http: { server: "public" },
  mcp: { server: "public", path: "/mcp" },
});

Use a separate server name to give any surface a dedicated port. What else changed with it:

  • Listener events are server:listening, server:failed and server:closed, each carrying the server name. plugin:http:server:listening / plugin:http:server:closed and plugin:mcp:server:listening are removed; subscribe to server:listening on the MCP transport's named server instead.

  • The per-route http({ auth: "required" | "optional" | "skip" }) option is removed. A mount decides authentication: http: { mounts: { api: { path: "/api" }, public: { path: "/", auth: false } } } walls /api under the server validator while the catch-all stays open, and a route picks a surface with http({ mount: "api", path: "/orders" }). A route on an open mount that needs identity declares .authorize(), which forces verification through the server validator. The former "optional" personalisation mode has no equivalent.

  • One auth vocabulary on every mount. Unset inherits the server validator as a wall when one exists; a config is the mount's own wall; false removes the wall and keeps the inherited validator reachable, so a route's .authorize() still pulls identity through it. On the MCP mount auth: false no longer means "credentials are never inspected": a valid token attaches a principal to tool calls and an invalid one is treated as absent. serverAuthConfigured is removed from WebIngress and HttpMountContext in favour of the resolved HttpMountAuth facts (configured, own, optedOut, walled).

  • server is a mount property on http. Each entry under mounts names its own listener (default "default"); the plugin's top-level server remains only as the single-mount shorthand and is refused alongside mounts.

  • mcpPlugin({ transport: "http" }) requires an explicit HTTPS resource.url unless NODE_ENV is exactly development or test; an unset NODE_ENV counts as production, so craft run or craft start on a laptop refuses to boot with RC5003. Previously an omitted resource.url silently advertised the bound address. For local work over plain http, run with NODE_ENV=development (for example "dev": "NODE_ENV=development craft start"); in production, set resource.url to the HTTPS URL clients reach through your proxy.

  • MCP and ACP mounts check Host and Origin before CORS and auth, and answer 403. Two things that worked on 0.6 now need configuration:

    • Browser clients need browserOrigins. A request carrying an Origin header is refused unless that exact origin is listed on the mount, and that includes local browser tools such as the MCP Inspector (mcp: { browserOrigins: ["http://localhost:6274"] }). A permissive cors setting does not admit a browser any more; the two are independent. Clients that send no Origin (editors, server-side and desktop MCP clients, curl) are unaffected.
    • A reverse proxy's public hostname needs allowedHostnames. The bound hostname is trusted, and loopback and wildcard binds also accept localhost, 127.0.0.1 and [::1]. A proxy that preserves its public Host needs it listed under servers.<name>.allowedHostnames; MCP also trusts the hostname of its own resource.url. Forwarded and X-Forwarded-Host never establish trust.

    A request the Host or browser-origin check refuses emits server:request:rejected with the server, the mount and the reason (host or origin); a CORS refusal answers 403 without it. See Securing capabilities for the full rules.

  • auth:rejected reasons are one bounded vocabulary (invalid_token, unsupported_scheme, infrastructure, expired, insufficient_scope), the missing-credential reason is missing_header, and both auth:rejected and auth:success carry source as http, http:<name> or mcp. A bearer verification that fails on the server side (JWKS unreachable) answers 500 rather than 401, so a client keeps its cached token through an IdP blip.

  • Mount paths are static canonical prefixes: no ?, #, :param segments, empty segments or percent-encoding. mcpPlugin({ path }) is held to the same contract and rejects "/", which was previously remapped to /mcp. The validator is exported as normalizeStaticPathPrefix.

  • Listeners reap idle connections after 255s, tunable per server with idleTimeout (refused above 255s rather than clamped, because Bun caps it there). Mounts that hold quiet long-lived streams exempt their requests, and maxStreamingRequests (default 500) caps the streams one listener carries.

  • A declared server with nothing mounted fails the boot, naming every mount and where it landed, so a plugin that failed to apply is told apart from a misspelt server name.

3. Deferral, new in 0.7.0

Nothing to migrate here. 0.6 had no way to pause an exchange durably and continue it later, so .defer(), .resume(), the deferral store and the deferral config block are new surface rather than changed surface.

What they are, in one place:

  • .defer() defers an exchange into a store and answers the caller at once with a Deferred acknowledgment, { status, deferralId, token, schema?, expiresAt? }. .resume() revives it by signed token, from any transport, at the step after the deferral. A route with a reachable .defer() has output type Output | Deferred.
  • Deferrals expire after deferral.defaultTtl, which is 72h unless you set it. An overdue one re-enters the route's error channel with RC5047. Settled records are purged after retention, 90d by default. Either takes "never".
  • .resume({ authorize }) is the one policy point. Without it the door is bearer: any holder of a valid token may resume, which is why securing a resume ingress is something you do rather than something you inherit.
  • Durability starts at a declared .defer(). A process that dies at step four of a route that never deferred loses that exchange.

If you ran a 0.7.0-canary-* build, this surface was called something else for most of the canary. See section 11.

4. Lifecycle and shutdown

  • A context is single-use. context.start() after context.stop() refuses with RC1004 instead of resolving readiness over routes whose controllers are gone. Build a fresh context from your config; the process is the restart unit, and craft run already behaves this way. Two concurrent start() calls collapse into one boot.
  • context.stop() resolves { forced, pending } instead of void, and context:stopped carries the same payload. A clean stop reports forced: false and an empty pending; an await context.stop() call site is unaffected, and a handler typed against the old empty payload keeps compiling.
  • plugin:started moves. Plugins gain a start() phase between apply and teardown, running after every route has signalled readiness. The apply-phase events are plugin:applying / plugin:applied; plugin:starting / plugin:started bracket the new phase, so a subscriber to plugin:started keeps compiling and firing, later than before. CraftContext.whenStarted() resolves once every hook has finished, which is what startAndWaitReady() awaits.
  • teardown takes a second argument, { partial, started }. Additive: partial says the context never finished starting, started says whether this plugin's own start() ran.
  • Route.signal means "in-flight work is abandoned". It used to fire at the start of shutdown; that meaning moved to the new Route.intakeSignal. Code that read route.signal to notice a shutdown keeps compiling and silently stops reacting to a graceful one: read route.intakeSignal. An out-of-repo implementation of Route must add intakeSignal, abortExecution() and inFlightCount.
  • Shutdown drains. The first signal closes intake and lets in-flight exchanges finish; only the forced stage abandons them, at shutdown.timeout (default 30s; set it below your platform's kill timer). At the deadline one warn line names the routes still working, execution is abandoned, and the process exits non-zero. A non-positive or non-finite shutdown.timeout is refused with RC5058. A route disabled at runtime drains the same way, bounded by .enabled({ drainGrace }).
  • A second Ctrl+C no longer forces an exit. A repeated SIGINT or SIGTERM during the graceful stage is ignored. SIGQUIT (Ctrl+Backslash) or SIGBREAK (Ctrl+Break on Windows) forces an immediate exit, before or during it. A container whose STOPSIGNAL is SIGQUIT and expects a drain gets a force exit instead; keep SIGTERM as the stop signal. See shutdown helpers.
  • A lifecycle hook must not await its own ctx.stop(): shutdown now waits for an in-flight apply() or start() before teardown, so the awaited form waits for itself. throw to abort the boot, or call ctx.stop() without awaiting it.
  • route:started fires earlier for a callable source, when the route invokes the callable rather than on its first message, so a quiet polling route no longer delays readiness by the 30s backstop.
  • A context whose routes are all disabled no longer auto-stops, because a disabled route has not completed.

5. Agents and tools

5.1 currentTime() and randomUuid() are removed

Both were fn factories you assigned a tool name in agentPlugin({ functions }). Declare the fn inline under the same name and every tools([...]) reference keeps working:

Before (0.6.x):

import { agentPlugin, currentTime } from "@routecraft/ai";

agentPlugin({ functions: { CurrentTime: currentTime() } });

After (0.7.0):

import { agentPlugin } from "@routecraft/ai";
import { z } from "zod";

agentPlugin({
  functions: {
    CurrentTime: {
      description: "Current date and time in ISO 8601.",
      input: z.object({}),
      handler: () => new Date().toISOString(),
      tags: ["read-only", "idempotent"],
    },
  },
});

The randomUuid() replacement is the same shape with handler: () => crypto.randomUUID() and tags: ["read-only"]. directTool() is now the only fn builder the framework ships.

5.2 FnHandlerContext

checkpointId is renamed deferralId, and defer is a required member, so a hand-built handler context must provide one. makeFnHandlerContext and testFn both do, and @routecraft/testing's TestFnHandlerContext includes a structural defer that returns the sentinel shape without deferring anything.

5.3 Agent files

agents() walks the agents/ folder recursively, matching Claude Code's subagent convention, and an agent's identity is its frontmatter name: the filename no longer has to match, so nothing that loaded before stops loading. Three things can: duplicate names throw, naming both files; a directory holding AGENT.md is one agent and its name must match the directory; and skills: frontmatter has new semantics, declaring where an agent's skills come from (local paths, npm: refs) rather than naming blocks, which is what the key removed in 0.6 did. disallowedTools without tools is rejected at load, because a deny list against inherited defaults cannot be honoured.

5.4 craft chat is removed

With no shim, alias or pointer. The interactive interface is an editor speaking the Agent Client Protocol: craft acp is the bridge, and acp: on defineConfig serves it. See talk from your editor.

6. MCP

  • A route that drops the exchange answers an error result. A .filter() rejecting, a .choice() matching no branch or a handler returning recovery.drop() used to publish the caller's own request as the tool's result. Such a call is now isError: true under AI2002, emits plugin:mcp:tool:declined and logs at warn; plugin:mcp:tool:failed keeps meaning a failure. A route that should return a value on that path needs a branch that produces one, or .error() recovering with a body.
  • The advertised outputSchema is enforced. A body the route's own .output() already validated is not re-checked; a result that reached the server without passing through it (a tool registered directly, a deferral) is validated at the surface and refused with AI2001 if it contradicts the promise. validateAgainst and wasOutputValidated are exported for any request/reply adapter enforcing a schema at its own boundary.
  • Primitive and array outputs carry structuredContent. A tool declaring .output({ body: z.string() }) used to advertise a schema and then omit structuredContent, which a spec-compliant client rejects. The value is now published in the envelope the protocol era expects ({"result": "42.50"} on the 2025 era, unwrapped on 2026). A client reading only the text block sees no change; an object output is byte-identical. A deferring tool advertises oneOf: [Output, Deferred] and its deferral emits plugin:mcp:tool:deferred rather than completed.
  • McpIcon.sizes is readonly string[], and the default icon set gains a 96x96 PNG per theme, growing from 772 B to 3840 B per inheriting tool; icons: [] opts out.
  • mcpPlugin({ auth: false }) now attaches a principal when a valid token is presented, per the mount vocabulary in section 2.

7. The http() client

  • The response body is capped at 10 MB. A declared Content-Length above the cap is refused before the body is read; otherwise the count is checked as bytes arrive. Exceeding it fails with RC5061 naming the option and the size; the body is never truncated to fit. Raise maxBodySize on a call that legitimately moves more, or pass Infinity to opt out.
  • body is typed as HttpRequestPayload (string, number, boolean, null, object, Uint8Array, ArrayBuffer, URLSearchParams, FormData). In practice only bigint and symbol bodies stop compiling, and in exchange body: (ex) => ... gets ex typed as the route's exchange with no annotation.
  • A lower-case content-type header no longer doubles up. The client used to add Content-Type: application/json when it found no header spelled exactly that way.
  • New and additive: redirect: "follow" | "manual" | "error" (a 3xx under "manual" does not trip throwOnHttpError; isRedirect is exported) and responseBody: "bytes" for a Uint8Array that arrives intact.

8. forward() carries the caller's headers

forward() built the forwarded exchange with no headers, so a target's .authorize() refused it with RC5012 or ran it with no authority at all, and the correlation chain broke at the boundary. It now passes the caller's headers by reference, which brings it to parity with a direct() destination. Two consequences to check:

  • A target route with a strict .input({ headers }) schema now sees the caller's headers and may reject a forward that previously passed.
  • Forwarding a principal whose expiresAt has passed surfaces the expiry error rather than RC5012.

Every route ingress now mints a fresh routecraft.id rather than inheriting one, and drops routecraft.split_hierarchy, so a forwarded or direct()-dispatched exchange never collides with its caller in a store keyed by exchange id or joins the caller's aggregation. Route.getForward() (internal) takes the calling exchange as a required argument.

9. html() text extraction

extract: "text", "innerText" and "textContent" no longer run a tag-stripping regex over text cheerio had already decoded. Two things change, on every role that extracts:

  • Whitespace inside the match survives. Only the ends are trimmed, per matched element, so a <pre> keeps its newlines and ordinary page indentation is no longer flattened to single spaces.
  • Escaped markup survives. Array&lt;string&gt; extracts as Array<string> rather than Array. The value is decoded and unsanitised, so escape it at the sink before writing extracted text into HTML or a line-structured format.

You are affected if a route compares extracted text to a literal, keys or hashes on it, validates it against a schema, or writes it to a line-oriented sink. Collapse in the route where you want the old shape:

import { craft, direct, html, noop } from "@routecraft/routecraft";

craft()
  .id("card-text")
  .from(direct())
  .transform(html<unknown, string>({ selector: ".card", extract: "text" }))
  .transform((text) => text.replace(/\s+/g, " "))
  .to(noop());

<style> and <script> subtrees are still removed. extract: "html", "outerHtml" and "attr" are unchanged.

10. Smaller breaks

  • timer({ exactTime }) and timer({ timePattern }) are removed. timePattern was never read; exactTime anchored only the first fire and then ran on interval. Use cron("0 9 * * *", { timezone: "Europe/Amsterdam" }), which pulls the croner optional peer.
  • ops.auth: false is no longer refused. It carries the server plugin's meaning: no validator is effective for the ops mount, the health.details gate closes, and a scope-gated tier has nothing to check against and fails the boot.
  • .input() validation throws RC5065, not RC5002. RC5002 meant both a caller's malformed payload and the instance failing its own contract (.output() validation, a mid-pipeline validate(), an empty aggregation), so no transport could answer the two differently. The route's .input() body and header schemas now raise RC5065; everything else keeps RC5002. A route-scope .error() matching rc === "RC5002" to recover a bad payload matches "RC5065" now; one catching an output or validate() failure is unchanged. The failure is raised at the same chain position, is routable through .error() the same way, and is still retryable: false. The visible payoff is on the ops dispatch mount, which answers 400 {"error":"bad request","code":"RC5065","message":...} where it used to answer 500 {"error":"dispatch failed","code":"RC5002"} with no message, so craft exec now names the field and the rule that rejected the request.
  • A schema whose validate() returns a thenable now validates instead of passing the caller's input back as accepted. A body such a schema rejects now fails, with RC5065 on .input() and RC5002 on .output(); a schema that never settles hangs the validation instead of silently passing, and nothing bounds that wait. A validate() returning a non-record fails with a message instead of crashing.
  • Callable schemas (ArkType) are accepted wherever the framework asks "is this a Standard Schema", and a deferral deferred under one now hashes its rendered JSON Schema. The digest changes for ArkType only: an ArkType deferral from before the upgrade resumes into RC5048 (continuation changed), is denied, and re-asks its approver. Settle or drain those before upgrading if a re-ask is disruptive. Zod digests are byte-identical.
  • The deferral counter refuses corruption with RC5057 instead of resetting to zero, which could have re-derived an id an earlier deferral already used.
  • An event handler returning a bare thenable is no longer logged as having thrown.
  • @routecraft/testing's startAndWaitReady() no longer rejects when a startup exchange fails while another source is still coming up, and its readiness gate settles a route that starts or is disabled.
  • apiKey() gains scopes and is refused alongside verify; apiKey({ scheme }) was already removed in 0.6.

11. Canary-only notes

Nothing released is affected by these; they matter only if you ran a 0.7.0-canary-* build.

  • The surface() params callback no longer takes sessionId; remove the sessionId: '' placeholder from every callback. The adapter fills in the running turn's session.
  • The SQLite session schema is version 2 and keyed by session id alone. A database written by a canary or a recent main checkout loses its stored conversations on first open.
  • startedBy on the agent-sessions management resource is owner.
  • sessions: { store } and deferral: { store } resolving to the same file are refused at boot, naming both settings, and every SQLite file carries the identity of the store that owns it.
  • .suspend() is .defer(), and a suspension is a deferral. The action is named for what it does: nothing is scheduled and no worker waits. ex.suspension is ex.deferral, the acknowledgment's status is "deferred" and its suspensionId is deferralId, SuspensionStore is DeferralStore (with MemoryDeferralStore and SqliteDeferralStore), suspension: { ... } is deferral: { ... }, and ROUTECRAFT_SUSPENSION_STORE and ROUTECRAFT_SUSPENSION_SECRET are ROUTECRAFT_DEFERRAL_STORE and ROUTECRAFT_DEFERRAL_SECRET. .resume() keeps its name, and so do schema, ttl, meta and every member of the affordance.
  • .defer({ expect }) is .defer({ schema }), and the option is optional. The rename carries through ctx.defer(), DeferError, testFn's structural defer, the acknowledgment's wire field, the stored record, and describeExpect, which is describeSchema. A site declaring no schema defers with no contract, validates nothing at the resume ingress, and types ex.deferral.result as unknown.
  • The acknowledgment dropped question and reason. Carry them in .defer({ meta }), which is persisted verbatim and handed to .resume({ authorize }), or in the notification step that already runs before the deferral.
  • The deferral store starts again at schema version 1, as .routecraft/deferrals.db with a deferrals table and the application id RCDE. On defaults that is a different file from the canary's .routecraft/suspensions.db, so the old one is abandoned rather than read: nothing fails, and deleting it reclaims the disk. A configured path carried over unchanged IS opened, and refused at boot as not a deferral store; delete that database or name a new path.
  • The record carries two states, not five. status is state: "waiting" | "settled", plus waitingFor ("resume" today) for what the work is waiting for and outcome: { kind, at, reason?, by? } for how it settled, where kind is "resumed" | "expired" | "denied". The old expiring status was a delivery claim rather than an outcome and is now claimedAt on a record that is still waiting: a deferral is resumable while it is waiting and unclaimed, which the exported resumable(record) predicate answers. settledAt, resumedAt, resumedBy and deniedReason fold into outcome.
  • The cached result of execution two is continuation, not terminal. On the record and on the resume acknowledgment alike, where it was also called outcome. One word per concept: an outcome is how the deferral ended, a continuation is how the resumed run ended.
  • A custom store gained members during the canary: findExpired(now, limit, after?), claimExpiry(id, at), releaseClaims(before), resumedWithoutContinuation(limit?), recordContinuation(id, result), and replaceStepState(id, fingerprint, next). releaseExpiring and resumedWithoutTerminal are the old names of the middle two.
  • A resumed continuation honours the route's filter chain. A route declaring route-scope .retry() alongside .defer() gains at-least-once execution of the steps after the deferral, with no opt-in, so make those steps idempotent.

12. What is new in 0.7.0

For context, no migration required:

  • Durable defer and resume. .defer(), .resume(), the deferral store with signed tokens, the sweeper, .resume({ authorize }), meta, tokenFor, and re-entrant defer sites for adapters that defer mid-step.
  • Durable agents and sessions. ctx.defer() inside a tool defers the loop; agent(name, { session }) keeps a transcript with an inbox, one turn at a time, interrupt: true, and directTool(id, { background: true }) for work that outlasts a turn. See durable agents and the agent adapter.
  • Talk to an agent from your editor. acp: on defineConfig, craft acp, profiles, the model and thinking-level pickers, and the surface() adapter for a capability that reaches the editor. See talk from your editor.
  • The ops surface. Health, readiness, indicators, the management API, contributed resources and the event tail, behind scope-gated tiers. See the ops plugin.
  • Remotes. Another instance's dispatchable routes as direct endpoints. See the remotes plugin.
  • The project runtime. craft start boots a project from its layout, Claude Code agent files load unchanged, registerProjectDiscoverer lets a package claim a folder. See project structure and the CLI reference.
  • .enabled(), authorize({ anyScope, effective }), direct({ internal: true }), RFC 9728 discovery on every http surface, timingSafeStringEqual, isStandardSchema, validateAgainst, isDropped, parseDuration, anySignal.
  • The http() source: Server-Sent Events and streaming bodies, respond for early acknowledgment, the Standard Webhooks signature scheme, maxStreamingRequests and idleTimeout. The http() client: redirect, responseBody: "bytes".
  • shell() in @routecraft/os, isolated by default, with the docker tier; the require-untrusted-shell-args lint rule.
  • Model controls: reasoning, providerOptions, the sampling block on agent(), content parts in the user prompt, agent({ stream: true }), typed prompt callbacks.
  • MCP: default icons with a PNG per theme, plugin:mcp:tool:declined and plugin:mcp:tool:deferred.
  • New error codes: RC1004, RC5040 to RC5064, AI1004 to AI1019, AI2001 and AI2002, OS1001 and OS1004. See the errors reference.