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() or direct(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') or direct((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).


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.

Previous
Judging agent results