Migrating from 0.4.x to 0.5.0

What changed between Routecraft 0.4.0 and 0.5.0, and how to update.

This guide covers every breaking change extracted from a direct diff of the public surface (packages/*/src/index.ts, public type definitions, and adapter factory signatures). It is split into three sections:

  1. Stable-API changes every consumer needs to address.
  2. Experimental-API changes that only affect you if you opted into the AI, MCP, mail, or auth surfaces flagged @experimental at 0.4.0.
  3. What is new in 0.5.0 — for context, no migration required.

If you stayed on the stable surface (route DSL, http(), cron(), timer(), simple(), direct(), telemetry, logger, eslint-plugin), the only changes that touch you are sections 1.1, 1.2, and 1.3.


1. Stable-API changes

1.1 Route metadata moves to the route builder

title, description, and input / output schemas were previously fields on direct() and mcp() source options. They are now route-level concerns expressed through new builder methods, so any source adapter inherits them automatically.

New builder methods on RouteBuilder:

  • .title(value: string) — display title
  • .description(value: string) — discoverable description
  • .input(schema | { body, headers }) — body and header validation, framework-enforced before the pipeline runs
  • .output(schema | { body, headers }) — output validation against the primary destination
  • .tag(value) / .tags(values) — tags drive selectors like tools({ tagged: "read-only" }) on the agent side; literals "read-only" | "destructive" | "idempotent" autocomplete and any string is accepted

.input() failures emit exchange:dropped; .output() failures route through the route's error handler or emit exchange:failed.

1.2 direct() source: endpoint is the route id

Previously, direct() source took an explicit endpoint name and discovery metadata as the second argument. Now the endpoint is the route id, and metadata lives on the route builder per section 1.1.

Before (0.4.0):

craft()
  .from(
    direct("ingest", {
      description: "Process inbound orders",
      schema: PostBody,
      headerSchema: HeaderSchema,
      keywords: ["orders"],
    }),
  )
  .to(...)

After (0.5.0):

craft()
  .id("ingest")
  .title("Ingest orders")
  .description("Process inbound orders")
  .input({ body: PostBody, headers: HeaderSchema })
  .from(direct())
  .to(...)

DirectServerOptions now contains only channelType. description, schema, headerSchema, and keywords are removed. A route without .id() becomes agent-only with a UUID endpoint. The framework now enforces route-id uniqueness instead of endpoint uniqueness.

The destination form is unchanged: direct("fetch-order") and direct((exchange) => ...) still work.

1.3 Logger writes to stdout by default

Framework logs now write to stdout, matching pino's default and 12-factor conventions. To send logs to a file, use the --log-file flag:

craft run server.js --log-file ./logs.txt

Critical for stdio MCP servers: routecraft logs will now corrupt the stdio MCP protocol stream unless you redirect them out of stdout. Use one of:

craft run mcp-server.js --log-file ./mcp.log
# or
craft run mcp-server.js --log-level silent

1.4 Define your config with defineConfig

CraftConfig switched from type to interface so ecosystem packages can declaration-merge first-class config keys onto it. The recommended way to author your config is now the new defineConfig helper, which preserves literal-type inference at the call site without you having to declare a config type yourself:

Before (0.4.0):

import type { CraftConfig } from "@routecraft/routecraft"

const config: CraftConfig = {
  plugins: [...],
  routes: [...],
}
export default config

After (0.5.0):

import { defineConfig } from "@routecraft/routecraft"
import "@routecraft/ai" // side-effect import enables first-class llm/agent/mcp/embedding keys

export default defineConfig({
  llm: { providers: { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY } } },
  agent: { defaultOptions: { model: "anthropic:claude-opus-4-7" } },
  routes: [...],
})

If you actually extended the type, switch the type alias to an interface:

// Before
type MyConfig = CraftConfig & { custom: string }

// After
interface MyConfig extends CraftConfig {
  custom: string
}

Runtime behaviour is unaffected.

1.5 ESLint rule removal

The mcp-server-options rule was removed. It enforced the old mcp(name, { description }) shape, which no longer exists after the metadata hoist (1.1). The framework now validates at subscribe time with a clearer error.

If you have this rule explicitly configured, drop it from your ESLint config:

// remove this line from rules
"routecraft/mcp-server-options": "error",

1.6 Exchange is immutable

Every field on Exchange<T> is now readonly, and DefaultExchange shallow-freezes the wrapper, headers, and (when present) principal at construction. Body is intentionally not deep-frozen so adapter authors can attach arbitrary user payloads, but the framework will not mutate it and your code should not either.

Code that mutated the parameter inside .process(), a custom .enrich() aggregator, or a custom WrapperStep will fail to compile (the parameter is Readonly<>) and again at runtime in strict mode (TypeError on a frozen field).

Before (0.4.0):

.process((exchange) => {
  exchange.body = { ...exchange.body, hello: "world" };
  exchange.headers["x-stage"] = "processed";
  return exchange;
})

After (0.5.0):

.process((exchange) => ({
  ...exchange,
  body: { ...exchange.body, hello: "world" },
  headers: { ...exchange.headers, "x-stage": "processed" },
}))

The framework re-wraps the returned plain object back into a proper instance via DefaultExchange.rewrap, preserving the context binding, route binding, and identity (exchange.id). Returning the same exchange unchanged is still a valid no-op pass-through.

Custom .enrich() aggregators follow the same rule: return a spread instead of mutating original.body. The built-in aggregators (only, replace, none, defaultEnrichAggregator) already follow the new contract.

Two related framework signals moved off headers (which would now fail because they are frozen) onto out-of-band helpers. They only affect you if you fork an operation or write a custom step:

  • exchange.headers["routecraft.dropped"] is gone. Drop signalling (filter, choice halt + unmatched, source-payload parse with OnParseError: "drop") uses markDropped(exchange) / isDropped(exchange) from @routecraft/routecraft.
  • exchange.headers["routecraft.startedAt"] is gone. Child exchange start timestamps used by aggregate for duration emission live on the exchange's internals via framework-internal helpers; survives rewrap.

For deeper details, see .standards/type-safety-and-schemas.md § Exchange Immutability.


2. Experimental-API changes

These all carried @experimental at 0.4.0. If you opted in, here are the renames and removals.

2.1 mail() — body reshape and verify option

MailMessage.text and MailMessage.html are grouped under a single body object. Mailparser collapses MIME into at most one of each, so the correct abstraction is a grouped alternative-pair.

Before (0.4.0):

console.log(message.text)
console.log(message.html)

After (0.5.0):

console.log(message.body.text)
console.log(message.body.html)

MailMessage.attachments is unchanged.

New: verify?: "off" | "headers" | "strict" on MailServerOptions (default "headers"). When set, populates a new MailMessage.sender?: MailSender field with sender analysis (mailing-list and auto-forward detection, ARC/DMARC trust). The "strict" mode requires the mailauth peer dependency.

2.2 agent() — model id, prompt fields, and tool authorisation

Before (0.4.0):

agent({
  modelId: "anthropic:claude-opus-4-7",
  systemPrompt: "You are a summariser.",
  userPrompt: (ex) => `Summarise: ${ex.body}`,
  allowedRoutes: ["fetch-order", "cancel-order"],
  allowedMcpServers: ["docs-server"],
})

After (0.5.0):

agent({
  model: "anthropic:claude-opus-4-7",
  system: "You are a summariser.",
  user: (ex) => `Summarise: ${ex.body}`,
  tools: tools(["fetch-order", "cancel-order", "mcp_docs-server:search"]),
})

Field-level changes:

  • modelIdmodel. Now optional when agentPlugin({ defaultOptions: { model } }) provides a default. Resolution order at dispatch: instance value > plugin default > throw RC5003.
  • systemPromptsystem. Both string and (exchange) => string are accepted (parity with llm()).
  • userPromptuser. Same shape widening.
  • allowedRoutes and allowedMcpServers are removed. Tool authorisation goes through the new tools() helper, which resolves explicit references and tag selectors against the live fn / direct / mcp registries.
  • New optional output?: StandardSchemaV1 for structured output, mirroring llm({ output }) and the route-level .output(schema).

Inline LlmModelConfig credentials on agent({...}) are no longer accepted. Provider credentials now live exclusively on llmPlugin:

Before (0.4.0):

agent({
  model: { provider: "anthropic", apiKey: "...", model: "claude-opus-4-7" },
  // ...
})

After (0.5.0):

// Configure the provider once on llmPlugin
llmPlugin({ providers: { anthropic: { apiKey: "..." } } })

// Agents reference the model by id
agent({
  model: "anthropic:claude-opus-4-7",
  // ...
})

Removed type exports from @routecraft/ai: AgentModelId, AgentPromptSource. If you imported either, switch to LlmModelId and LlmPromptSource.

2.3 llm() — schema field renames

Before (0.4.0):

llm("anthropic:claude-opus-4-7", {
  outputSchema: ResultSchema,
  systemPrompt: "You are...",
  userPrompt: (ex) => `Summarise ${ex.body}`,
})

After (0.5.0):

llm("anthropic:claude-opus-4-7", {
  output: ResultSchema,
  system: "You are...",
  user: (ex) => `Summarise ${ex.body}`,
})

The result body still exposes text, output, and usage — no shape change to LlmResult / LlmResultWithOutput.

2.4 embedding()using is now type-required

Before (0.4.0):

embedding("openai:text-embedding-3-small", {})
// typechecked at compile time, but threw RC5003 at runtime

After (0.5.0):

embedding("openai:text-embedding-3-small", {
  using: (ex) => ex.body.text,
})

Adapter factory option types are no longer wrapped in Partial<>, so required fields are now required at the type level. llm(), direct(), and mail() had no actually-required option fields, so no call-site change is needed for those.

2.5 mcp() source — metadata hoist and isolated registry

The mcp() source no longer takes an endpoint name or descriptive metadata as arguments. The tool name is the route id; description, title, and input / output schemas come from the route builder.

Before (0.4.0):

craft()
  .from(
    mcp("search", {
      description: "Full-text search across documents",
      schema: SearchQuery,
      keywords: ["search", "docs"],
      annotations: { readOnlyHint: true },
    }),
  )
  .process(searchHandler)
  .to(...)

After (0.5.0):

craft()
  .id("search")
  .description("Full-text search across documents")
  .input({ body: SearchQuery })
  .from(mcp({ annotations: { readOnlyHint: true } }))
  .process(searchHandler)
  .to(...)

McpServerOptions now holds only MCP-protocol extras: annotations and icons. A non-empty .description() on the route is required for the MCP framework to expose the tool.

Local-tool registry isolation: MCP local tools no longer share the direct() registry. They have their own (MCP_LOCAL_TOOL_REGISTRY). Plugin-side changes:

  • McpPluginOptions.tools predicate signature changed: it now receives an McpLocalToolEntry (the new local-tool shape), not a direct entry.
  • McpServerOptions.keywords and McpLocalToolEntry.keywords are removed.

2.6 Auth surface moved to @routecraft/routecraft

jwt(), jwks(), and the principal types previously lived in @routecraft/ai. They now live in @routecraft/routecraft.

Before (0.4.0):

import { jwt, jwks, type AuthPrincipal } from "@routecraft/ai"

After (0.5.0):

import {
  jwt,
  jwks,
  type Principal,
  type OAuthPrincipal,
} from "@routecraft/routecraft"

Type changes:

  • AuthPrincipalPrincipal. The base shape no longer declares scheme; each subtype carries its own.
  • OAuthPrincipal is the discriminated subtype for OAuth flows.
  • McpAuthValidator is removed.

jwt() behaviour tightened:

  • Tokens without an exp claim are now rejected by default. Pass requireExp: false to opt out.
  • HS* (symmetric) tokens are no longer accepted by default. Pass acceptHmac: true to opt in.
  • issuer and audience are now required configuration fields.

oauth() factory:

  • OAuthFactoryOptions.getClient was renamed to client.
  • OAuthPrincipal.expiresAt is now contractually enforced.

2.7 First-class AI config keys (additive)

Importing @routecraft/ai now augments CraftConfig with first-class llm, mcp, embedding, and agent keys via declaration merging, so you can configure them directly on defineConfig instead of inside plugins[]. See section 1.4 for the recommended shape. The plugins: [llmPlugin(...), agentPlugin(...)] form continues to work — no migration required if you prefer it.


3. What is new in 0.5.0

For context only. None of these require any migration.

Dual-mode wrapper operations (.error() first)

.error() becomes the first dual-mode wrapper. The same method name now applies at two distinct scopes depending on where you call it on the route builder:

  • Route scope — call it before .from(). Catches any unhandled error from the pipeline and halts the route. This is the existing 0.4.0 behaviour, unchanged.
  • Step scope — call it after .from(). Wraps only the immediately next step. On success the pipeline continues untouched; on failure the handler runs, its return value replaces the body, and the pipeline continues with the next step. The builder's body type is preserved across the wrapper, so step-level .error() is fully type-safe.

This pattern is the foundation for future resilience operations (retry, cache, timeout, circuit breaker, throttle, delay) — each will adopt the same dual-mode shape so users learn it once. See issue #140 for the full design.

Step-scope example: recover from one flaky call

craft()
  .id("resilient-pipeline")
  .from(timer({ intervalMs: 60_000 }))
  .transform(prepareRequest)
  .error((err, ex) => ({ fallback: true, reason: String(err) }))
  .to(http({ url: "https://flaky.api/endpoint" }))
  .to(database())

If the http() call fails, the step-level handler returns the fallback object as the new body and the pipeline continues to database().

Combined route + step scope

craft()
  .id("with-safety-net")
  .error((err, ex, forward) => forward("errors.catchall", ex.body)) // route-level
  .from(timer({ intervalMs: 60_000 }))
  .transform(prepareRequest)
  .error((err) => ({ fallback: true })) // step-level
  .to(http({ url: "https://flaky.api/endpoint" }))
  .to(database())

The step-level handler recovers http() failures silently. If the step-level handler itself throws, the route-level handler takes over and forwards to errors.catchall. The route is not stopped; the next exchange processes normally.

Operation categories

For reference, route-builder operations now fall into three groups:

CategoryPosition relative to .from()Examples
Route-onlyBefore.id(), .batch(), .authorize()
Dual-mode wrapperBefore or after.error() (more to follow in 0.6.0)
PipelineAfter.transform(), .filter(), .to(), .process(), ...

ESLint rules continue to enforce route-only positioning. Wrapper positioning is enforced by the builder type system.

Agent runtime

  • Tool-calling loop on agent() with whitelisted access to fn handlers, direct routes, and remote MCP tools.
  • tools() helper for declarative tool authorisation (explicit names, tag selectors, per-binding guards and overrides).
  • fn() primitive for ad-hoc in-process functions registered via agentPlugin({ functions }).
  • Streaming agents: opt in via stream: true to receive an AgentStream body. The HTTP server bridges to SSE automatically.
  • defaultFns: built-in read-only fns (currentTime, randomUuid).
  • Forward-compat hooks landed for durable agents (0.6.0): SuspendError (@experimental), FnHandlerContext.checkpointId, AgentSession.

Choice operation

craft()
  .id("dispatch")
  .from(direct())
  .choice((c) =>
    c
      .when((ex) => ex.body.priority === "urgent", (b) =>
        b.transform(prepUrgent).to(direct("urgent-queue")),
      )
      .when((ex) => ex.body.amount > 1000, (b) =>
        b.transform(prepHighValue).to(direct("review-queue")),
      )
      .otherwise((b) => b.to(direct("standard-queue"))),
  )

Branches share the operations catalog with the parent route via a shared StepBuilderBase. Branches that end in b.halt() short-circuit; unmatched exchanges with no otherwise are dropped with reason "unmatched".

Programmatic invocation

import { CraftClient } from "@routecraft/routecraft"

const client = new CraftClient(context)
const result = await client.send("ingest", { orderId: "abc" })

Lets you invoke routes from outside the framework lifecycle (test runners, scripts, embeds).

Adapter mocking

@routecraft/testing now ships mockAdapter, tagAdapter, and factoryArgs. Combined with the new RC_ADAPTER_OVERRIDES store key, these let tests swap factory output without touching the route under test.

MCP OAuth 2.1 server provider

The mcp() source can now sit behind an OAuth 2.1 authorisation server. The framework ships JWT and JWKS verifiers, an oauth() factory, and a typed OAuthPrincipal shape.

.authorize() route-entry guard experimental

A new route-only .authorize() method declares an authorization requirement on a route. It runs at route entry, before any pipeline step, and verifies that the inbound exchange carries an authenticated principal and (optionally) that the principal carries every required role and scope.

craft()
  .id("delete-user")
  .description("Delete a user by id")
  .authorize({ roles: ["admin"] })
  .from(mcp({ annotations: { destructiveHint: true } }))
  .to(deleteUserDestination)

Stack .authorize() calls to AND-combine; the first failure short-circuits.

.authorize() is route-only: it stages onto the next route, same convention as .id / .title / .description / .input / .output / .tag / .batch. Calling a pipeline op (.to, .transform, .process, ...) while authorizers are staged but no new .from() has opened the next route throws RC2001 with a message naming .authorize among the staging ops that need a .from() to follow. For a mid-pipeline check, drop down to the validator form -- useful when you swap the principal in a .process() step or want to gate a .choice() branch:

import { authorize } from "@routecraft/routecraft"

craft()
  .from(mail({ /* ... */ }))
  .process(attachEmailPrincipal)
  .validate(authorize({ predicate: (p) => p.email?.endsWith("@yourcompany.com") === true }))
  .to(yourDestination)

Failures throw RC5012 (no principal) or RC5015 (principal failed the role / scope / predicate check). Both flow through the route's .error() handler like any other validation failure.

.authorize() does NOT issue, mint, or attach any credential. It checks an existing identity. Authentication happens at the source boundary (mcp({ auth: jwt(...) }), oauth(), etc.) or in a .process() step that attaches a Principal.

Runner argv channel

A new RUNNER_ARGV store key lets adapters read remaining CLI arguments after the runner has parsed its own flags, without coupling to a specific runner package.


Quick reference: import path moves

Symbol0.4.00.5.0
jwt, jwks, JwtAuthOptions, ...@routecraft/ai@routecraft/routecraft
AuthPrincipal@routecraft/aiPrincipal from @routecraft/routecraft
McpAuthValidator@routecraft/airemoved

Quick reference: removed exports

SymbolReplacement
AgentModelIdLlmModelId
AgentPromptSourceAgentUserPromptSource (alias of LlmPromptSource)
AuthPrincipalPrincipal
McpAuthValidatornone — use the new oauth() factory + verifiers