Beyond the defaults
Composing capabilities
Connect capabilities together to build multi-stage pipelines.
The direct() adapter is an in-process channel that lets one capability hand off data to another. Each capability stays focused on a single concern; direct() connects them without coupling the files.
Linear chain
The simplest pattern: one capability fetches data, passes it to a processor, which passes it to a notifier.
// capabilities/fetch-orders/route.ts
import { craft, direct, timer } from '@routecraft/routecraft'
export default craft()
.id('fetch-orders')
.from(timer({ interval: 300_000 }))
.transform(fetchNewOrders)
.to(direct('process-orders'))
// capabilities/process-orders/route.ts
import { craft, direct } from '@routecraft/routecraft'
export default craft()
.id('process-orders')
.from(direct())
.transform(fulfillOrder)
.to(direct('notify-orders'))
// capabilities/notify-orders/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
export default craft()
.id('notify-orders')
.from(direct())
.to(http({ method: 'POST', url: 'https://api.example.com/notifications' }))
The route's .id() is the direct endpoint name, and destinations reference the consumer by that id. Use letters, digits, - and _ only, such as process-orders: the id is the tool name when the capability is served over MCP, an agent it is granted to sees it as direct__<id>, and tool names allow nothing else.
Fan-out
To send to multiple downstream capabilities, use .tap() for all but the primary output. .tap() is fire-and-forget and does not alter the exchange.
// capabilities/ingest-event/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
export default craft()
.id('ingest-event')
.from(http({ path: '/events', method: 'POST' }))
.tap(direct('audit-event'))
.tap(direct('count-event'))
.to(direct('process-event'))
// capabilities/audit-event/route.ts
import { craft, direct, jsonl } from '@routecraft/routecraft'
export default craft()
.id('audit-event')
.from(direct())
.to(jsonl({ path: './logs/audit.jsonl', append: true }))
// capabilities/count-event/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
import { z } from 'zod'
export default craft()
.id('count-event')
.input({ body: z.object({ type: z.string() }) })
.from(direct())
.transform(({ type }) => ({ counter: type }))
.to(http({ method: 'POST', url: 'https://api.example.com/metrics' }))
Dynamic routing
The destination channel can be resolved at runtime from the exchange body or headers. This lets a single capability route to different consumers without knowing them all in advance.
// capabilities/route-job/route.ts
import { craft, direct, http } from '@routecraft/routecraft'
import { z } from 'zod'
export default craft()
.id('route-job')
.input({ body: z.object({ priority: z.enum(['high', 'normal']) }) })
.from(http({ path: '/jobs', method: 'POST' }))
// TypeScript cannot infer the body type here yet, so it is given explicitly
.to(direct<{ priority: string }, unknown>((exchange) => `jobs-${exchange.body.priority}`))
The explicit type arguments on direct() work around an open issue with its overloads (#726).
// capabilities/jobs-high/route.ts
import { craft, direct, log } from '@routecraft/routecraft'
export default craft()
.id('jobs-high')
.from(direct())
.transform(processUrgent)
.to(log())
// capabilities/jobs-normal/route.ts
import { craft, direct, log } from '@routecraft/routecraft'
export default craft()
.id('jobs-normal')
.from(direct())
.transform(processNormal)
.to(log())
Discovery metadata and framework validation
Title, description, and request / response schemas are route-level concerns declared on the builder. The framework validates .input() against every incoming message before the pipeline runs, and .output() against the final exchange before the primary destination fires. Any source adapter inherits this validation, and any discovery-aware adapter (direct, mcp) mirrors the same metadata into its registry so agents, docs, and observability see one consistent view.
// capabilities/process-orders/route.ts
import { craft, direct, log } from '@routecraft/routecraft'
import { z } from 'zod'
export default craft()
.id('process-orders')
.title('Process orders')
.description('Validate an order payload and trigger fulfilment')
.input({
body: z.object({
orderId: z.string(),
items: z.array(z.string()),
}),
})
.output({ body: z.object({ ok: z.literal(true) }) })
.from(direct())
.transform(fulfillOrder)
.to(log())
Add mcp() beside direct() in .from() and the same title, description and schemas describe the MCP tool; no metadata moves.
How direct() knows its role
direct() is overloaded, and the type of the first argument determines whether it acts as a source or a destination:
direct()ordirect(options): no endpoint string (or an options object), so it acts as a source (.from()). The route's.id()is the endpoint name.direct('channel')ordirect((ex) => channel): a string or function naming a target route, so it acts as a destination (.to(),.tap()).
One import, two roles, one source of truth for the endpoint name (the route id).
Related
Capabilities
What a capability is: its folder, its sources, its checks and its steps.
Type registries
Register direct() endpoint ids so a typo fails at compile time, not at runtime.
Programmatic invocation
Call a direct() capability from your own code with CraftClient.
direct
Every option of the direct() adapter, as a source and as a destination.