Reference

Plugins

Full catalog of built-in plugins with options and behaviour.

Plugin overview

PluginPackageDescription
llmPlugin@routecraft/aiRegister LLM providers for use with llm()
embeddingPlugin@routecraft/aiRegister embedding providers for use with embedding()
mcpPlugin@routecraft/aiStart an MCP server and register remote MCP clients
agentPlugin@routecraft/aiRegister 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:

OptionTypeRequiredDescription
providersLlmPluginProvidersYesProvider credentials (at least one required)
defaultOptionsPartial<LlmOptions>NoDefault options applied to all llm() calls

Providers:

ProviderOptionsDescription
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:

OptionTypeRequiredDescription
providersEmbeddingPluginProvidersYesProvider credentials (at least one required)
defaultOptionsPartial<EmbeddingOptions>NoDefault options applied to all embedding() calls

Providers:

ProviderOptionsDescription
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:

OptionTypeDefaultDescription
namestring'routecraft'Server name exposed in MCP metadata (serverInfo.name)
titlestring--Human-readable display title (serverInfo.title)
versionstring'1.0.0'Server version
descriptionstring'Powered by Routecraft.dev'serverInfo.description; pass '' to omit
websiteUrlstring'https://routecraft.dev'serverInfo.websiteUrl; pass '' to omit
instructionsstring--Server-wide usage guidance on the initialize result; pass '' (or omit) to send none
iconsMcpIcon[]Routecraft logoserverInfo.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
portnumber3001HTTP port (http transport only)
hoststring'localhost'HTTP host (http transport only)
authMcpHttpAuthOptions--Auth for the HTTP endpoint (http transport only; see below)
corsfalse | McpCorsOptionsloopback-onlyCORS 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.
toolsstring[] | (meta) => boolean--Allowlist of tool names to expose, or a filter function
clientsRecord<string, McpClientHttpConfig | McpClientStdioConfig>--Named remote MCP servers (see below)
maxRestartsnumber5Max automatic restarts for stdio clients before giving up
restartDelayMsnumber1000Initial delay before first restart attempt (ms)
restartBackoffMultipliernumber2Multiplier applied to delay on each successive restart
toolRefreshIntervalMsnumber60000Polling 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.

FieldTypeDescription
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:

FieldTypeRequiredDescription
kind'jwt' | 'oauth' | 'api-key' | 'basic' | 'custom'YesDiscriminator for the principal subtype
schemestringYesHTTP authentication scheme ('bearer', 'basic', 'api-key')
subjectstringYesStable identity for the caller (JWT sub, user ID, key ID)

Subtypes:

kindAdditional 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:

FieldTypeRequiredDescription
jwksUrlstring | URLYesJWKS endpoint the IdP publishes; keys are fetched and rotated by jose's createRemoteJWKSet
issuerstringYesExpected iss claim; tokens from other issuers are rejected
audiencestring | string[]YesExpected aud claim; the token must include at least one of these values
clockTolerancenumber | stringNoSkew tolerance applied to exp/nbf validation (seconds as a number, or a string like "5s"); default: no tolerance
claimsOAuthJwtClaimMappersNoPer-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:

FieldDefault when omitted
subjectpayload.sub, then payload.client_id, then payload.azp
clientIdpayload.client_id, then payload.azp
scopesspace-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):

FieldTypeRequiredDescription
urlstringYesFull URL of the remote MCP server
authMcpClientAuthOptionsNoAuth credentials sent on every request to this server

McpClientAuthOptions:

FieldTypeDescription
tokenstring | string[] | (() => string | Promise<string>)Bearer token, array of tokens (round-robin), or provider function called per request
headersRecord<string, string>Additional request headers; overrides token if Authorization is set

Stdio client config (McpClientStdioConfig):

FieldTypeRequiredDescription
transport'stdio'YesMust be 'stdio' to select subprocess mode
commandstringYesExecutable to spawn (e.g. 'node', 'npx')
argsstring[]NoArguments passed to the command
envRecord<string, string>NoEnvironment variables for the child process
cwdstringNoWorking 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:

OptionTypeRequiredDescription
agentsRecord<string, AgentRegisteredOptions>NoAgents keyed by id. Duplicate ids across installs throw at context init. Defaults to {}.

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 | (exchange) => stringNoUser 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
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
skillsstring[]NoSkill names whose content is appended to the system prompt. Resolved against agentPlugin({ skills })
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. 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-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.

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 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 via directTool (the LLM-facing tool name stays direct_<routeId>); 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.
  • { 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).
  • { 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?: string scopes the walk to a single source; today from: "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.
  • 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, { input, tags }) 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 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.

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

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 timeViability today
Under a minuteFine. HTTP timeouts and restart risk are low.
1–10 minutesWorks on most platforms. Acceptable for "ask user, get reply during a meeting" flows.
10 min – 1 hourMarginal. 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 – daysNot 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.

EventFieldsWhen
route:<id>:agent:tool:invokedtoolCallId, toolName, inputAgent decided to call a tool.
route:<id>:agent:tool:resulttoolCallId, toolName, output, durationTool handler returned successfully.
route:<id>:agent:tool:errortoolCallId, toolName, error, durationTool handler / guard / input validation threw.
route:<id>:agent:finishedfinishReason, inputTokens?, outputTokens?, totalTokens?Agent dispatch returned a consolidated result.
route:<id>:agent:errorerrorProvider / 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:

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. onDelta is not part of defaultOptions because 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).


Plugins

How to write and register plugins.

Adapters reference

llm, embedding, and mcp adapter signatures and options.

Previous
Runtime