Reference
Plugins
Full catalog of built-in plugins with options and behaviour.
Plugin overview
| Plugin | Package | Description |
|---|---|---|
llmPlugin | @routecraft/ai | Register LLM providers for use with llm() |
embeddingPlugin | @routecraft/ai | Register embedding providers for use with embedding() |
mcpPlugin | @routecraft/ai | Start an MCP server and register remote MCP clients |
agentPlugin | @routecraft/ai | Register named agents for use with agent("id") |
Core adapter defaults (cron, direct) are set via dedicated fields on CraftConfig, not via plugins. See Configuration and Merged Options.
First-class config keys
Importing @routecraft/ai augments CraftConfig with first-class keys for the AI plugins. Setting llm, mcp, embedding, or agent on the config is equivalent to pushing the corresponding plugin onto plugins: []. Lifecycle (apply, teardown, plugin events) is identical.
// Before (still supported -- use this for shared plugin instances or programmatic composition)
import { defineConfig } from '@routecraft/routecraft'
import { llmPlugin, mcpPlugin } from '@routecraft/ai'
export const craftConfig = defineConfig({
plugins: [
llmPlugin({ providers: { openai: { apiKey: '...' } } }),
mcpPlugin({ clients: { /* ... */ } }),
],
})
// After (recommended for declarative configs)
import { defineConfig } from '@routecraft/routecraft'
import '@routecraft/ai' // augments CraftConfig
export const craftConfig = defineConfig({
llm: { providers: { openai: { apiKey: '...' } } },
mcp: { clients: { /* ... */ } },
})
The factories listed below remain available unchanged. Use them via plugins: [] when you need to instantiate a plugin once and reuse it (e.g. across multiple contexts) or compose plugins programmatically.
llmPlugin
import { llmPlugin } from '@routecraft/ai'
Registers LLM provider credentials in the context store. Required when any capability uses llm(). Configure once; all llm() calls in the context share it.
import { llmPlugin } from '@routecraft/ai'
import type { CraftConfig } from '@routecraft/routecraft'
const config: CraftConfig = {
plugins: [
llmPlugin({
providers: {
anthropic: { apiKey: process.env.ANTHROPIC_API_KEY },
openai: { apiKey: process.env.OPENAI_API_KEY },
},
}),
],
}
export default config
Options:
| Option | Type | Required | Description |
|---|---|---|---|
providers | LlmPluginProviders | Yes | Provider credentials (at least one required) |
defaultOptions | Partial<LlmOptions> | No | Default options applied to all llm() calls |
Providers:
| Provider | Options | Description |
|---|---|---|
openai | { apiKey: string, baseURL?: string } | OpenAI API |
anthropic | { apiKey: string } | Anthropic API |
openrouter | { apiKey: string, modelId?: string } | OpenRouter API |
ollama | { baseURL?: string, modelId?: string } | Local Ollama instance |
gemini | { apiKey: string } | Google Gemini API |
See llm adapter for usage.
embeddingPlugin
import { embeddingPlugin } from '@routecraft/ai'
Registers embedding provider credentials in the context store. Required when any capability uses embedding(). Runs a teardown on context stop to release native ONNX resources (used by the huggingface provider).
import { embeddingPlugin } from '@routecraft/ai'
import type { CraftConfig } from '@routecraft/routecraft'
const config: CraftConfig = {
plugins: [
embeddingPlugin({
providers: {
openai: { apiKey: process.env.OPENAI_API_KEY },
},
}),
],
}
export default config
Options:
| Option | Type | Required | Description |
|---|---|---|---|
providers | EmbeddingPluginProviders | Yes | Provider credentials (at least one required) |
defaultOptions | Partial<EmbeddingOptions> | No | Default options applied to all embedding() calls |
Providers:
| Provider | Options | Description |
|---|---|---|
huggingface | {} | Local ONNX inference, no API key required |
ollama | { baseURL?: string } | Local Ollama instance |
openai | { apiKey: string, baseURL?: string } | OpenAI embeddings API |
mock | {} | Deterministic test vectors, for use in tests |
See embedding adapter for usage.
mcpPlugin
import { mcpPlugin } from '@routecraft/ai'
Starts an MCP server so capabilities exposed with .from(mcp(...)) are reachable by external MCP clients. Also registers named remote MCP clients (HTTP or stdio subprocess) so capabilities can call external MCP servers by a short server id. Required when any capability uses mcp() as a source.
Tools discovered from remote MCP servers (stdio clients and HTTP clients) are collected into an McpToolRegistry stored in the context store under MCP_TOOL_REGISTRY. Local mcp() routes defined in the same context are not auto-populated into this registry; the MCP server reads them directly from the direct-adapter registry when responding to tools/list.
import { mcpPlugin, jwt } from '@routecraft/ai'
import type { CraftConfig } from '@routecraft/routecraft'
const config: CraftConfig = {
plugins: [
mcpPlugin({
transport: 'http',
port: 3001,
auth: jwt({ secret: process.env.JWT_SECRET! }),
clients: {
browser: {
url: 'http://127.0.0.1:8089/mcp',
auth: { token: process.env.BROWSER_MCP_TOKEN! },
},
search: { url: 'http://127.0.0.1:8090/mcp' },
filesystem: {
transport: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
},
},
maxRestarts: 5,
restartDelayMs: 1000,
restartBackoffMultiplier: 2,
}),
],
}
export default config
Options:
| Option | Type | Default | Description |
|---|---|---|---|
name | string | 'routecraft' | Server name exposed in MCP metadata (serverInfo.name) |
title | string | -- | Human-readable display title (serverInfo.title) |
version | string | '1.0.0' | Server version |
description | string | 'Powered by Routecraft.dev' | serverInfo.description; pass '' to omit |
websiteUrl | string | 'https://routecraft.dev' | serverInfo.websiteUrl; pass '' to omit |
instructions | string | -- | Server-wide usage guidance on the initialize result; pass '' (or omit) to send none |
icons | McpIcon[] | Routecraft logo | serverInfo.icons, inherited by tools that set none of their own; pass [] to omit. See Server identity and branding. |
transport | 'http' | 'stdio' | 'stdio' | Transport protocol for the MCP server |
port | number | 3001 | HTTP port (http transport only) |
host | string | 'localhost' | HTTP host (http transport only) |
auth | McpHttpAuthOptions | -- | Auth for the HTTP endpoint (http transport only; see below) |
cors | false | McpCorsOptions | loopback-only | CORS for the HTTP transport. Default reflects loopback Origin headers; set to false to disable or { origin } to allowlist production browser clients. See Expose as MCP -> CORS. |
tools | string[] | (meta) => boolean | -- | Allowlist of tool names to expose, or a filter function |
clients | Record<string, McpClientHttpConfig | McpClientStdioConfig> | -- | Named remote MCP servers (see below) |
maxRestarts | number | 5 | Max automatic restarts for stdio clients before giving up |
restartDelayMs | number | 1000 | Initial delay before first restart attempt (ms) |
restartBackoffMultiplier | number | 2 | Multiplier applied to delay on each successive restart |
toolRefreshIntervalMs | number | 60000 | Polling interval for HTTP client tool lists (0 = no polling) |
Logging when transport is 'stdio':
The stdio transport uses stdout as the protocol channel. Routecraft's logger defaults to stdout, so logs will corrupt the protocol stream unless you redirect them. When running an MCP server over stdio, always pass one of:
--log-file <path>-- write logs to a file--log-level silent-- disable logging entirely
HTTP server auth (McpHttpAuthOptions):
When auth is set and transport is 'http', every request to /mcp must include a valid Authorization: Bearer <token> header. The auth object requires a validator function that receives the raw bearer token and returns an AuthPrincipal on success or null to reject. The principal is made available on exchange headers so routes can read the caller's identity.
| Field | Type | Description |
|---|---|---|
validator | (token: string) => AuthPrincipal | null | Promise<AuthPrincipal | null> | Validates the bearer token and returns the caller's identity, or null to reject with 401. |
AuthPrincipal:
AuthPrincipal is a discriminated union on the kind field. Every subtype carries kind, scheme, and subject; other fields live on the subtype that gives them meaning. Narrow on kind to reach scheme-specific data.
Shared fields on every subtype:
| Field | Type | Required | Description |
|---|---|---|---|
kind | 'jwt' | 'oauth' | 'api-key' | 'basic' | 'custom' | Yes | Discriminator for the principal subtype |
scheme | string | Yes | HTTP authentication scheme ('bearer', 'basic', 'api-key') |
subject | string | Yes | Stable identity for the caller (JWT sub, user ID, key ID) |
Subtypes:
kind | Additional fields |
|---|---|
'jwt' | name?, email?, issuer?, audience?, scopes?, roles?, expiresAt?, claims (required) |
'oauth' | clientId (required), name?, email?, issuer?, audience?, scopes?, roles?, expiresAt?, claims? |
'api-key' | name?, expiresAt? |
'basic' | name? |
'custom' | name?, email?, roles?, scopes?, expiresAt?, claims? |
The populated principal rides on the exchange as a single structured header (routecraft.auth.principal) and is exposed ergonomically via the ex.principal getter; read fields with ex.principal?.subject, ex.principal?.scopes, ex.principal?.claims, etc.
Built-in jwt() helper
The jwt() helper creates a validator that verifies JWT signatures, checks expiry, and maps standard claims to AuthPrincipal fields. Zero dependencies (uses node:crypto).
import { mcpPlugin, jwt } from '@routecraft/ai'
HMAC (HS256 / HS384 / HS512):
auth: jwt({ secret: process.env.JWT_SECRET! })
// Explicit algorithm
auth: jwt({ algorithm: 'HS384', secret: process.env.JWT_SECRET! })
RSA (RS256):
import fs from 'node:fs'
auth: jwt({
algorithm: 'RS256',
publicKey: fs.readFileSync('./public.pem', 'utf-8'),
})
Custom validator:
auth: {
validator: async (token) => {
const user = await db.verifyApiKey(token)
if (!user) return null
return {
kind: 'api-key',
scheme: 'api-key',
subject: user.id,
name: user.label,
}
},
}
OAuth 2.1 with oauth()
oauth() mounts a full OAuth 2.1 server flow that proxies to an upstream IdP. Pass a jwt config to let the factory handle JWKS fetching, signature verification, issuer and audience checks, and claim mapping (requires the optional peer dependency jose). For opaque tokens, introspection, or fully custom verification, pass your own verifyAccessToken callback instead.
Built-in JWT verification (recommended):
import { mcpPlugin, oauth } from '@routecraft/ai'
auth: oauth({
issuerUrl: 'https://mcp.example.com',
endpoints: {
authorizationUrl: 'https://idp.example.com/authorize',
tokenUrl: 'https://idp.example.com/token',
},
jwt: {
jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
},
client: {
client_id: 'my-mcp-server',
redirect_uris: ['http://localhost:3000/callback'],
},
})
issuer and audience are required, so the server cannot silently accept tokens from a different IdP or minted for a different resource. The factory maps standard JWT claims (sub, client_id, email, name, iss, aud, scope, roles, exp) to OAuthPrincipal fields automatically; the resolved principal surfaces on the structured routecraft.auth.principal exchange header and is exposed ergonomically via the ex.principal getter.
client accepts either a static OAuthClientInfo (matched on client_id; unknown IDs are rejected) or a supplier (clientId) => Promise<OAuthClientInfo | undefined> for dynamic lookup against a database or registry.
OAuthJwtConfig fields:
| Field | Type | Required | Description |
|---|---|---|---|
jwksUrl | string | URL | Yes | JWKS endpoint the IdP publishes; keys are fetched and rotated by jose's createRemoteJWKSet |
issuer | string | Yes | Expected iss claim; tokens from other issuers are rejected |
audience | string | string[] | Yes | Expected aud claim; the token must include at least one of these values |
clockTolerance | number | string | No | Skew tolerance applied to exp/nbf validation (seconds as a number, or a string like "5s"); default: no tolerance |
claims | OAuthJwtClaimMappers | No | Per-claim overrides for non-standard IdPs (see below) |
OAuthJwtClaimMappers fields. Each maps a verified payload to the corresponding OAuthPrincipal field when the IdP uses non-standard claim names:
| Field | Default when omitted |
|---|---|
subject | payload.sub, then payload.client_id, then payload.azp |
clientId | payload.client_id, then payload.azp |
scopes | space-split payload.scope |
email, name, and roles are not mappable here. They are read from the standard claim names (email, name, roles) when present in the token. For identity fields that do not live in the bearer (most IdPs do not put them there), use the userinfo option on mcpPlugin({}) — function variant for custom mappings, OIDC Discovery or an explicit URL for the standard /userinfo endpoint.
Claim overrides for non-standard IdPs:
jwt: {
jwksUrl: 'https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys',
issuer: 'https://login.microsoftonline.com/<tenant>/v2.0',
audience: '<app-id>',
claims: {
subject: (p) => p.oid as string,
},
}
Custom verification (opaque tokens, introspection, etc.):
import { mcpPlugin, oauth } from '@routecraft/ai'
import { jwtVerify, createRemoteJWKSet } from 'jose'
const jwks = createRemoteJWKSet(new URL('https://idp.example.com/.well-known/jwks.json'))
auth: oauth({
issuerUrl: 'https://mcp.example.com',
endpoints: {
authorizationUrl: 'https://idp.example.com/authorize',
tokenUrl: 'https://idp.example.com/token',
},
verifyAccessToken: async (token) => {
const { payload } = await jwtVerify(token, jwks, {
issuer: 'https://idp.example.com',
audience: 'https://mcp.example.com',
})
return {
kind: 'oauth',
scheme: 'bearer',
subject: payload.sub as string,
clientId: payload['client_id'] as string,
expiresAt: payload.exp,
claims: payload as Record<string, unknown>,
}
},
client: async (clientId) => await db.clients.findByClientId(clientId),
})
expiresAt is required by the MCP SDK's bearer middleware; omit it and every request is rejected with 401. Pass either jwt or verifyAccessToken, never both.
The client supplier (when you pass a function rather than a static object) is invoked per request by the OAuth proxy provider during every authorize/token/revoke call. Cache or preload registry reads so the hot path stays fast.
HTTP client config (McpClientHttpConfig):
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Full URL of the remote MCP server |
auth | McpClientAuthOptions | No | Auth credentials sent on every request to this server |
McpClientAuthOptions:
| Field | Type | Description |
|---|---|---|
token | string | string[] | (() => string | Promise<string>) | Bearer token, array of tokens (round-robin), or provider function called per request |
headers | Record<string, string> | Additional request headers; overrides token if Authorization is set |
Stdio client config (McpClientStdioConfig):
| Field | Type | Required | Description |
|---|---|---|---|
transport | 'stdio' | Yes | Must be 'stdio' to select subprocess mode |
command | string | Yes | Executable to spawn (e.g. 'node', 'npx') |
args | string[] | No | Arguments passed to the command |
env | Record<string, string> | No | Environment variables for the child process |
cwd | string | No | Working directory for the child process |
Stdio clients are spawned when the context starts and stopped on teardown. If the subprocess exits unexpectedly, the plugin automatically restarts it with exponential backoff (restartDelayMs * restartBackoffMultiplier ^ attempt). The restart counter resets after a successful reconnection.
See Expose as MCP and Call an MCP for usage guides.
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({ intervalMs: 24 * 60 * 60 * 1000 }))
.to(agent('summariser'))
.to(direct('reply'))
Options:
| Option | Type | Required | Description |
|---|---|---|---|
agents | Record<string, AgentRegisteredOptions> | No | Agents keyed by id. Duplicate ids across installs throw at context init. Defaults to {}. |
Entry shape (AgentRegisteredOptions):
| Option | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Human-readable description. Surfaces in observability and is used as the tool description when the agent is exposed to other agents |
model | LlmModelId | No* | "provider:model" string resolved via llmPlugin. Required unless defaultOptions.model supplies a fallback; otherwise dispatch throws RC5003 |
system | string | (exchange) => string | Yes | System prompt. Static string or a function that derives it from the exchange (mirrors llm({ system })) |
user | string | (exchange) => string | No | User prompt override. Static string or a function that derives it from the exchange. Defaults to exchange.body (string as-is, JSON for objects) when omitted |
tools | ToolSelection | No | Tool whitelist built via tools([...]). Inherits defaultOptions.tools when omitted; an explicit value replaces the default entirely |
maxTurns | number | No | Cap on tool-calling turns. Inherits defaultOptions.maxTurns when omitted |
skills | string[] | No | Skill names whose content is appended to the system prompt. Resolved against agentPlugin({ skills }) |
principal | boolean | (principal, exchange) => string | No | Append 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 |
output | StandardSchemaV1 | No | Schema for structured output. Validated and parsed onto AgentResult.output after dispatch (runtime ships in a follow-up release) |
Agents loaded from markdown via agents("./dir") accept the same fields as frontmatter. principal is supported there 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", { zoe: { principal: (p) => ... } })) or agentPlugin({ defaultOptions }).
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.
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:
| Option | Type | Required | Description |
|---|---|---|---|
functions | Record<string, FnOptions> | No | Functions keyed by id. Duplicate ids across installs throw at context init. Defaults to {}. |
Entry shape (FnOptions):
| Option | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Human-readable description. Used in observability and as the tool description when exposed to an agent |
input | StandardSchemaV1 | Yes | Standard Schema for the input (Zod, Valibot, ArkType, etc.). Validated at invocation time |
handler | (input, ctx) => Promise<TOut> | TOut | Yes | Called 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 greet = {
description: 'Greets someone',
input: z.object({ name: z.string() }),
handler: async (input, ctx) => `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 } 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. Synchronous in-memory loop today; streaming and durable suspend/resume are tracked separately (streaming agents, durable agents epic).
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,
currentTime,
directTool,
randomUuid,
tools,
} from '@routecraft/ai'
agentPlugin({
functions: {
CurrentTime: currentTime(), // built-in (read-only, idempotent)
RandomUuid: randomUuid(), // built-in (read-only)
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 },
{ tagged: 'read-only' }, // single tag
{ tagged: ['read-only', 'idempotent'] }, // OR-of-tags
]),
},
},
defaultOptions: {
model: 'anthropic:claude-opus-4-7', // applies to agents that omit `model`
tools: tools(['CurrentTime', { tagged: 'read-only' }]),
},
})
tools(items)
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 staysdirect_<routeId>);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. { 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).{ tagged, from?, guard? }: selects every fn / route / MCP tool whose tags overlap the requested set (single tag or array; OR semantics across the array).from?: stringscopes the walk to a single source; todayfrom: "mcp__<server>"restricts the selection to one MCP server. Optional guard applies to every match. Tag-zero-match throws RC5003 so a misconfigured selector cannot silently strip every tool from an agent.
MCP tools are auto-tagged at registration from each tool's MCP annotations: readOnlyHint → "read-only", destructiveHint → "destructive", idempotentHint → "idempotent", openWorldHint → "open-world". That means { tagged: "read-only" } matches fns, routes, AND MCP tools out of the box.
Examples:
agent({
tools: tools([
'CurrentTime', // fn
'Direct(orders/fetch)', // direct route
'MCP(Nuclino:list_teams)', // one MCP tool
'MCP(Stripe)', // all tools from one MCP server
{ tagged: 'read-only' }, // cross-cutting tag filter
{ tagged: 'destructive', from: 'mcp__Nuclino' }, // tag filter scoped to 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');
}
},
},
]),
});
Resolution rules:
- Final list deduplicated by tool name.
- Explicit refs always win over tag-selector matches, regardless of position in the list.
- A
directTool(routeId)fn-registry wrapper supersedes the same direct route surfaced via the prefix convention. 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, { input, tags })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
| Builder | Use |
|---|---|
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 can replace any of those. |
currentTime() / randomUuid() | Built-in fn factories (read-only / idempotent). Assign each a tool name in your functions: config, the same way as directTool. |
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.
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 selectors 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.
Context-level defaultOptions
Mirrors the llmPlugin({ defaultOptions }) pattern: a single bag of values applied to any agent that omits the corresponding field.
| Field | Type | Inherited by |
|---|---|---|
defaultOptions.model | LlmModelId (string) | Agents that omit model |
defaultOptions.tools | ToolSelection (from tools([...])) | Agents that omit tools |
defaultOptions.maxTurns | number | Agents that omit maxTurns |
defaultOptions.principal | boolean | (principal, exchange) => string | Agents that omit principal |
Resolution at dispatch is per-key: instance value > plugin default > (for model) throw, (for tools) undefined. Agents that set the field replace the default entirely (override, not extend).
Two agentPlugin installs that each set the same field throw at context init. Two installs that set DIFFERENT fields merge cleanly.
agentPlugin({
defaultOptions: {
model: 'anthropic:claude-opus-4-7',
tools: tools(['CurrentTime', { tagged: 'read-only' }]),
},
agents: {
researcher: { description, system }, // inherits both
fast: { description, model: 'anthropic:claude-haiku-4-5', system },
},
})
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 8 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.
Human-in-the-loop (today: blocking; tomorrow: durable)
The current loop is synchronous and in-memory. A tool handler that awaits for a while pins the agent's await chain until it resolves. Practical sweet spot:
| Tool wait time | Viability today |
|---|---|
| Under a minute | Fine. HTTP timeouts and restart risk are low. |
| 1–10 minutes | Works on most platforms. Acceptable for "ask user, get reply during a meeting" flows. |
| 10 min – 1 hour | Marginal. Platform request timeouts (Vercel, CloudRun, etc.) cap how long an HTTP request can hang. Use queue / cron entry points if the tool may take this long. |
| Hours – days | Not viable in the synchronous loop. Wait for the durable agents epic. SuspendError is exported today as a forward-compat stub so handler code can be written against the eventual surface. |
A blocking tool handler today looks like:
{
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 })
},
}
When the durable epic lands, the same handler migrates by replacing the blocking await with throw new SuspendError({ reason: "awaiting-human-approval" }) and consuming the resume callback in a separate route. The runtime contract (return value, schema, FnHandlerContext) stays identical.
Observability: two channels
Agents emit on two distinct channels with different shapes and use cases:
1. Context bus (ctx.on('route:*:agent:*', ...)): coarse decision events. Broadcast to every subscriber. Use for telemetry, dashboards, audit trails, TUIs. Always emitted; no opt-in needed.
| Event | Fields | When |
|---|---|---|
route:<id>:agent:tool:invoked | toolCallId, toolName, input | Agent decided to call a tool. |
route:<id>:agent:tool:result | toolCallId, toolName, output, duration | Tool handler returned successfully. |
route:<id>:agent:tool:error | toolCallId, toolName, error, duration | Tool handler / guard / input validation threw. |
route:<id>:agent:finished | finishReason, inputTokens?, outputTokens?, totalTokens? | Agent dispatch returned a consolidated result. |
route:<id>:agent:error | error | Provider / transport error during dispatch. |
All events also carry routeId, exchangeId, correlationId. Wildcard subscriptions (route:*:agent:tool:*) work as expected.
ctx.on("route:*:agent:tool:invoked", ({ details }) => {
console.log(`[${details.routeId}] tool ${details.toolName} called with`, details.input);
});
ctx.on("route:*:agent:finished", ({ details }) => {
metrics.increment("agent.calls.total", { route: details.routeId });
metrics.histogram("agent.tokens.total", details.totalTokens ?? 0);
});
2. onDelta callback (per-dispatch, opt-in): token-level deltas, directed delivery, back-pressure-aware. Use for streaming tokens to a chat UI / SSE / WebSocket where you want to render text as the model writes it.
agent({
model: "openai:gpt-4o",
system: "Be helpful.",
tools: tools(["search"]),
onDelta: (delta) => {
sse.send({ data: delta.text, type: delta.type });
},
})
Setting onDelta switches dispatch from generateText to streamText; externally the destination still returns a consolidated AgentResult once the stream drains.
AgentDelta is a narrow discriminated union:
| Type | Fields | When |
|---|---|---|
text-delta | text | Each token (or token chunk) emitted by the model. |
reasoning-delta | text | Provider reasoning text (Anthropic extended thinking, OpenAI o1). Useful for "thinking..." UI. |
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.
onDeltais not part ofdefaultOptionsbecause delta sinks are typically request-scoped.
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 forwarding tokens into an HTTP SSE response so a UI updates as the model writes. 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({ 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 r = ex.body as AgentResult;
const replied = r.toolCalls?.some(
(c) => c.toolName === "replyEmail" && !c.error,
);
if (!replied) throw new Error("Agent finished without sending a reply");
return r;
})
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.
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).
Related
Plugins
How to write and register plugins.
Adapters reference
llm, embedding, and mcp adapter signatures and options.