Before / after — Vercel AI SDK
A real three-tool agent: search → review → commit. Same model call, same SDK. The only difference is
when an out-of-order tool call gets caught.
import { streamText, tool } from 'ai';
import { z } from 'zod';
// Three tools, each with their own inputSchema and execute.
const search = tool({
description: 'Search the repository for files relevant to the change.',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => ({ hits: [] }),
});
const review = tool({
description: 'Review the proposed diff.',
inputSchema: z.object({ diff: z.string() }),
execute: async ({ diff }) => ({ ok: true }),
});
const commit = tool({
description: 'Commit the reviewed change.',
inputSchema: z.object({ message: z.string() }),
execute: async ({ message }) => ({ sha: 'deadbeef' }),
});
// Tool ordering lives in the system prompt — and in your prayers.
await streamText({
model,
tools: { search, review, commit },
system: 'Always call review before commit. Never call commit first.',
prompt: 'Ship the login fix.',
});
// At 3am the model decides the diff "looks fine" and calls commit
// straight after search. Sentry pages. The legality (review must precede commit) lives in a sentence inside the system prompt. The runtime accepts whatever order the model picks. When it picks wrong at 3am, you find out from Sentry — not from the editor.
nextAllowed on each toolimport { streamText } from 'ai';
import { defineTool, createRouterFromTools } from 'toolroute';
import { z } from 'zod';
// Same tools — plus one inline declaration of legal successors.
const search = defineTool({
name: 'search',
description: 'Search the repository for files relevant to the change.',
inputSchema: z.object({ query: z.string() }),
nextAllowed: ['review'] as const,
execute: async ({ query }) => ({ hits: [] }),
});
const review = defineTool({
name: 'review',
description: 'Review the proposed diff.',
inputSchema: z.object({ diff: z.string() }),
nextAllowed: ['commit'] as const,
execute: async ({ diff }) => ({ ok: true }),
});
const commit = defineTool({
name: 'commit',
description: 'Commit the reviewed change.',
inputSchema: z.object({ message: z.string() }),
nextAllowed: [] as const,
execute: async ({ message }) => ({ sha: 'deadbeef' }),
});
// Names are derived from the array; no separate registry.
const router = createRouterFromTools([search, review, commit] as const, {
strictMode: true,
});
await streamText({
model,
tools: router.tools,
prompt: 'Ship the login fix.',
});
// If the model picks 'commit' after 'search', the wrapped execute throws:
// ToolRouteViolation: 'commit' called after 'search'; legal next: [review]
// (toolroute@0.1.0+ai-sdk@6.0.174) nextAllowed on each tool is the single source of truth.
Type narrowing reads it. The runtime guard reads it. They cannot drift.
An out-of-order call is a tsc error and a
thrown ToolRouteViolation.
The wedge, in a few lines of diff
- const search = tool({ description, inputSchema, execute });
+ const search = defineTool({
+ name: 'search',
+ nextAllowed: ['review'] as const,
+ description, inputSchema, execute,
+ });
- await streamText({ model, tools: { search, review, commit }, prompt });
+ const router = createRouterFromTools([search, review, commit] as const, {
+ strictMode: true,
+ });
+ await streamText({ model, tools: router.tools, prompt });Try it
The TS Playground link below has the narrowing demo prefilled — type commit into a step where only review is legal
and watch the red squiggle appear in your editor.