← Back to home

Before / after — Vercel AI SDK

A real three-tool agent: searchreviewcommit. Same model call, same SDK. The only difference is when an out-of-order tool call gets caught.

Before Vercel AI SDK · system prompt
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.

After ToolRoute · nextAllowed on each tool
import { 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.