Migrating from 0.5.x to 0.6.0

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

Two coordinated changes to the agent surface:

  1. skills is replaced by a unified blocks record. Skills, memory, identity, instructions, and any future system-prompt contribution are now one primitive.
  2. Tag selectors on tools() are removed. Programmatic tools((catalog) => [...]) is the new escape hatch for "give me all read-only tools" style selection.

Every other consumer-visible part of the public API is unchanged.


1. Agents: skills is replaced by blocks

AgentOptions.skills: string[] and agentPlugin({ skills }) are removed. They are replaced by a single primitive that covers what skills used to do and unifies it with memory, identity, instructions, and any other system-context contribution: AgentOptions.blocks: Blocks (a Record<string, BlockBody | false>).

A block body has:

  • mode: "inject" to always concatenate the resolved content into the system prompt as ## <name>\n\n<content>, or "progressive" to surface the block as a synthetic loader tool the model invokes on demand. Progressive blocks require a description.
  • lifetime (optional, default "dispatch"): "dispatch" re-runs the resolver on every dispatch; "context" runs it once per CraftContext and reuses the result.
  • value: a static string used verbatim, or a function (exchange, context, events, client) => string | Promise<string>. The client carries forward(routeId, payload), the same callable route .error() handlers receive, so a resolver can delegate to a registered direct route. events is reserved (always [] today) for a forthcoming exchange-event log.

The block's name is the record key, not a field on the body. Names starting with the reserved _block_ prefix are rejected (RC5026).

The big semantic shift: progressive disclosure is now the default for skills. The model sees each skill's name and description in the tool list and loads the body via a tool call only when relevant. This matches Claude Code's actual default. To preserve the legacy "always inject every skill" behaviour, opt into mode: "inject".

1.1 Inline skills becomes inline blocks

Before (0.5.x):

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are an analyst.",
  skills: ["web-search", "cite-sources"],
});

After (0.6.0):

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are an analyst.",
  blocks: {
    "web-search": {
      mode: "inject",
      value: "Always search before answering.",
    },
    "cite-sources": {
      mode: "inject",
      value: "Always cite your sources.",
    },
  },
});

1.2 agentPlugin({ skills }) is removed; skills() returns blocks

agentPlugin({ skills: { ... } }), the Skill / SkillRegistry / RegisteredSkillName / SkillOverride exports, and the ADAPTER_SKILL_REGISTRY symbol are all gone. There is no shim.

skills({ source, mode?, lifetime? }) keeps the same name as the 0.5 markdown loader but now returns a Blocks record you spread into an agent's blocks: { ... } map. It reads the same markdown layout (flat <name>.md and nested <name>/SKILL.md, with the Claude Code frontmatter the old loader accepted). The default mode is "progressive" so the model picks which skills to load.

Before (0.5.x):

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

agentPlugin({
  skills: await skills("./skills"),
});

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are an analyst.",
  skills: ["web-search"],
});

After (0.6.0), progressive disclosure (recommended):

import { agent, skills } from "@routecraft/ai";

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are an analyst.",
  blocks: { ...(await skills({ source: "./skills" })) },
});

After (0.6.0), recovering the legacy "concatenate every skill" behaviour:

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are an analyst.",
  blocks: { ...(await skills({ source: "./skills", mode: "inject" })) },
});

The function signature changed from skills(path) to skills({ source }). The return type changed from Record<name, Skill> to Blocks. Both are visible at the call site.

1.3 agents() markdown loader: skills: frontmatter is rejected

The agent markdown loader (agents("./agents")) used to accept a skills: frontmatter field. That field is now rejected with RC5003 "not yet supported" because blocks accept function-form resolvers that YAML cannot express. Set blocks on the registered agent in code instead, either via the per-agent overrides map handed to agents() or via the agent's call site.

Before (0.5.x): agents/researcher.md

---
name: researcher
description: Researches things
model: anthropic:claude-sonnet-4-6
skills:
  - web-search
  - cite-sources
---
You are a researcher.

After (0.6.0): drop skills: from frontmatter, supply blocks via the overrides map:

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

agentPlugin({
  agents: await agents("./agents", {
    researcher: {
      blocks: await skills({ source: "./skills" }),
    },
  }),
});

1.4 Resolver-backed blocks (memory, tenant config, identity)

Function-form resolvers receive the live exchange, context, a reserved events list, and a block client. Use client.forward(routeId, payload) to delegate to a registered direct route. Use lifetime: "context" to evaluate once per CraftContext and cache the result across dispatches.

This is the pattern memory adapters will use; it is illustrative, not a shipped builder in 0.6.0.

import { craft, direct } from "@routecraft/routecraft";
import { agent } from "@routecraft/ai";

craft()
  .id("memory:get")
  .from(direct())
  // `.transform(body => body)` is body-in / body-out; the exchange
  // itself is frozen in 0.6 (copy-on-write), so `ex.body = ...` from
  // a `.process()` step would throw. Return the new body instead.
  .transform(async (body) => {
    const { subject } = body as { subject: string };
    return await loadMemoryFor(subject);
  });

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are Zoe.",
  blocks: {
    memory: {
      description: "Long-term notes about the operator.",
      mode: "progressive",
      lifetime: "context",
      value: async (exchange, _context, _events, client) => {
        // Read identity from the typed principal, not from a header.
        // `exchange.principal` is the verified, framework-tracked
        // identity (authenticity, expiry, claims); a string header
        // would bypass those guarantees.
        const subject = exchange.principal?.subject;
        if (!subject) return ""; // anonymous: no memory to inject
        const result = await client.forward("memory:get", { subject });
        return result as string;
      },
    },
  },
});

A resolver that needs nothing more than the CraftContext can ignore the client and read from the context directly:

{
  blocks: {
    "tenant-config": {
      mode: "inject",
      lifetime: "context",
      value: (_exchange, context) => {
        const config = context.services.get(TenantConfig);
        return `Tenant: ${config.name}`;
      },
    },
  }
}

1.5 Loader tool naming reservation

Progressive blocks are exposed to the model as synthetic tools named _block_load_<blockName>. Any user tool (fn id, direct route id, or block name) starting with _block_ is rejected at construction or dispatch time with RC5026. Rename the offending tool or block.

1.6 AgentResult: tool-call partitioning and blocksLoaded

Synthetic block-loader invocations no longer appear on AgentResult.toolCalls. They surface on a new AgentResult.blocksLoaded?: AgentBlockLoadSummary[] so post-dispatch assertions on the agent's user-tool usage stay clean. Each entry carries blockName, toolName (the _block_load_<name> form), toolCallId, and either output or error.

Observability follows the same split: loader calls emit route:<id>:agent:block:loaded and :agent:block:error instead of the :agent:tool:* events.

1.7 Defaults merging and removal via false

agentPlugin({ defaultOptions: { blocks } }) lets a context install shared blocks once. The merge rule differs from how tools merges: a per-agent blocks: { ... } does not replace defaults wholesale. Instead, defaults are merged into the final blocks record by name. A per-agent block whose key matches a default replaces only that entry; non-colliding defaults still apply.

To remove a default from a specific agent, set its name to false:

agentPlugin({
  defaultOptions: {
    blocks: {
      "house-style": { mode: "inject", value: "Be terse." },
      safety: { mode: "inject", value: "Refuse harmful requests." },
    },
  },
});

agent({
  model: "anthropic:claude-sonnet-4-6",
  system: "You are a friendly assistant.",
  blocks: {
    // Override "house-style" with a friendlier framing
    "house-style": { mode: "inject", value: "Be warm and helpful." },
    // Drop the "safety" default from this specific agent
    safety: false,
  },
});

A false for a name absent from defaults is a no-op so adding or removing defaults later cannot silently break an agent's block list.

1.8 Multiple agentPlugin installs

Two agentPlugin installs that each set defaultOptions.blocks now merge additively by name (a name set in both installs throws RC5003). This matches the per-agent merge semantics and the mental model that blocks are independent contributions. Other defaultOptions fields (model, tools) still throw on any double-set.

1.9 New error codes

CodeMeaning
RC5025Block resolver threw or returned a non-string. Inject mode aborts the dispatch; progressive mode reports back to the model as a tool error.
RC5026Block name collides with another block, a user tool, or uses the reserved _block_ prefix.
RC5027Block misconfigured: invalid mode, missing description on a progressive block, non-string non-function value, etc.

2. Tools: tag selectors removed, function-form added

The { tagged } and { tagged, from } selector variants on tools() are gone, along with the tags override on directTool({ tags }).

The implicit-extension risk is identical between the deleted tag selector and the new builder form. In both, a future fn registered with a matching tag silently extends the agent's surface. The deletion does not eliminate the risk; it relocates it. The reason this is still worth doing: a declarative selector embedded in framework config ({ tagged: "read-only" }) reads as a static piece of configuration to a reviewer, while a .filter() in user code reads as obviously dynamic. The risk surfaces at the call site where a code review can spot it, instead of being implicit in the framework's interpretation of a tag.

For the cases where enumeration is impractical, tools() now accepts a builder function that receives a ToolsCatalog snapshot:

Before (0.5.x):

agent({
  tools: tools([{ tagged: "read-only" }]),
});

After (0.6.0), explicit (recommended):

agent({
  tools: tools(["fetchOrder", "getCustomer", "listOrders"]),
});

After (0.6.0), programmatic escape hatch:

agent({
  tools: tools((catalog) =>
    catalog.fns
      .filter((f) => f.tags?.includes("read-only"))
      .map((f) => f.name),
  ),
});

The builder receives { fns, routes, mcp }, each a readonly frozen array of { name | id | server+tool, description?, tags? } (entries are deep-frozen so a builder cannot mutate the snapshot). It must return the same ToolsItem[] the array form accepts (strings or { name, guard?, description? } objects). Builder errors are wrapped in RC5003 with the original chained.

2.1 directTool({ tags }) override removed

The tags option on ToolBuilderOverrides was only meaningful for the now-removed tag selectors. directTool(routeId, { description, input }) still works for per-binding overrides.


3. What is new in 0.6.0

For context, no migration required:

  • skills({ source, mode?, lifetime? }) and fromFile(path) builders alongside the new Blocks shape.
  • agent:block:loaded / agent:block:error context events.
  • AgentResult.blocksLoaded.
  • tools((catalog) => [...]) builder form with ToolsCatalog shape.
  • Three new error codes (RC5025, RC5026, RC5027).