npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

toolroute

v0.2.1

Published

Vercel AI SDK companion that turns tool definitions into a typed routing graph.

Readme

ToolRoute

Vercel AI SDK companion that turns tool definitions into a typed routing graph.

📚 Docs site · Quickstart · API reference · Before / after · Decisions

Production agents fail most often at tool ordering — the model calls send_email before draft_email, or commit before review. Today that's a system-prompt sentence and a Sentry alert at 3am. ToolRoute turns it into a compile-time error AND a runtime guard that throws.

defineTool({ name: 'commit', nextAllowed: [], ... })

That's it. The tool itself declares its legal successors. ToolRoute narrows the SDK tools union per step so the wrong tool can't compile, and a 50-line runtime guard catches the model when it tries anyway.

The 60-second hero

▶ Watch the recording (assets/recordings/readme-hero.castnpx asciinema play it locally)

The recording shows two things on the same offending call (commit before review):

  1. Type-level rejection. tsc flags the call site — 'commit' does not exist in type '{ review: SDKToolFor<...> }'.
  2. Runtime rejection. The same code, run anyway, throws ToolRouteViolation: 'commit' called after 'search'; legal next: [review] ([email protected][email protected]).

Both layers describe the same rejection because both read the same nextAllowed array — there is no second source of truth to drift.

Install

pnpm add toolroute
# peer deps:
pnpm add ai zod

5-line 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 the routerVersion you were compiled against.

Side-by-side: raw Vercel AI SDK vs ToolRoute

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

- const search = tool({
+ const search = defineTool({
+   name: 'search',
+   nextAllowed: ['review'] as const,
    description: 'Search the repository for files relevant to the change.',
    inputSchema: z.object({ query: z.string() }),
    execute: async ({ query }) => ({ hits: [...] }),
  });

  // ... review, commit similarly wrapped ...

- 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 });

The single change you make at each tool site is nextAllowed. The single change you make at the call site is wrapping tools in a router. Both layers — type-level narrowing and runtime guard — read that one nextAllowed array.

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 unreachable from the type. The red squiggle in the hero recording is that exclusion.

Debug

import { printRouterGraph } from 'toolroute';

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

Plain text, sorted, zero deps. At 7+ tools the routing graph lives across files; this dump is your single-screen view.

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.

strictMode

createRouterFromTools(tools, { strictMode: true });   // throws on violation
createRouterFromTools(tools, { strictMode: false });  // warns on violation (default)

Warn mode is the default because some agent flows are recoverable. Throw mode is the production setting once you trust your graph.

Edge runtime warning

console.warn is suppressed in Vercel Edge Functions and Cloudflare Workers. ToolRoute detects this at router construction and emits a one-time init warning:

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

Pipe warn: to your own log sink in those environments:

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

SDK compatibility

ToolRoute pins to a peer range of the Vercel AI SDK. See COMPATIBILITY.md for the dated row of every version we test against — a weekly CI cron re-runs the suite against ai@latest and opens an issue tagged sdk-drift if anything breaks.

Errors

ToolRouteViolation extends Error exposes:

  • prev: string | null — the previous tool, or null at start of run.
  • next: string — the offending tool the model wanted to call.
  • legalNext: readonly string[] — what was allowed.
  • routerVersion: stringtoolroute@<pkg>+ai-sdk@<peer>. Paste this into a Sentry title; the dashboard will tell you which SDK version was on the box.

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

Pairs well with tapedeck

tapedeck is the sibling package: record/replay middleware for the same SDK. Guard trajectories in production with ToolRoute, replay model calls offline in CI with tapedeck, and assert the recorded trajectory against your router with the toFollowRoute() matcher from tapedeck/vitest:

import { expect } from 'vitest';
import { toFollowRoute, withCassette } from 'tapedeck/vitest';

expect.extend({ toFollowRoute });

await withCassette('checkout-flow.json', async () => {
  const result = await runAgent({ prompt: 'buy a t-shirt' });
  expect(result.steps).toFollowRoute(router);
});

The matcher reads the router's public adjacency / routerVersion fields structurally, so any ToolRoute version works and neither package depends on the other.

Known limitations

Two behaviours are worth knowing before you wire ToolRoute into a fleet of agents:

  • One router per concurrent run. A router instance tracks a single state.prev pointer. If several agent runs share the same router.tools at the same time, their transitions cross-contaminate the guard and you will get spurious (or missed) violations. Build one router per run, or call router.reset() between sequential runs that reuse the same instance.
  • Entry tools must have successors. An entry tool is defined as one with a non-empty nextAllowed. A terminal tool (empty nextAllowed) can therefore never be the first call in a run — nextTools(router, null) excludes it by design. If a tool should be legal as the first step, give it at least one successor.

Limits and v2

Things explicitly not in v1 — see issues/999-v2-deferred.md for the full roadmap:

  • Live Cloudflare playground.
  • Anthropic SDK adapter (Vercel AI SDK only in v1).
  • Mermaid/DOT routing-graph renderer.
  • Hosted observability dashboard.

License

MIT.