Skip to content

Commit d9d8ab1

Browse files
committed
add TanStack AI lazy tool discovery blog post
1 parent 5f28e78 commit d9d8ab1

File tree

2 files changed

+169
-0
lines changed

2 files changed

+169
-0
lines changed
1.64 MB
Loading
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
---
2+
title: 'Lazy Tool Discovery: Scaling AI Tool Systems Without Drowning in Tokens'
3+
published: 2026-03-12
4+
authors:
5+
- Alem Tuzlak
6+
---
7+
8+
![Lazy Tool Discovery](/blog-assets/tanstack-ai-lazy-tool-discovery/header.webp)
9+
10+
If you've built an AI-powered application with more than a handful of tools, you've probably hit the wall: every tool definition you send to the LLM costs tokens, eats into the context window, and — past a certain point — actually makes the model _worse_ at picking the right tool. More tools means more noise, slower responses, and higher bills.
11+
12+
Today we're shipping **lazy tool discovery** in TanStack AI, a mechanism that lets the LLM discover tools on demand instead of receiving all of them upfront.
13+
14+
## The Problem
15+
16+
Consider a customer support agent with 30 tools: ticket lookup, order management, refund processing, knowledge base search, escalation workflows, analytics queries, and more. On any given request, the user probably needs 2–3 of these. But the LLM sees all 30 tool definitions on every single call.
17+
18+
This creates three problems:
19+
20+
1. **Token waste.** Tool definitions with descriptions and JSON schemas add up fast. Thirty detailed tools can easily burn 3,000–5,000 tokens before the conversation even starts.
21+
2. **Decision fatigue.** LLMs perform better with fewer, more relevant options. Research consistently shows that tool selection accuracy degrades as the number of available tools grows.
22+
3. **Inflexibility.** You either send everything or build your own routing layer to pre-filter tools. Neither option is great.
23+
24+
## The Solution: `lazy: true`
25+
26+
Lazy tool discovery adds a single flag to tool definitions:
27+
28+
```typescript
29+
const searchProducts = toolDefinition({
30+
name: 'searchProducts',
31+
description: 'Search products by keyword',
32+
inputSchema: z.object({
33+
query: z.string(),
34+
}),
35+
lazy: true,
36+
})
37+
```
38+
39+
That's it. Tools marked `lazy: true` are withheld from the LLM. In their place, the LLM sees a single synthetic tool called `__lazy__tool__discovery__` whose description lists the names of all available lazy tools.
40+
41+
When the LLM decides it needs a tool, the flow looks like this:
42+
43+
1. The LLM sees the discovery tool: _"You have access to additional tools: [searchProducts, compareProducts, calculateFinancing]"_
44+
2. The LLM calls the discovery tool: `{ toolNames: ["searchProducts"] }`
45+
3. The discovery tool returns the full description and JSON schema for `searchProducts`
46+
4. `searchProducts` is injected as a normal tool in the next iteration
47+
5. The LLM calls `searchProducts` directly with actual arguments
48+
49+
From the LLM's perspective, it asked about a tool, learned what it does, and then used it. From your perspective, you saved tokens on every request where that tool wasn't needed.
50+
51+
## How It Works Under the Hood
52+
53+
When you pass tools to `chat()`, the engine separates them into eager tools (the default) and lazy tools. A `LazyToolManager` class handles the rest:
54+
55+
- **Separation:** Eager tools are sent to the LLM immediately. Lazy tools are held back.
56+
- **Discovery tool:** If any lazy tools exist, a synthetic discovery tool is created and included in the tool set. Its description contains the names of all lazy tools so the LLM knows what's available.
57+
- **Dynamic injection:** When the LLM discovers a tool, it's added to the active tool set for the next agent loop iteration. The LLM then sees it as a regular tool with full schema — no proxy, no indirection.
58+
- **Multi-turn memory:** On each `chat()` call, the manager scans the message history for previous discovery results. Tools discovered in earlier turns are automatically pre-populated — the LLM doesn't need to re-discover them.
59+
- **Self-correction:** If the LLM tries to call a lazy tool it hasn't discovered yet (LLMs sometimes skip steps), it gets a helpful error: _"Tool 'searchProducts' must be discovered first. Call **lazy**tool**discovery** with toolNames: ['searchProducts'] to discover it."_ The LLM self-corrects on the next iteration.
60+
- **Auto-cleanup:** When all lazy tools have been discovered, the discovery tool removes itself from the active set. No unnecessary clutter.
61+
62+
## Zero Overhead When Not Used
63+
64+
If none of your tools have `lazy: true`, the behavior is identical to before. No discovery tool is created, no extra processing happens, no code paths change. The feature is entirely opt-in.
65+
66+
## A Real Example
67+
68+
Here's a guitar store chat application with a mix of eager and lazy tools:
69+
70+
```typescript
71+
import { chat, toolDefinition, maxIterations } from '@tanstack/ai'
72+
import { openaiText } from '@tanstack/ai-openai'
73+
import { z } from 'zod'
74+
75+
// Always available — core functionality
76+
const getGuitars = toolDefinition({
77+
name: 'getGuitars',
78+
description: 'Get all guitars from inventory',
79+
inputSchema: z.object({}),
80+
}).server(() => fetchGuitarsFromDB())
81+
82+
const recommendGuitar = toolDefinition({
83+
name: 'recommendGuitar',
84+
description: 'Display a guitar recommendation to the user',
85+
inputSchema: z.object({ id: z.number() }),
86+
}).server(({ id }) => ({ id }))
87+
88+
// Discovered on demand — secondary features
89+
const compareGuitars = toolDefinition({
90+
name: 'compareGuitars',
91+
description: 'Compare two or more guitars side by side',
92+
inputSchema: z.object({
93+
guitarIds: z.array(z.number()).min(2),
94+
}),
95+
lazy: true,
96+
}).server(({ guitarIds }) => buildComparison(guitarIds))
97+
98+
const calculateFinancing = toolDefinition({
99+
name: 'calculateFinancing',
100+
description: 'Calculate monthly payment plans for a guitar',
101+
inputSchema: z.object({
102+
guitarId: z.number(),
103+
months: z.number(),
104+
}),
105+
lazy: true,
106+
}).server(({ guitarId, months }) => computePaymentPlan(guitarId, months))
107+
108+
const searchGuitars = toolDefinition({
109+
name: 'searchGuitars',
110+
description: 'Search guitars by keyword in name or description',
111+
inputSchema: z.object({
112+
query: z.string(),
113+
}),
114+
lazy: true,
115+
}).server(({ query }) => searchInventory(query))
116+
117+
// Use in chat — lazy tools work automatically
118+
const stream = chat({
119+
adapter: openaiText('gpt-4o'),
120+
messages,
121+
tools: [
122+
getGuitars,
123+
recommendGuitar,
124+
compareGuitars,
125+
calculateFinancing,
126+
searchGuitars,
127+
],
128+
agentLoopStrategy: maxIterations(20),
129+
})
130+
```
131+
132+
When a user asks _"recommend me a guitar"_, the LLM sees `getGuitars`, `recommendGuitar`, and `__lazy__tool__discovery__`. It calls the first two and never touches discovery. Tokens saved.
133+
134+
When a user asks _"compare the Motherboard Guitar and the Racing Guitar"_, the LLM sees the discovery tool, discovers `compareGuitars`, and calls it. One extra round-trip, but only when the feature is actually needed.
135+
136+
When a user follows up with _"how much would the cheaper one cost per month?"_, the LLM has `compareGuitars` already available (from the earlier discovery) and discovers `calculateFinancing`. The conversation builds naturally without re-discovering tools.
137+
138+
## When Should You Use It?
139+
140+
Lazy discovery makes sense when:
141+
142+
- **You have many tools** and most aren't needed in every conversation
143+
- **Some tools are niche** — advanced search, analytics, admin functions, reporting
144+
- **Tool descriptions are verbose** — detailed schemas with many properties
145+
- **You're cost-conscious** — fewer tokens per request means lower bills
146+
147+
Keep tools eager (the default) when:
148+
149+
- They're called in most conversations
150+
- The total tool count is small (under ~10)
151+
- Latency is critical and the extra discovery round-trip matters
152+
153+
A good rule of thumb: if a tool is used in less than 30% of conversations, it's a strong candidate for `lazy: true`.
154+
155+
## What's Next
156+
157+
Lazy tool discovery is available now in `@tanstack/ai`. Add `lazy: true` to any tool definition and you're done.
158+
159+
We're exploring a few follow-on improvements:
160+
161+
- **Grouped discovery:** Discover related tools together (e.g., all analytics tools at once)
162+
- **Cost tracking:** Surface token savings from lazy tools in DevTools
163+
- **Smarter descriptions:** Let the discovery tool description include one-line summaries per lazy tool, not just names
164+
165+
Check out the [full documentation](https://tanstack.com/ai/latest/docs/guides/lazy-tool-discovery) for details, or try it out in the [ts-react-chat example](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat) which includes three lazy tools with test prompts.
166+
167+
---
168+
169+
_TanStack AI is an open-source, provider-agnostic AI SDK for building type-safe AI applications. [Get started here.](https://tanstack.com/ai)_

0 commit comments

Comments
 (0)