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:
Entry shape (AgentRegisteredOptions):
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-ToolSelectiontoolsvalue. - The agent throws at dispatch (
RC5003) when neither the agent nordefaultOptions.modelsupplies 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
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.
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.
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:
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 aBlocksrecord.sourceaccepts a single.mdfile or a directory (flat<name>.mdand nested<name>/SKILL.mdmay coexist). Defaults tomode: "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:
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:
Entry shape (FnOptions):
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 throughctx.defer()is live; see Durable agents. Token-level streaming is live viaagent({ 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 viadirectTool(the LLM-facing tool name becomesdirect__<routeId>), a route imported from another instance throughremotesincluded:Direct(hello)reaches the default remote's route under its bare name,Direct(lab:hello)any other remote's under its qualified one, andRemote(lab)expands at dispatch time to every route imported from that remote;MCP(server:tool)resolves againstMCP_TOOL_REGISTRY(populated bydefineConfig.mcp/mcpPlugin({ clients })), andMCP(server)(or the rawmcp__server__tool/mcp__server/mcp__server__*forms) expands at dispatch time to every tool the named server exposed. The rawmcp__server__toolform 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. Thedescriptionoverride applies only to this binding for fn-style names. MCP references rejectdescription(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:
__ 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:
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. descriptionis 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 withdirectTool(routeId, { description, input })if you need a fundamentally different view. MCP refs rejectdescriptionoutright.- The agent does NOT forward
FnHandlerContext.principalto the MCP server. Principal authenticates the caller into Routecraft; MCPauth(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
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.
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
},
})
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:
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.
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:
Behaviour notes:
- Listener errors are contained. A throw inside
onDeltais caught and logged; the dispatch keeps running and the consolidatedAgentResultstill reaches downstream ops. - Async listeners are awaited. Returning a
PromisefromonDeltaapplies 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:errorcontext event also fires. Failure handling matches the non-streaming path. - Per-agent only. Neither
onDeltanorstreamis part ofdefaultOptions: 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).