Examples
Support triage agent
Let an agent triage incoming support email, bounded to a two-tool allowlist.
This is the "whole agent" mode: the capability is the agent loop. A support email arrives over
IMAP, and an agent() destination reads it, looks the customer up, decides a priority, and
posts an internal brief. The agent is the brain, but it has hands, not keys: it can call
exactly two capabilities and nothing else, no arbitrary HTTP, no shell, no open-ended tools.
The bounded tools
Each tool is an ordinary capability with a direct() source, a description (the agent reads
it to decide when to call), and a typed input. Because they are normal capabilities, they are
testable and reusable on their own.
// capabilities/support/lookup-customer/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
import { z } from 'zod'
export const LookupInput = z.object({ email: z.string().email() })
export type LookupInput = z.infer<typeof LookupInput>
export default craft()
.id('lookup-customer')
.description('Look up a customer and their plan by email address')
.input({ body: LookupInput })
.from(direct())
// TypeScript cannot infer the body type here yet, so it is given explicitly:
// https://github.com/routecraftjs/routecraft/issues/726
.to(
http<LookupInput>({
method: 'GET',
url: (ex) => `https://api.example.com/customers/${ex.body.email}`,
}),
)
// capabilities/support/post-brief/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
import { z } from 'zod'
export const BriefInput = z.object({
priority: z.enum(['P1', 'P2', 'P3']),
customer: z.string(),
summary: z.string(),
})
export type BriefInput = z.infer<typeof BriefInput>
export default craft()
.id('post-brief')
.description('Post a triage brief to the internal support channel')
.input({ body: BriefInput })
.from(direct())
.to(http({ method: 'POST', url: 'https://chat.example.com/support/briefs' }))
The agent
The triage capability sources from the inbox and hands each message to agent(). The
tools([...]) allowlist is the guardrail: Direct(lookup-customer) and Direct(post-brief)
are the only tools the model can call.
// capabilities/support/triage/route.ts
import { craft, mail } from '@routecraft/routecraft'
import { agent, tools } from '@routecraft/ai'
type SupportEmail = { text?: string }
export default craft()
.id('triage-support')
.description('Triage an incoming support email')
.from(mail('INBOX', { unseen: true, markSeen: true }))
.to(
agent<SupportEmail>({
model: 'anthropic:claude-sonnet-4-6',
system:
'You are a support triage assistant. Look the sender up, decide a priority (P1 urgent, P2 normal, P3 low), and post one concise internal brief. Do not reply to the customer.',
user: (ex) =>
`From: ${ex.headers['routecraft.mail.from']}\n` +
`Subject: ${ex.headers['routecraft.mail.subject']}\n\n${ex.body.text ?? ''}`,
tools: tools(['Direct(lookup-customer)', 'Direct(post-brief)']),
}),
)
Direct(<id>) references a registered capability as a tool; the agent sees its
.description() and .input() schema and calls it with validated arguments. A bare fn id and
MCP(server:tool) are also valid allowlist entries, and the object form
{ name, guard, description } adds a per-tool guard
or a per-agent description override. A guard runs after the tool's schema has validated the
arguments, receives them with a context carrying the caller's principal, and throws to deny the
call. This one keeps customer email addresses out of the internal channel, in any field of the
brief. Pass it as the agent's tools in place of the plain list above:
import { tools } from '@routecraft/ai'
const triageTools = tools([
'Direct(lookup-customer)',
{
name: 'Direct(post-brief)',
guard: (input) => {
if (/[^\s@]+@[^\s@]+/.test(JSON.stringify(input))) {
throw new Error('leave email addresses out of the brief')
}
},
},
])
This guard judges the input rather than the caller, because a mail source mints no principal:
ctx.principal is empty for every run of this capability. To decide by who sent the mail, mint a
principal from claims you verified yourself (a DKIM-passing sender, say) with
.authenticate() before the agent step; Credentials
and identity has the model.
Config
Model providers live under the llm key, and the inbox the mail() source reads under the
mail key. The agent inherits the provider from llm.
// craft.config.ts
import { defineConfig } from '@routecraft/routecraft'
import '@routecraft/ai'
export const craftConfig = defineConfig({
llm: {
providers: { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY! } },
},
mail: {
accounts: {
default: {
imap: {
host: 'imap.example.com',
auth: { user: process.env.SUPPORT_MAIL_USER!, pass: process.env.SUPPORT_MAIL_PASSWORD! },
},
},
},
},
})
The mail() source needs its optional peers: bun add imapflow mailparser.
Giving the agent durable context
For standing instructions the agent should always have (tone, escalation policy, product
facts), attach blocks instead of stuffing the system string. skills(...) loads markdown
files as blocks; by default they are surfaced progressively (the model sees each skill's name
and description and loads the body via a tool call only when relevant). It is async, so resolve
it once and assign it to a named group so every skill stays under one key:
import { agent, tools, skills } from '@routecraft/ai'
const supportKnowledge = await skills({ source: './support-knowledge' })
agent({
model: 'anthropic:claude-sonnet-4-6',
system: 'You are a support triage assistant.',
blocks: { knowledge: supportKnowledge },
tools: tools(['Direct(lookup-customer)', 'Direct(post-brief)']),
})
Each skill then resolves to knowledge__<skill-name> (its loader tool and blocksLoaded
entry). Spreading ...supportKnowledge still works if you would rather keep each skill at the
top level.
Related
Agents and skills
Define an agent, grant it capabilities as tools, and give it skills.
agent
The agent() adapter: model, system, tools, blocks, and loop options.
agentPlugin
Every form a tool can take, per-tool guards, and the tool policy.
The mail adapter: named accounts, source filters and sending.