Guide

Production agents on the Vercel AI SDK fail most often at tool ordering — the model calls send_email before draft_email, or commit before review. ToolRoute makes that a compile error and a runtime guard that throws.

ToolRoute is a Vercel AI SDK companion. Each tool declares its legal nextAllowed successors inline; createRouterFromTools derives the name set from the array; the typed tools record narrows per step; and a 50-line runtime guard rejects illegal calls at the wrapped execute.

Install

pnpm add toolroute
# peer deps
pnpm add ai zod

ai is required (peer range >=6.0.0 <7). zod is optional but recommended — inputSchema accepts any Standard Schema implementation.

Quickstart

import { defineTool, createRouterFromTools } from 'toolroute';
import { streamText } from 'ai';
import { z } from 'zod';

const search = defineTool({
  name: 'search',
  inputSchema: z.object({ query: z.string() }),
  nextAllowed: ['review'] as const,
  execute: async ({ query }) => ({ hits: [`match for ${query}`] }),
});

const review = defineTool({
  name: 'review',
  inputSchema: z.object({ diff: z.string() }),
  nextAllowed: ['commit'] as const,
  execute: async ({ diff }) => ({ ok: true, notes: diff.slice(0, 80) }),
});

const commit = defineTool({
  name: 'commit',
  inputSchema: z.object({ message: z.string() }),
  nextAllowed: [] as const,
  execute: async ({ message }) => ({ sha: 'deadbeef', message }),
});

const router = createRouterFromTools([search, review, commit] as const, {
  strictMode: true,
});

await streamText({ model, tools: router.tools, prompt: '...' });

If the model decides to call commit before review, the wrapped execute throws ToolRouteViolation with prev: 'search', next: 'commit', legalNext: ['review'], and a routerVersion string encoding both the package and the SDK peer version it was built against.

defineTool

defineTool collocates the legality graph with the tool definition itself. That collocation is the load-bearing idea — both the type narrowing and the runtime guard read the same nextAllowed array, which means they cannot drift.

defineTool({
  name: 'search',
  description: 'Find files relevant to the change.',
  inputSchema: z.object({ query: z.string() }),
  nextAllowed: ['review'] as const,
  execute: async ({ query }) => ({ /* ... */ }),
});
  • name is preserved at the type level as a literal.
  • nextAllowed is preserved as a tuple, not string[] — that is what makes per-step narrowing possible.
  • inputSchema accepts any Vercel AI SDK FlexibleSchema (Zod, Standard Schema, JSON Schema).

The output of defineTool is structurally compatible with the SDK's tool() helper output. The router transforms the array into the ToolSet shape streamText expects.

createRouterFromTools

const router = createRouterFromTools([search, review, commit] as const, {
  strictMode: true,
});
  • Derives the union of legal tool names from tools[number]['name'].
  • Validates every nextAllowed reference at build time and runtime — a typo is a TypeScript error, and the constructor also throws if a reference is unknown.
  • Returns { tools, adjacency, routerVersion, strictMode, reset }. Pass router.tools to streamText; the wrapped execute consults the guard.

The routerVersion field is toolroute@<pkg>+ai-sdk@<peer>. It is threaded into every ToolRouteViolation, so error titles in Sentry tell you which SDK was on the box.

One state machine per router
The router holds a single `prev` pointer. Use one router per agent run, or call router.reset() between runs. Concurrent streamText calls sharing the same router will interleave.

Per-step narrowing

If you drive the agent yourself — a manual loop instead of streamText's multi-step — nextTools(router, prevName) gives you the narrowed legal subset:

import { nextTools } from 'toolroute';

await streamText({ model, tools: nextTools(router, null) });    // entry tools
await streamText({ model, tools: nextTools(router, 'search') }); // { review }
await streamText({ model, tools: nextTools(router, 'review') }); // { commit }

At the type level, NextTools<typeof router, 'search'> is exactly { review: SDKToolFor<typeof review> }commit is not in the type. That is the red squiggle in the README hero recording.

strictMode

createRouterFromTools(tools, { strictMode: true });   // throws on violation
createRouterFromTools(tools, { strictMode: false });  // warns on violation (default)
  • strictMode: true — the wrapped execute throws ToolRouteViolation. Use this in production once you trust your graph.
  • strictMode: false (default) — the wrapped execute calls warn with a single-line diagnostic and proceeds. Useful while you are still shaking the graph out.

You can swap the default console.warn for a custom sink:

createRouterFromTools(tools, {
  strictMode: false,
  warn: (msg) => myLogger.warn(msg),
});

Edge runtime

console.warn is suppressed in Vercel Edge Functions and Cloudflare Workers — a silent warn-mode would lose violations exactly where you need them. ToolRoute detects the runtime at router construction (checking globalThis.EdgeRuntime, process.env.NEXT_RUNTIME === 'edge', and Cloudflare worker globals) and emits a one-time init warning:

[ToolRoute] Edge runtime detected. console.warn may be suppressed;
pipe runtime logs to capture violations.

The detection is wrapped in try/catch and fails open (skip the warning) rather than throwing. Pipe warn: to your own log sink in those environments.

Renames

createRouterFromTools derives the name set from the tool array itself — so renaming a tool in one place propagates everywhere:

// rename `review` -> `inspect` at the tool definition site:
const inspect = defineTool({ name: 'inspect', nextAllowed: ['commit'], /* ... */ });

// every other tool that listed 'review' in nextAllowed is now a
// TypeScript error at *its* definition site, not yours.

There is exactly one source of truth (the name field on the tool). There is no separate registry to keep in sync.

Errors

ToolRouteViolation extends Error exposes:

FieldTypeNotes
prevstring \| nullPrevious tool, or null at start of run.
nextstringThe offending tool the model wanted to call.
legalNextreadonly string[]What was allowed at this step.
routerVersionstringtoolroute@<pkg>+ai-sdk@<peer>.

The message is single-line so a copy-paste from a terminal lands cleanly in a Sentry issue title:

ToolRoute violation: 'commit' called after 'search'; legal next: [review]
(toolroute@0.1.0+ai-sdk@6.0.174)

Debug

import { printRouterGraph } from 'toolroute';

console.log(printRouterGraph(router));
//=> commit -> (terminal)
//   review -> commit
//   search -> review

Plain text, sorted alphabetically, terminals as (terminal). Zero runtime dependencies, two-minute implementation. At 7+ tools your routing graph lives across files; this is your single-screen view.

Non-goals

Things explicitly not in v1, with rationale on the Decisions pages:

  • Live Cloudflare playground — the recording + side-by-side diff is the calling card.
  • Anthropic SDK adapter — Vercel AI SDK only in v1, scope-honesty.
  • Hosted observability dashboard — out of scope; would be a separate product.
  • Mermaid/DOT graph renderer — printRouterGraph text adjacency in v1.

See the v2 deferred index for the canonical list.