agentPlugin

import { agentPlugin } from '@routecraft/ai'

Register named agents in the context store so routes can reference them by name via agent("id"). Registered agents are distinct from route-backed agents: a registration carries its own description because it is not backed by a route; the id is the record key. Duplicate ids across multiple agentPlugin installs throw at context init.

import { agentPlugin, llmPlugin } from '@routecraft/ai'
import type { CraftConfig } from '@routecraft/routecraft'

export const craftConfig: CraftConfig = {
  plugins: [
    llmPlugin({ providers: { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY! } } }),
    agentPlugin({
      agents: {
        summariser: {
          description: 'Summarises documents into bullet points',
          model: 'anthropic:claude-opus-4-7',
          system: 'You are a summariser. Be concise.',
        },
        'translator-en-fr': {
          description: 'Translates English text to French',
          model: 'anthropic:claude-opus-4-7',
          system: 'Translate the input from English to French.',
        },
      },
    }),
  ],
}

Then in any route:

import { agent } from '@routecraft/ai'

craft()
  .id('daily-digest')
  .from(timer({ interval: 24 * 60 * 60 * 1000 }))
  .to(agent('summariser'))
  .to(direct('reply'))

Options:

OptionTypeRequiredDescription
agentsRecord<string, AgentRegisteredOptions>NoAgents keyed by id. Duplicate ids across installs throw at context init. Defaults to {}.
defaultOptionsAgentDefaultOptionsNoDefaults applied to every agent in the context that omits them: model, tools, maxTurns, principal, blocks, and the sampling block (temperature, maxTokens, topP, the penalties, reasoning, providerOptions). A per-agent value wins per key. Two installs setting the same single-valued default throw at context init; blocks is the one field that composes across installs.
toolPolicyAgentToolPolicyNoRepository-wide admission rules for the agent tool surface. Omit for no policy (everything is admitted). Not part of defaultOptions, because an agent must not be able to override it. See tool policy.

Entry shape (AgentRegisteredOptions):

OptionTypeRequiredDescription
descriptionstringYesHuman-readable description. Surfaces in observability and is used as the tool description when the agent is exposed to other agents
modelLlmModelIdNo*"provider:model" string resolved via llmPlugin. Required unless defaultOptions.model supplies a fallback; otherwise dispatch throws RC5003
systemstring | (exchange) => stringYesSystem prompt. Static string or a function that derives it from the exchange (mirrors llm({ system }))
userstring | LlmPromptPart[] | (exchange) => string | LlmPromptPart[]NoUser prompt override. Static string, an array of content parts, or a function deriving either from the exchange. Defaults to exchange.body (string as-is, JSON for objects) when omitted. See content parts
toolsToolSelectionNoTool whitelist built via tools([...]). Inherits defaultOptions.tools when omitted; an explicit value replaces the default entirely
maxTurnsnumberNoCap on tool-calling turns. Inherits defaultOptions.maxTurns when omitted
blocksBlocks (Record<string, BlockBody | false>)NoContributions to the agent's system context, keyed by name. Each block has a mode ("inject" to concatenate into the system prompt as ## <name>\n\n<content>, or "progressive" to surface as a synthetic _block__load__<name> tool the model invokes on demand) and an optional lifetime ("dispatch" re-runs the resolver every call, "context" caches once per CraftContext). Set an entry to false to remove a default inherited from agentPlugin({ defaultOptions: { blocks } }). Use skills({ source }) to load markdown skills. See the blocks reference
principalboolean | (principal, exchange) => stringNoAppend a ## Caller section describing exchange.principal. true for the built-in block, a function to render it yourself. Inherits defaultOptions.principal when omitted; a per-agent value (including false) overrides it. See Telling the agent who the caller is
outputStandardSchemaV1NoSchema for structured output. The agent requests provider-level structured output, validates the response, and parses it onto AgentResult.output
temperature, maxTokens, topP, frequencyPenalty, presencePenaltynumberNoSampling controls for every model call the agent makes. Default to 0 and 1024 for the first two, unset for the rest. Inherit defaultOptions when omitted
reasoning'none' | 'low' | 'medium' | 'high'NoHow much the model should reason per turn, mapped to each provider's own control. Same mapping table as llm(). Inherits defaultOptions.reasoning when omitted
providerOptionsRecord<string, Record<string, unknown>>NoRaw provider settings forwarded to the SDK, winning over reasoning for the settings they name. See Reaching a provider directly

Agents loaded from markdown via agents("./dir") accept the same fields as frontmatter, except for blocks and output. principal is supported in frontmatter as a boolean (principal: true); the function-renderer form is a closure YAML cannot express, so set it via the per-agent override map (agents("./dir", { triage: { principal: (p) => ... } })) or agentPlugin({ defaultOptions }). blocks and output are override-only because YAML can express neither the function-form resolvers a block may carry nor a Standard Schema (a live object with a validate function); supply them via the same override map (agents("./dir", { triage: { blocks: await skills({ source: "./skills" }), output: triageSchema } })).

Resolution semantics:

  • agent("name") resolves only registered agents. Route-backed agents are called via .to(direct("route-id")) and run the full pipeline of the target route; agent("name") runs the registered agent's LLM call inline.
  • The plugin throws at context init (RC5003) on: duplicate ids across installs, empty id key, missing description, malformed model string when present, empty system, or a non-ToolSelection tools value.
  • The agent throws at dispatch (RC5003) when neither the agent nor defaultOptions.model supplies a model.
  • agent("unknown") fails at dispatch (RC5004) with the list of registered agent ids.

See the agent adapter for usage patterns.

Loading agents from markdown (agents())

agents("./agents") walks a directory and returns a Record<name, AgentRegisteredOptions> ready to hand to agentPlugin({ agents }). The walk follows the Claude Code .claude/agents/ convention, so an existing tree loads unmodified.

agents
├── triage.md                   # flat agent, any depth
├── review
│   └── security.md             # grouping folder, walked
└── aria                        # agent bundle
    ├── AGENT.md
    └── skills                  # aria's skills, never agents
        └── refund-policy.md
LayoutRule
Flat file (<anything>.md)One agent, at any depth. Identity is the frontmatter name; the filename and the folders above it are grouping and carry no identity.
Bundle (<name>/AGENT.md)Exactly one agent. The directory is not descended into for further agents, and it can hold assets scoped to that agent. This is the one place the relaxed filename rule does not apply: the frontmatter name must match the bundle directory name.
Reserved (skills/)Never scanned for agents, at any depth under agents/. The folder belongs to the enclosing bundle and is loaded by skills().

node_modules and dot-directories are skipped at every level. A symlink to a file is followed, so a tree assembled by linking shared definitions loads; a symlink to a directory is not, which is what keeps the walk loop-free.

Duplicate name values anywhere in the tree throw RC5003 naming both files. Identity is deliberately strict here: a silently shadowed agent definition surfaces months later as "why is this agent behaving like the other one".

Passing a single .md path loads that one file.

Claude Code compatibility

The loader is deliberately tolerant so an existing agent file works without edits.

FieldAccepted forms
toolsA YAML array (- Direct(cancel-order)) or Claude's comma-separated string (tools: Read, Grep, echo)
disallowedToolsSame two forms. Removes references from this agent's own list
modelA full provider:model reference or one of the aliases opus, sonnet, haiku. inherit is valid only as a scalar. A YAML list may hold references and those three aliases; inherit inside a list is refused
anything elseIgnored with a warning

Unknown keys are never fatal. A file carrying permissionMode, color, hooks or a key a future version of another harness introduces still loads; each ignored key is warned about once. The two Routecraft-owned keys blocks and output are the exception and still throw, because they point at a real mistake: their values cannot be expressed in YAML and belong in the override map.

Model aliases map to pinned references: opus, sonnet and haiku resolve to specific Anthropic model ids rather than "whatever is newest", because an agent whose model changes under it is not reproducible. Write the full provider:model form to pick a different version. inherit leaves the model unset, which is exactly right: the agent then picks up agentPlugin({ defaultOptions: { model } }) at dispatch.

Unimplemented Claude built-ins are skipped, not fatal. A reference to Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch and friends is dropped with a warning when this runtime provides no tool of that name, so a coding agent still boots here with its remaining tools instead of failing the load. The check runs against the live registry, so a fn you register under one of those names resolves normally. A name that is neither registered nor a recognised built-in is still a hard error, because that is a typo or a missing registration rather than a known gap.

disallowedTools applies to the list the agent itself declares. It cannot narrow an inherited agentPlugin({ defaultOptions: { tools } }), because a per-agent list replaces that default outright.

Warning

A deny list needs an allow list

Setting disallowedTools with no tools throws RC5003 at load. There is no reading of that file the runtime can honour: with no per-agent list the agent inherits the context default, so it would receive the very tools its file denies. A field whose whole purpose is removing capability is not something to fail open on, so it fails loudly instead. List the tools the agent may use in tools, or drop disallowedTools. Honouring a deny list against inherited defaults is tracked in #583; that is the Claude-faithful end state, and it needs the deny to apply where defaults are merged at dispatch rather than where the file is parsed.

Offering a choice of model or thinking level

model and reasoning each take a list as well as a single value. The first entry is what the agent runs on, and the whole list is what a person may choose from when they are talking to the agent themselves.

---
name: max
description: The house engineer
model:
  - anthropic:claude-opus-4-6
  - anthropic:claude-sonnet-4-6
reasoning:
  - medium
  - none
  - high
---

You are Max.

A list of one is the same as the scalar: a choice with one option is not a choice, and nothing is advertised for it. inherit is refused inside a list, naming the entry, because a list is the set somebody picks from and "whatever the context defaults to" is not a thing to pick.

Only a surface that has a picker uses these; nothing else in a dispatch changes. A choice that is not on the agent's list is refused with AI1017, so what an agent runs on is still the agent file's decision rather than the caller's.

In code, the same thing is two fields on the registered options rather than a union: models and reasoningLevels beside model and reasoning.

The skills: frontmatter key

An agent file may declare where its skills come from:

---
name: zoe
description: Handles customer conversations
skills:
  - ./skills
  - npm:@devoptixnl/claude-skills/devoptix
---

Entries are local paths (relative to the agent file) or npm: package refs. agents() validates the list and surfaces it verbatim; it does not resolve it. Resolving a ref needs the house skills/ folder and the bundle's own folder, neither of which a bare agents() call is given, so the key is a declaration for the project runtime to consume. When you call agents() yourself, attach skills through the blocks override.

Agent blocks

Blocks are an agent's contribution to its system context, expressed as a Blocks record ({ [name: string]: BlockBody | Blocks | false }) keyed by block name. A value is either a single block (a BlockBody leaf) or a nested Blocks group. A leaf is either always injected into the system prompt (mode: "inject") or surfaced as a synthetic loader tool the model invokes on demand (mode: "progressive", the default for skills). They replace the 0.5 skills field and unify with memory, identity, instructions, and any future system-prompt contribution.

import { agent, skills } from '@routecraft/ai'

agent({
  model: 'anthropic:claude-sonnet-4-6',
  system: 'You are an analyst.',
  blocks: {
    identity: {
      mode: 'inject',
      value: 'You are precise and concise.',
    },
    // A named group keeps every skill under the `skills` namespace
    // instead of dissolving them into the top level.
    skills: await skills({ source: './skills' }),
    'tenant-config': {
      mode: 'inject',
      lifetime: 'context',
      value: (_exchange, context) => {
        const config = context.services.get(TenantConfig)
        return `Tenant: ${config.name}`
      },
    },
  },
})

BlockBody shape:

FieldTypeRequiredDescription
descriptionstringYes*Required when mode === "progressive" so the model can decide whether to load. Ignored for inject blocks.
mode"inject" | "progressive"Yes"inject" concatenates into the system prompt as ## <name>\n\n<content>. "progressive" registers a _block__load__<name> tool the model invokes on demand.
lifetime"dispatch" | "context"NoDefaults to "dispatch" (re-run resolver each call). "context" runs the resolver once per CraftContext and caches the result (cache key is the body's object identity, so concurrent dispatches share one resolution).
valuestring | (exchange, context, events, client) => string | Promise<string>YesStatic string used verbatim, or a function. client.forward(routeId, payload) is the same callable route .error() handlers receive. events is reserved (always []) 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 with AI1002 at every nesting level. An empty-string key is rejected with AI1002.

Nested groups:

A block value may be a nested Blocks record instead of a single body. This keeps a named collection, such as the skills returned by skills({ source }), grouped under one key rather than dissolving into the top-level namespace:

blocks: {
  skills: await skills({ source: './skills' }), // a group of progressive leaves
  tone: { mode: 'inject', value: 'Be terse.' }, // a single leaf
}

Groups flatten depth-first into a single canonical name joined by __. A leaf onboarding under group skills resolves to skills__onboarding for its system-prompt heading (## skills__onboarding), its loader tool (_block__load__skills__onboarding), and its blocksLoaded summary. __ (not /) is used because loader tool names reach the provider unsanitised and must match ^[a-zA-Z0-9_-]{1,64}$. A leaf is distinguished from a group by the presence of a string mode field; any other object value is a group.

These rules are enforced at agent() / agentPlugin() construction, not deferred to dispatch: two blocks that flatten to the same name are rejected with AI1002; a flattened name that lands in the reserved _block_ namespace (including combinations like a group _block with a leaf x resolving to _block__x) is rejected with AI1002; and a progressive block whose flattened loader-tool name would break the provider charset or exceed 64 characters is rejected with AI1003. A blocks tree that contains a cycle is also rejected rather than recursed without bound.

Grouping also isolates collisions: a skill named tone inside the skills group resolves to skills__tone and no longer clashes with a top-level tone block. To remove or replace a whole group, set or override its top-level key (see below); per-member merge inside a group is not supported.

Removing a default:

Set a name to false to drop a default block from a specific agent:

agent({
  ...,
  blocks: {
    // Override the "house-style" default
    'house-style': { mode: 'inject', value: 'Be terse.' },
    // Drop the "safety" default for this agent only
    safety: false,
  },
})

A false for a name not present in defaults is silently ignored, so adding or removing defaults later cannot break agent definitions.

Builders:

  • skills({ source, mode?, lifetime? }) -- loads markdown skills as a Blocks record. source accepts a single .md file or a directory (flat <name>.md and nested <name>/SKILL.md may coexist). Defaults to mode: "progressive".
  • fromFile(path) -- returns a resolver that reads a UTF-8 text file at resolution time.

Loader tools and observability:

Progressive blocks register one synthetic tool per block named _block__load__<blockName> with no input schema. The handler runs the resolver against the dispatch's live exchange and returns the resolved string back to the model. Loader invocations are excluded from AgentResult.toolCalls and surface on AgentResult.blocksLoaded?: AgentBlockLoadSummary[] instead, so post-dispatch user-tool assertions stay clean. On the context bus they emit route:agent:block:loaded and :agent:block:error rather than the :agent:tool:* events.

Defaults merging:

agentPlugin({ defaultOptions: { blocks } }) installs shared blocks for every agent in the context. The merge differs from how tools merges: a per-agent blocks record does not replace defaults wholesale. Defaults are merged in by name, and a per-agent block whose key matches a default replaces only that entry (or removes it when set to false). Non-colliding defaults still apply. This lets a context install identity / memory / tenant blocks once and have individual agents add, replace, or remove entries.

Two agentPlugin installs that each supply defaultOptions.blocks merge additively: each install contributes named entries, but the same name appearing in two installs throws RC5003 so you never silently inherit one over the other.

Errors:

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

Functions (functions)

agentPlugin also registers ad-hoc in-process functions that agents whitelist as tools (follow-up story). Functions are keyed by id in the same plugin config and share the same duplicate-id-throws-at-init semantics as agents.

Functions are an agent-only concept: there is no public dispatch API for fns outside the agent tool loop. If you want to call a "named processor" from a route, write .process(...) inline.

import { agentPlugin } from '@routecraft/ai'
import { z } from 'zod'

agentPlugin({
  functions: {
    CurrentTime: {
      description: 'Current UTC timestamp in ISO 8601',
      input: z.object({}),
      handler: async () => new Date().toISOString(),
    },
    sendSlackMessage: {
      description: 'Post a message to a Slack channel',
      input: z.object({ channel: z.string(), text: z.string() }),
      handler: async (input, ctx) => {
        ctx.logger.info({ channel: input.channel }, 'Posting to Slack')
        return { ok: true }
      },
    },
  },
})

Options:

OptionTypeRequiredDescription
functionsRecord<string, FnOptions>NoFunctions keyed by id. Duplicate ids across installs throw at context init. Defaults to {}.

Entry shape (FnOptions):

OptionTypeRequiredDescription
descriptionstringYesHuman-readable description. Used in observability and as the tool description when exposed to an agent
inputStandardSchemaV1YesStandard Schema for the input (Zod, Valibot, ArkType, etc.). Validated at invocation time
handler(input, ctx) => Promise<TOut> | TOutYesCalled with validated input and a FnHandlerContext ({ logger, abortSignal, context })

Errors at context init (RC5003): missing description, input is not a Standard Schema, input's validate is not a function, missing handler, empty id key, duplicate id across installs.

Testing fns

There is no public invokeFn helper. Agents are the only legitimate dispatcher for registered fns. To exercise a fn's input schema and handler in isolation in tests, use testFn from @routecraft/testing:

import { testFn } from '@routecraft/testing'
import { z } from 'zod'

const greetInput = z.object({ name: z.string() })

const greet = {
  description: 'Greets someone',
  input: greetInput,
  handler: async (input: z.infer<typeof greetInput>) => `hello ${input.name}`,
}

const out = await testFn(greet, { name: 'alice' })
// out === 'hello alice'

testFn validates the input against the input schema, calls the handler with a synthetic { logger, abortSignal, defer } context, and returns the handler's output. Validation failures throw RC5002. It works structurally on any { input, handler } shape, so real FnOptions values pass without modification.

Agent tools

Status: live. Tools an agent declares via tools([...]) are bridged into the Vercel AI SDK's tool-calling loop at dispatch time. The model sees each tool's name, description, and JSON schema; the SDK validates tool-call arguments against the schema, reports validation errors back to the model for self-correction, and otherwise invokes the agent's handler. Durable deferral and resume through ctx.defer() is live; see Durable agents. Token-level streaming is live via agent({ stream: true }); see Streaming the reply.

Tags, the tools([...]) selector, the builder helpers, and the context-level defaultOptions bag compose to give an agent a typed, whitelisted set of capabilities.

import { agentPlugin, agent, directTool, tools } from '@routecraft/ai'
import { z } from 'zod'

agentPlugin({
  functions: {
    CurrentTime: {                                  // an inline fn, written in full
      description: 'Current date and time in ISO 8601.',
      input: z.object({}),
      handler: () => new Date().toISOString(),
      tags: ['read-only', 'idempotent'],
    },
    sendSlack: { description, input, handler, tags: ['destructive', 'messaging'] },
    fetchOrder: directTool('fetch-order'),          // wraps a direct route as a fn
  },
  agents: {
    researcher: {
      description, system,                          // model + tools inherit from defaultOptions
      tools: tools([
        'CurrentTime',                              // bare ref
        'fetchOrder',
        'Direct(cancel-order)',                     // direct route
        { name: 'sendSlack', guard: requireApproval },
      ]),
    },
  },
  defaultOptions: {
    model: 'anthropic:claude-opus-4-7',             // applies to agents that omit `model`
    tools: tools(['CurrentTime', 'fetchOrder']),
  },
})

tools(items) -- array form

Flat array of items. Each item is one of:

  • Bare string: name lookup. Plain ids resolve against the fn registry; Direct(<routeId>) wraps a direct route via directTool (the LLM-facing tool name becomes direct__<routeId>), a route imported from another instance through remotes included: Direct(hello) reaches the default remote's route under its bare name, Direct(lab:hello) any other remote's under its qualified one, and Remote(lab) expands at dispatch time to every route imported from that remote; MCP(server:tool) resolves against MCP_TOOL_REGISTRY (populated by defineConfig.mcp / mcpPlugin({ clients })), and MCP(server) (or the raw mcp__server__tool / mcp__server / mcp__server__* forms) expands at dispatch time to every tool the named server exposed. The raw mcp__server__tool form is the string Claude Code agent files carry, so they resolve unchanged, provided the server segment is unambiguous: the form is split at the first __ after the prefix, so a client whose own name is empty, carries a __, or ends in _ would misresolve. mcpPlugin({ clients }) rejects such a name at startup, which is what makes the guarantee hold for any client it registered; see client names.
  • { name, guard?, description? }: same name lookup, with optional per-binding overrides. The guard runs after schema validation and before the handler; throwing surfaces back to the LLM as a tool error so the model can self-correct. The description override applies only to this binding for fn-style names. MCP references reject description (the MCP server is the source of truth for description and schema; do not override).

Authoring grammar vs wire names

Direct(<routeId>) and MCP(server:tool) are the grammar you write, here and in markdown agent frontmatter. They are not what the model sees. Tool names cannot carry parentheses or colons, so resolution normalises each reference to a wire name:

KindYou writeThe model sees
fnfetchOrderfetchOrder
capabilityDirect(cancel-order)direct__cancel-order
imported capability, qualifiedDirect(lab:hello), or any Remote(lab) expansionremote__lab__hello
MCP client toolMCP(github:create_issue)mcp__github__create_issue
block loader(declared via blocks)_block__load__<name>

__ is the only structural separator, which is what keeps a single underscore inside a segment unambiguous: a server named my_company_api and a route named fetch_order both survive the prefix boundary intact.

An MCP client name may not be empty, contain __, or end in _, which mcpPlugin({ clients }) rejects with RC5003 at startup; see client names for why. Constraining the server half is what leaves the tool half free, so a remote may use __ in its own tool names and mcp__github__issues__create resolves correctly.

Direct(<routeId>) needs no equivalent rule. A direct wire name carries exactly one separator by construction, and everything after it is the route id, so there is nothing to disambiguate. A qualified imported route is the one case with two: remote__lab__hello splits at the first separator into the remote and the id, and a remote's name is constrained at configuration exactly as an MCP client's is (no __, no trailing _), which is what leaves the id half free. The default remote's routes referenced bare keep the direct__ form, because to the agent they are local.

Disabled routes are not offered

A route held back by its .enabled() predicate is absent from ctx.capabilities(), and so from the tool surface built on top of it. The model is never shown a capability it cannot use, which is what makes "the agent must not call this until I supply credentials" true by construction rather than by the model behaving well. Re-enable the route and it is offered again, with no restart.

Wire names must match /^[A-Za-z0-9_-]{1,64}$/, the charset every mainstream provider enforces. Route ids are deliberately not constrained that way, so Direct(memory:get) is rejected at resolution rather than encoded into something the model has to read. Expose such a route under a tool-safe alias instead:

agentPlugin({
  functions: {
    memoryGet: directTool('memory:get'), // clean name, same capability
  },
})

Fn ids reach the provider verbatim, with no prefix, so the same constraint applies to them and is checked when the plugin registers.

Examples:

agent({
  tools: tools([
    'CurrentTime',                                  // fn
    'Direct(orders-fetch)',                         // direct capability
    'MCP(Nuclino:list_teams)',                      // one MCP tool
    'MCP(Stripe)',                                  // all tools from one MCP server
    {
      name: 'MCP(Nuclino:get_item)',
      guard: (input, ctx) => {
        if (!ctx.principal?.scopes?.includes('nuclino.read')) {
          throw new Error('missing nuclino.read scope');
        }
      },
    },
  ]),
});

tools((catalog) => items) -- builder form

Programmatic escape hatch when explicit enumeration is impractical. The builder receives a ToolsCatalog snapshot of the live registries and returns the same shape the array form accepts.

agent({
  tools: tools((catalog) => [
    // Explicit, reviewed at call site
    'fetchOrder',
    'Direct(escalate)',

    // Dynamic, user-controlled
    ...catalog.fns
      .filter((f) => f.tags?.includes('read-only'))
      .map((f) => f.name),
  ]),
});

ToolsCatalog shape:

FieldTypeDescription
fnsReadonlyArray<{ name; description?; tags? }>Fns from agentPlugin({ functions }). Deferred wrappers (directTool) appear by name only; filter on their underlying routes via catalog.routes if you need tag-based selection of routes.
routesReadonlyArray<{ id; description?; tags? }>Direct routes from ADAPTER_DIRECT_REGISTRY. Reference via "Direct(<id>)" in the returned items.
mcpReadonlyArray<{ server; tool; description?; tags? }>MCP tools populated by mcpPlugin({ clients }). Reference via "MCP(<server>:<tool>)" or "mcp__<server>__<tool>".

The builder runs once per agent dispatch (same lifecycle as the array resolver). Builder errors are wrapped in RC5003 with the original chained. The framework ships no helpers on ToolsCatalog: any filter is user code, and .filter() at the call site is an obvious signal that the set is dynamic (vs the declarative tag selectors removed in 0.6, which were a security footgun because they implicitly extended an agent's surface when new tagged fns were registered).

Resolution rules:

  • Final list deduplicated by tool name (later refs win).
  • A directTool(routeId) fn-registry wrapper and the underlying direct route share the same surface; reference via the fn id you registered.
  • description is the only override permitted at the use site, and only on the explicit { name } form for fn-style names. Input schema, tags, and any other registration-time fields are not overridable here. Register a separate fn with directTool(routeId, { description, input }) if you need a fundamentally different view. MCP refs reject description outright.
  • The agent does NOT forward FnHandlerContext.principal to the MCP server. Principal authenticates the caller into Routecraft; MCP auth (configured on the client) authenticates the Routecraft → MCP hop. To thread user-specific data into an MCP call, put it in the tool's input as a regular argument and let the MCP server enforce its own policy. See .standards/security.md §11.

Builders

BuilderUse
directTool(routeId, overrides?)Adapt a registered direct route as a fn. Pulls description, input schema, and tags from the route's discovery bundle by default; overrides accepts description and input to replace either of those (tags pass through unchanged), and background: true to return a handle now and deliver the result later (see Background tools).

directTool is the only fn builder the framework ships. A tool that just wraps a line of JavaScript, such as a clock or a UUID generator, is written inline in functions: as shown above: the declaration costs the same as naming a factory, and it stays yours to change.

MCP tools are NOT exposed via a builder. Use the MCP(server:tool) / MCP(server) grammar (or the raw mcp__server__tool form) inside tools([...]) instead; the registry populated by defineConfig.mcp is the source of truth.

When to hand an agent a raw MCP(...) tool versus a wrapped Direct(...) route -- and why a guard cannot stand in for the wrap -- is covered in Calling an MCP.

Background tools

A build or a test run takes minutes, and a tool call holds the agent's turn until its route returns, so the person talking to the agent waits too. directTool(routeId, { background: true }) changes how the agent awaits the route, and nothing about the route:

agentPlugin({
  functions: {
    sandboxRun: directTool('sandbox-run', { background: true }),
  },
  agents: {
    max: {
      description: 'Max, the development agent',
      system: readFileSync('agents/max.md', 'utf-8'),
      maxTurns: 200,
      tools: tools(['sandboxRun', 'Direct(github-create-pr)']),
    },
  },
})

The call dispatches the route as usual and returns { handle, status: 'running' } at once, where handle is <routeId>:<dispatchId>, stable across restarts and written on the dispatched exchange's headers as routecraft.agent.background.handle (AgentHeadersKeys.BACKGROUND_HANDLE) so an operator can find the run. When the route finishes, its result, or its failure as a message carrying the error code and text, is posted to the calling session's inbox attributed to the handle. The completion starts the next turn on its own. A turn that ends with a background call still running stores its exchange's continuation (the body and headers, as a .defer() deferral stores them, at the agent step); when the call settles, that continuation is revived on the running process, the agent step runs a turn whose user message is the inbox (the completion, plus anything that queued meanwhile), and the route's downstream steps run on its reply. So a build finishing is what tells the agent to open the pull request, with nobody sending a message. If the route finishes while the calling turn is still running, the turn's own boundary delivers it instead. The description the model sees says the tool is asynchronous, so it does not wait on the return value.

The result lands in a session, so the flag needs one: an agent dispatched without session refuses a background tool when its tool list is resolved (RC5003 naming the tool), before the model can call it. A restart while the route runs loses the dispatch the way it loses any in-flight exchange. At the next boot, every session with a stored continuation has the calls its dead process was waiting on reported lost, attributed to their handles, and the continuation revived, so the loss reaches the agent as a turn and it can start the run again; a session whose turn died before it could store a continuation is restored by its next message instead. Nothing is dropped silently.

A downstream deferral is not a completed action. Ordinary tool calls that receive a Deferred acknowledgment expose only { status: 'deferred' } to the model, session thread and tool-result telemetry. The application owns that pending approval; the calling agent does not automatically receive its eventual result.

A background tool whose route defers settles its result-delivery handle as failed with AI1006. Its resume token never enters the session inbox. This failure does not cancel the downstream action: it remains pending in the application's approval flow. Do not retry it automatically, since that would create another pending action. Durable delivery of execution two's result to a background handle is not supported yet.

Not here yet: cancelling a background call from the agent, progress from a running route, and a join over several calls.

Tags

Apply with .tag(value | values[]) on routes and tags?: Tag[] on FnOptions. Empty strings are rejected; surrounding whitespace is trimmed at storage so exact comparisons match.

KnownTag (a literal-suggested type) covers the framework's well-known tags:

type KnownTag = 'read-only' | 'destructive' | 'idempotent';

Any user string is also accepted; the KnownTag literals just power autocomplete.

Tags are exposed on ToolsCatalog entries so the builder form of tools() can filter on them. They do not drive any framework-level selector (the { tagged } variant on tools() was removed in 0.6.0); the security boundary belongs at the agent's call site, not in implicit registry queries.

Context-level defaultOptions

Mirrors the llmPlugin({ defaultOptions }) pattern: a single bag of values applied to any agent that omits the corresponding field.

FieldTypeInherited by
defaultOptions.modelLlmModelId (string)Agents that omit model
defaultOptions.toolsToolSelection (from tools([...]))Agents that omit tools
defaultOptions.maxTurnsnumberAgents that omit maxTurns
defaultOptions.principalboolean | (principal, exchange) => stringAgents that omit principal
defaultOptions.blocks{ [name: string]: BlockBody | Blocks }All agents (merged by name into per-agent blocks; see Agent blocks). A default may be a nested group; a false removal sentinel at any nesting level is rejected with RC5003 (defaults cannot remove themselves).

Resolution at dispatch is per-key: instance value > plugin default > (for model) throw, (for tools) undefined. Agents that set model, tools, maxTurns, or principal replace the default entirely (override, not extend). Per-agent blocks merges into defaults by name (see the Defaults merging note in the blocks section).

For model / tools / maxTurns / principal, two agentPlugin installs that each set the same field throw at context init. blocks merges additively across installs by name; a name set in two installs throws.

agentPlugin({
  defaultOptions: {
    model: 'anthropic:claude-opus-4-7',
    tools: tools(['CurrentTime', 'fetchOrder']),
  },
  agents: {
    researcher: { description, system },                            // inherits both
    fast:       { description, model: 'anthropic:claude-haiku-4-5', system },
  },
})

Tool policy

toolPolicy sets repository-wide rules for which tools an agent may be given, independent of what any individual agent asks for.

Omitting toolPolicy changes nothing: every tool is admitted, exactly as before. Supplying it makes the tool surface an allowlist. That asymmetry is deliberate: it is the only shape that leaves existing contexts untouched while failing closed for anyone who opts in.

Once you supply a policy, every kind is required. Writing only the line you care about does not compile:

agentPlugin({ toolPolicy: { mcp: false } }) // Error: missing 'fn', 'direct'

That is on purpose. An optional key would read the way ? reads everywhere else in TypeScript, "omit it and get the default", when the effective default here is denial. The partial form above would otherwise strip every fn and every capability from every agent in the repository, with no diff signal and nothing to notice but a warn line per dropped tool. It also means a future release adding a fourth tool kind breaks your build rather than silently narrowing a policy you already deployed.

agentPlugin({
  agents: await agents('./agents'),
  toolPolicy: {
    fn: true,
    direct: true,
    mcp: false, // client MCPs reach agents only by wrapping one in a capability
  },
})
KeyGoverns
fnIn-process fns registered via agentPlugin({ functions })
directCapabilities in the capability registry, reached via Direct(<routeId>), Remote(<name>) or a directTool alias. A capability imported from another instance carries source.remote ('default', 'lab', absent for a local route), so direct: (tool) => tool.source.remote === undefined keeps an agent local-only
mcpTools discovered from external MCP clients

Each value is true, false, or a predicate:

agentPlugin({
  toolPolicy: {
    fn: true,
    direct: (tool) => !tool.tags.includes('experimental'),
    mcp: (tool) => tool.source.server === 'docs',
  },
})

The predicate receives a read-only descriptor (name, description, tags, source). source is narrowed to the kind whose rule is running, so an mcp rule reads tool.source.server directly with no kind guard. The descriptor does not carry the handler, so a rule cannot wrap or invoke what it is deciding about, and it is a frozen copy, so a rule cannot mutate registry state through it.

A second argument carries agentId (the registered agent's name, or undefined for an inline agent). It is for diagnostics only. Do not branch on it. Inline agents have no id, so an identity-keyed rule has no defensible behaviour for them: denying breaks every inline agent, allowing creates a trivial bypass. If you want per-agent policy, the missing ingredient is provenance carried through agent registration, not this field.

Why direct and not route. Only routes that register a capability are reachable as agent tools. A route sourced solely from http() or mcp() never registers one, so Direct(...) cannot resolve it. Naming the key route would imply governance over a set the policy cannot see.

Enforcement is total. The check runs at the single point every agent form converges on, so inline agents, registered agents, markdown agents, and nested agents dispatched from inside a route are all covered. An agent's own tools([...]) selection cannot widen what the policy admits.

Denial drops, logs, and emits; it never throws. A denied tool is removed from the agent's list, a warning names the agent, the tool, and the kind, and a route:agent:tool:denied event carries the same on the context bus with a reason of rule, rule-error, or unknown-provenance. The log is for someone reading text; the event is what you alert and audit on. A silent drop would be undiagnosable when a model starts insisting it cannot do something; a throw would turn tightening a policy into an outage.

A predicate that throws denies its tool rather than propagating. The throw is reported at error level with its cause, and the routine denial warning is suppressed so one failure produces one line. A policy is meant to fail closed and a denial is meant never to abort a dispatch; letting the throw escape would do the opposite of both, turning one bad predicate into an outage for every agent listing a tool of that kind.

Multiple installs compose with AND. A tool is admitted only when every installed policy admits it, so adding a plugin can only narrow the surface.

Block loader tools are not governed. _block__load__* tools assemble context rather than granting reach: skills() sets a static body and fromFile() returns a file's contents, both inert.

The exception to keep in mind is BlockClient.forward. A block resolver can dispatch to any registered capability, and that call does not pass through toolPolicy: loader tools are built after policy filtering, and forward dispatches in your code rather than through the agent's tool list. toolPolicy governs what the model may call, not what a resolver you wrote may reach. A capability reached that way is still subject to the target route's .authorize() and any guards on it, which remain the enforcement points.

Raw MCP annotations, not just tags

For mcp rules, tool.source.annotations carries the remote's hints verbatim. Prefer it over tags when the distinction matters, because tag derivation only fires on a truthy hint and therefore cannot tell "the server declared this safe" from "the server said nothing". The MCP specification assigns per-hint defaults for an absent hint, and destructiveHint defaults to true. A tags-only rule reads silence as "not destructive", inverting the safe reading on the hint where being wrong costs most.

Prefer allowlist form across trust boundaries. A denylist predicate (server !== 'untrusted') silently admits whatever a remote adds at its next refresh. Within your own repository, where fns and capabilities are under code review, denylist refinement is reasonable.

This is admission control, not a security boundary

It is a filter against accidental exposure. Its value is converting a failure of omission (a tool name appearing in markdown frontmatter, with no diff signal and nothing to notice) into a failure of commission (someone must author a capability, name it, and write an authorization line a reviewer can read).

It does not stop a developer who deliberately wraps a client tool in a capability, and it is not a substitute for reviewing agent files. Treat it as one layer alongside .authorize() and tool guards, which remain the actual enforcement points.

Soft dependency on llmPlugin

Agent model references use the "providerId:modelName" format and resolve against the LLM provider registry populated by llmPlugin. You must install llmPlugin with the relevant providers. This is intentional: provider credentials live in one place, and agents reference them by id. There is no inline-credentials escape hatch on agent({...}); centralised wiring via llmPlugin is the only path.

Turn cap (maxTurns)

The Vercel AI SDK's tool-calling loop runs until the model returns a final text response or a stop condition fires. Each iteration is one turn (one model call plus the resulting tool calls / results). The agent caps turn count to 20 by default; override per agent via maxTurns: or context-wide via defaultOptions.maxTurns. When the cap fires the SDK returns whatever text the model produced last; downstream logic should treat truncated output as a possible outcome. The budget survives a durable deferral: a resumed run continues with the turns it had already spent (see Durable agents).

Human-in-the-loop: blocking or durable

Two ways for a handler to wait on a human, chosen by how long the wait is:

Tool wait timeThe right shape
Under a minuteA blocking await is fine. HTTP timeouts and restart risk are low.
1 to 10 minutesBlocking works on most platforms. Acceptable for "ask user, get reply during a meeting" flows.
Longer than thatDefer the run durably with ctx.defer(): the loop survives restarts and continues when the resume arrives through any ingress. See Durable agents.

A blocking tool handler:

{
  description: "Ask a human for approval via email; wait up to 15 min.",
  input: z.object({ question: z.string() }),
  handler: async (input) => {
    return await pollUntilReply(input.question, { timeoutMs: 15 * 60 * 1000 })
  },
}

The durable form replaces the blocking await with return ctx.defer({ schema: Approval, ttl: "72h" }) and consumes the payload when the run resumes; ctx.deferralId and ctx.deferral.token are populated before the handler runs, so the approval request can carry a working resume link. DeferError remains available as a throw-form escape hatch, with a documented footgun: a handler that wraps its work in try/catch silently swallows a thrown deferral. Both require the exchange to be route-bound and, at deferral time, a deferral block on the context.

Observability: three channels

Agents emit on three distinct channels with different shapes and use cases:

1. Context bus (ctx.on('route:agent:started', ...) and friends): coarse decision events. Broadcast to every subscriber. Use for telemetry, dashboards, audit trails, TUIs. Always emitted; no opt-in needed.

EventFieldsWhen
route:agent:tool:invokedsession?, toolCallId, toolName, _snapshot.inputAgent decided to call a tool.
route:agent:tool:resultsession?, toolCallId, toolName, _snapshot.output, durationTool handler returned successfully.
route:agent:tool:errorsession?, toolCallId, toolName, errorName, _snapshot.error, durationTool handler / guard / input validation threw.
route:agent:finishedagentName?, model, finishReason, inputTokens?, outputTokens?, totalTokens?Agent dispatch returned a consolidated result.
route:agent:erroragentName?, model, errorProvider / transport error during dispatch.

All events also carry routeId, exchangeId, correlationId. Narrow to one route with forRoute(routeId, handler) or any payload predicate.

ctx.on("route:agent:tool:invoked", ({ details }) => {
  console.log(`[${details.routeId}] tool ${details.toolName} called with`, details._snapshot.input);
});

ctx.on("route:agent:finished", ({ details }) => {
  metrics.increment("agent.calls.total", { route: details.routeId });
  metrics.histogram("agent.tokens.total", details.totalTokens ?? 0);
});

2. stream: true (per-dispatch, opt-in): the dispatch produces the deltas instead of the consolidated AgentResult. This is the shortest path from a prompt to a token-by-token reply, because an AsyncIterable body is exactly what the http source frames as SSE:

craft()
  .id("chat-stream")
  .input({ body: ChatInput })
  .from(http({ path: "/chat/stream", method: "POST" }))
  .to(
    agent({
      system: aria,
      user: (ex) => (ex.body as z.infer<typeof ChatInput>).message,
      stream: true,
    }),
  );

The route stays declarative: the queue and the abort that closes it live inside the adapter. Iterating the stream is what drives the run, and abandoning it aborts that run, so a client that disconnects mid-answer stops the model rather than paying for the rest of an answer nobody will read. An author who wants custom SSE event names maps the stream in one ordinary .transform() step; hand-rolling a queue and a wake loop in a transform is not the way to get there.

The trade is the result. text, output, usage and toolCalls are consolidated when a run finishes, and a stream is handed over before that. A route that needs both sends the stream to its reader and takes the completed record from the coarse route:agent:* events. Under .enrich() the stream merges like any other enrichment value.

3. onDelta callback (per-dispatch, opt-in): the push form of the same thing. Token-level deltas, directed delivery, back-pressure-aware. Use it when the sink is something other than the route's own output. Setting both stream and onDelta is refused at construction.

agent({
  model: "openai:gpt-4o",
  system: "Be helpful.",
  tools: tools(["search"]),
  onDelta: (delta) => {
    sse.send({ data: delta.text, type: delta.type });
  },
})

Either spelling switches dispatch from generateText to streamText. With onDelta the destination still returns a consolidated AgentResult once the stream drains, so downstream pipeline ops are unaffected; with stream: true the deltas are the product.

AgentDelta is a narrow discriminated union:

TypeFieldsWhen
text-deltatextEach token (or token chunk) emitted by the model.
reasoning-deltatextProvider reasoning text (Anthropic extended thinking, OpenAI o1). Useful for "thinking..." UI.

Behaviour notes:

  • Listener errors are contained. A throw inside onDelta is caught and logged; the dispatch keeps running and the consolidated AgentResult still reaches downstream ops.
  • Async listeners are awaited. Returning a Promise from onDelta applies back-pressure to the stream, which is what you want when forwarding to a slow consumer (database, remote SSE channel).
  • Stream errors still throw. Provider errors propagate out of the dispatch promise; the agent:error context event also fires. Failure handling matches the non-streaming path.
  • Per-agent only. Neither onDelta nor stream is part of defaultOptions: delta sinks are request-scoped, and whether a route's output is a stream is intrinsic to that route.

For named agents that share a definition across requests, accept onDelta at the call site:

.to(agent("summariser", { onDelta: (d) => sse.send(d.text) }))

The 90% use case is an HTTP SSE response updating a UI as the model writes, which stream: true serves directly. For everything else (per-tool observability, finish reasons, total usage, errors) use the context bus.

Asserting on agent behaviour (AgentResult.toolCalls)

For programmatic assertions ("the agent must have replied via replyEmail, otherwise escalate"), inspect AgentResult.toolCalls in a downstream .process() step. The list pairs each tool call with its return value or thrown error in invocation order; combine with step-scope .error() for fallback routing:

craft()
  .id("inbox-bot")
  .from(mail("INBOX", { account: "support" }))
  .to(agent({
    system: "Reply to the customer via replyEmail. If you cannot answer, leave it unanswered.",
    tools: tools(["replyEmail"]),
  }))
  .error((err, ex, forward) => {
    // Agent did not reply via tool; escalate to a human inbox
    return forward("escalate-to-human", ex.body);
  })
  .process((ex) => {
    const replied = ex.body.toolCalls?.some(
      (c) => c.toolName === "replyEmail" && !c.error,
    );
    if (!replied) throw new Error("Agent finished without sending a reply");
    // .process() returns the exchange, not the body
    return ex;
  })

The context bus events (route:agent:tool:*) are the live observation channel for the same calls; toolCalls on the result is the synchronous post-hoc view a pipeline step can branch on. Use the bus for telemetry / dashboards / TUIs; use toolCalls for assertions and routing.

Hard-coded assertions like the one above cover mechanical requirements ("this tool must have been called"). To judge whether the agent achieved the outcome the request asked for, pair toolCalls with a second model call: see Judging Agent Results.

Typed fn ids (FnRegistry)

For compile-time autocomplete of fn ids in the agent tools: [...] field (follow-up story), populate the FnRegistry marker interface via declaration merging in your project:

// src/types/routecraft.d.ts
declare module '@routecraft/ai' {
  interface FnRegistry {
    CurrentTime: true
    sendSlackMessage: true
  }
}

When FnRegistry is empty, the id type falls back to string (no breaking change).