Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/code-mode-lazy-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/ai': minor
'@tanstack/ai-code-mode': minor
---

Add lazy tool support (progressive disclosure) to Code Mode. Tools marked `lazy: true` are kept out of the `execute_typescript` system prompt and listed in a discoverable catalog; the model fetches their TypeScript signatures on demand via a new `discover_tools` tool. A shared optional `lazyToolsConfig` (`includeDescription: 'none' | 'first-sentence' | 'full'`) tunes the catalog detail for both `chat()` and `createCodeMode()`. `createCodeMode` now also returns `discoveryTool` and a `tools` array (backward compatible — `tool` and `systemPrompt` are unchanged).
190 changes: 190 additions & 0 deletions docs/code-mode/lazy-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
---
title: Lazy Tools
id: lazy-tools
order: 5
description: "Keep large tool catalogs out of the Code Mode system prompt with lazy tools — the model fetches TypeScript signatures on demand via a discover_tools call."
keywords:
- tanstack ai
- code mode
- lazy tools
- discover_tools
- progressive disclosure
- prompt size
- tool catalog
---

Large tool catalogs bloat the `execute_typescript` system prompt. Every tool you pass to `createCodeMode` becomes a full TypeScript type stub in that prompt — and at 50+ tools, those stubs can push the effective prompt into the tens of thousands of tokens before the model has even seen your user message.

Lazy tools fix this with **progressive disclosure**: mark rarely-used tools `lazy: true` and they are withheld from the initial system prompt. The model sees only their names in a short "Discoverable APIs" catalog. When it needs one, it calls the `discover_tools` sibling tool to fetch the TypeScript signature on demand, then uses it inside `execute_typescript`. All sandbox bindings are always injected — lazy only defers _documentation_, not callability.

## Marking a Tool Lazy

Add `lazy: true` to the `toolDefinition` config for any tool you want to defer:

```typescript
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";

// Always eager — documented upfront
const fetchWeather = toolDefinition({
name: "fetchWeather",
description: "Get current weather for a city",
inputSchema: z.object({ location: z.string() }),
outputSchema: z.object({ temperature: z.number(), condition: z.string() }),
}).server(async ({ location }) => {
const res = await fetch(`https://api.weather.example/v1?city=${location}`);
return res.json();
});

// Lazy — kept out of the system prompt until discovered
const fetchArchive = toolDefinition({
name: "fetchArchive",
description: "Retrieve historical weather archive data for a date range",
inputSchema: z.object({
location: z.string(),
from: z.string(),
to: z.string(),
}),
outputSchema: z.array(z.object({ date: z.string(), temperature: z.number() })),
lazy: true,
}).server(async ({ location, from, to }) => {
const res = await fetch(
`https://api.weather.example/v1/archive?city=${location}&from=${from}&to=${to}`
);
return res.json();
});
```

Eager tools continue to receive full type stubs in the system prompt. Lazy tools appear only by name.

## Server Setup

Pass both eager and lazy tools to `createCodeMode`. When at least one tool is lazy, `createCodeMode` also returns a `discover_tools` sibling tool — include it in the `tools` array you pass to `chat()`:

```typescript
// server/route.ts
import { chat, maxIterations, toServerSentEventsStream } from "@tanstack/ai";
import { createCodeMode } from "@tanstack/ai-code-mode";
import { createNodeIsolateDriver } from "@tanstack/ai-isolate-node";
import { openaiText } from "@tanstack/ai-openai";

const { tools, systemPrompt } = createCodeMode({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
driver: createNodeIsolateDriver(),
tools: [fetchWeather, fetchArchive], // fetchArchive is lazy
});

// tools is [execute_typescript, discover_tools]
// — discover_tools is included automatically because fetchArchive is lazy

export async function POST(req: Request) {
const { messages } = await req.json();

const stream = chat({
adapter: openaiText("gpt-5.5"),
systemPrompts: ["You are a helpful weather assistant.", systemPrompt],
tools: [...tools],
messages,
agentLoopStrategy: maxIterations(10),
});

return toServerSentEventsStream(stream);
}
```

`createCodeMode` returns `{ tool, discoveryTool, tools, systemPrompt }`:

| Field | Type | Description |
|-------|------|-------------|
| `tool` | `ServerTool` | The `execute_typescript` tool (backward compatible) |
| `discoveryTool` | `ServerTool \| null` | The `discover_tools` tool, or `null` when there are no lazy tools |
| `tools` | `Array<ServerTool>` | `[tool]` or `[tool, discoveryTool]` — spread into `chat({ tools })` |
| `systemPrompt` | `string` | The matching system prompt |

If no tools are lazy, `discoveryTool` is `null` and `tools` contains only `execute_typescript`.

## The `discover_tools` Flow

When the model encounters a task that requires a lazy tool, it:

1. Calls `discover_tools` with the tool name (bare name, no `external_` prefix).
2. Receives the TypeScript type stub and description for that tool.
3. Writes `execute_typescript` code using the now-documented `external_fetchArchive(...)` call.

The bindings are always injected into the sandbox — discovering a tool only retrieves documentation, it does not enable the binding. The model could call `external_fetchArchive` without discovering it first, but it would be writing blind without the type signature.

## Tuning the Discoverable APIs Catalog

By default, lazy tools appear in the system prompt as bare names with no description:

```text
### Discoverable APIs

- external_fetchArchive
- external_runReport
- external_exportData
```

If you want the model to have a hint about what each tool does before deciding whether to discover it, use `lazyToolsConfig.includeDescription`:

```typescript
const { tools, systemPrompt } = createCodeMode({
driver: createNodeIsolateDriver(),
tools: [fetchWeather, fetchArchive, runReport, exportData],
lazyToolsConfig: {
includeDescription: "first-sentence", // 'none' | 'first-sentence' | 'full'
},
});
```

With `'first-sentence'` the catalog becomes:

```text
### Discoverable APIs

- external_fetchArchive — Retrieve historical weather archive data for a date range.
- external_runReport — Generate a summary report for a given time period.
- external_exportData — Export query results to CSV or JSON format.
```

| Value | Effect |
|-------|--------|
| `'none'` (default) | Bare names only — smallest possible prompt addition |
| `'first-sentence'` | Name plus the first sentence of the tool's description |
| `'full'` | Name plus the complete description |

The full type stub and input/output schema are always returned on discovery — `includeDescription` only affects the pre-discovery catalog.

## Lazy Tools with Plain `chat()`

The same `lazyToolsConfig` option works for lazy tools used directly with `chat()`, outside of Code Mode. Tools marked `lazy: true` are withheld from the `__lazy__tool__discovery__` catalog description until the model calls for them. Pass `lazyToolsConfig` directly to `chat()`:

```typescript
import { chat, maxIterations } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

// Non-code-mode: lazy tools in a regular chat agent
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [fetchWeather, fetchArchive, runReport],
lazyToolsConfig: {
includeDescription: "first-sentence",
},
agentLoopStrategy: maxIterations(10),
});
```

The `includeDescription` behavior is identical — `'none'` lists bare tool names, `'first-sentence'` appends the first sentence, `'full'` appends the complete description.

## Tips

- **Start with `'none'`.** The bare-names catalog is enough for models that reason well about tool names. Add `'first-sentence'` only if the model frequently discovers irrelevant tools.
- **Lazy tools are always callable.** Their `external_*` bindings are injected into the sandbox regardless of whether the model has called `discover_tools`. Discovery only reveals documentation.
- **Use `discoveryTool` for observability.** You can inspect `discoveryTool.name` (`"discover_tools"`) to confirm the tool is wired up, or log its calls for analytics.
- **Partition by frequency, not capability.** Mark tools lazy when they are rarely needed for a typical request. Core tools that most requests use should stay eager.

## Next Steps

- [Code Mode](./code-mode) — Core Code Mode setup and API reference
- [Code Mode with Skills](./code-mode-with-skills) — Persistent reusable skill libraries
- [Isolate Drivers](./code-mode-isolates) — Compare Node, QuickJS, and Cloudflare sandbox runtimes
8 changes: 7 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@
{
"label": "Lazy Tool Discovery",
"to": "tools/lazy-tool-discovery",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-06-08"
}
]
},
Expand Down Expand Up @@ -213,6 +214,11 @@
"label": "Code Mode Isolate Drivers",
"to": "code-mode/code-mode-isolates",
"addedAt": "2026-04-15"
},
{
"label": "Lazy Tools",
"to": "code-mode/lazy-tools",
"addedAt": "2026-06-08"
}
]
},
Expand Down
38 changes: 36 additions & 2 deletions docs/tools/lazy-tool-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

const stream = chat({
adapter: openaiText("gpt-4o"),
adapter: openaiText("gpt-5.5"),
messages,
tools: [
getProducts, // Normal tool — sent to LLM immediately
Expand All @@ -94,6 +94,40 @@ const stream = chat({
return toServerSentEventsResponse(stream);
```

## Controlling the Discovery Catalog

By default, the `__lazy__tool__discovery__` tool's description lists only the
**names** of available lazy tools. The optional `lazyToolsConfig` on `chat()`
controls how much of each lazy tool's description appears in that pre-discovery
catalog:

```typescript
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [getProducts, searchProducts, compareProducts],
lazyToolsConfig: {
// 'none' (default) | 'first-sentence' | 'full'
includeDescription: "first-sentence",
},
});
```

| `includeDescription` | Catalog entry for `searchProducts` |
| -------------------- | ----------------------------------------------- |
| `'none'` (default) | `searchProducts` |
| `'first-sentence'` | `searchProducts — Search products by keyword.` |
| `'full'` | `searchProducts — <full description>` |

This only affects the **pre-discovery** catalog. Regardless of the setting, the
discovery tool's result always returns each tool's full description and argument
schema — `includeDescription` just tunes how much the LLM sees before it
decides what to discover. The default `'none'` keeps the catalog as lean as
possible.

`lazyToolsConfig` is optional and the same option is accepted by Code Mode's
`createCodeMode()` — see [Code Mode Lazy Tools](../code-mode/lazy-tools).

## When to Use Lazy Tools

Lazy tools are useful when:
Expand Down Expand Up @@ -208,7 +242,7 @@ export async function POST(request: Request) {
const { messages } = await request.json();

const stream = chat({
adapter: openaiText("gpt-4o"),
adapter: openaiText("gpt-5.5"),
messages,
tools: [getProducts, compareProducts, calculateFinancing],
agentLoopStrategy: maxIterations(20),
Expand Down
79 changes: 79 additions & 0 deletions packages/ai-code-mode/skills/ai-code-mode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ sources:
- 'TanStack/ai:docs/code-mode/code-mode-isolates.md'
- 'TanStack/ai:docs/code-mode/code-mode-with-skills.md'
- 'TanStack/ai:docs/code-mode/client-integration.md'
- 'TanStack/ai:docs/code-mode/lazy-tools.md'
---

> **Note**: This skill requires familiarity with ai-core and ai-core/chat-experience. Code Mode is always used on top of a chat experience.
Expand Down Expand Up @@ -328,6 +329,84 @@ Skill-specific events (when using `codeModeWithSkills`):
| `code_mode:skill_error` | Skill failed | `skill`, `error`, `duration` |
| `skill:registered` | New skill saved | `id`, `name`, `description` |

### 4. Lazy Tools

When a large tool catalog would bloat the `execute_typescript` system prompt, mark low-priority tools `lazy: true`. Lazy tools are kept out of the full type-stub documentation and listed in a compact "Discoverable APIs" catalog instead. All sandbox bindings are always injected — `lazy` defers documentation, not callability.

**Marking a tool lazy:**

```typescript
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const rarelyUsedTool = toolDefinition({
name: 'fetchStocks',
description: 'Get stock prices for a ticker. Returns a price quote.',
inputSchema: z.object({ ticker: z.string() }),
outputSchema: z.object({ price: z.number() }),
lazy: true, // <-- opt out of full system-prompt documentation
}).server(async ({ ticker }) => {
// ...
return { price: 0 }
})
```

**`createCodeMode` return shape:**

`createCodeMode()` returns `{ tool, discoveryTool, tools, systemPrompt }`. When lazy tools are present `discoveryTool` is a `discover_tools` server tool; otherwise it is `null`. Always spread `tools` (not just `tool`) into `chat()` so the discovery tool is registered:

```typescript
import { chat } from '@tanstack/ai'
import { createCodeMode } from '@tanstack/ai-code-mode'
import { createNodeIsolateDriver } from '@tanstack/ai-isolate-node'
import { openaiText } from '@tanstack/ai-openai'

const { tools, systemPrompt } = createCodeMode({
driver: createNodeIsolateDriver(),
tools: [eagerTool, rarelyUsedTool], // rarelyUsedTool has lazy: true
})

const stream = chat({
adapter: openaiText('gpt-5.5'),
systemPrompts: ['You are a helpful assistant.', systemPrompt],
tools: [...tools, ...otherTools], // spread tools, not just tool
messages,
})
```

`tools` equals `[tool]` when there are no lazy tools (backward compatible) and `[tool, discoveryTool]` when lazy tools exist.

**`discover_tools` flow:**

When the model encounters a lazy tool it has not seen before, it calls `discover_tools` with the bare name (no `external_` prefix). The tool returns each requested tool's TypeScript type stub and description. The model then writes correctly-typed `external_<name>` calls inside `execute_typescript`.

```text
Model sees: "Discoverable APIs: external_fetchStocks"
Model calls: discover_tools({ toolNames: ["fetchStocks"] })
Response: { tools: [{ name: "external_fetchStocks", description: "...", typeStub: "declare function external_fetchStocks(...)" }] }
Model writes inside execute_typescript: const result = await external_fetchStocks({ ticker: "AAPL" })
```

**`lazyToolsConfig.includeDescription`:**

Control how much of each lazy tool's description appears in the Discoverable APIs catalog (the pre-discovery list):

| Value | Catalog entry |
| ------------------ | ----------------------------------------------------------------- |
| `'none'` | `external_fetchStocks` (name only — default) |
| `'first-sentence'` | `external_fetchStocks — Get stock prices.` |
| `'full'` | `external_fetchStocks — Get stock prices. Returns a price quote.` |

```typescript
const { tools, systemPrompt } = createCodeMode({
driver: createNodeIsolateDriver(),
tools: [eagerTool, rarelyUsedTool],
lazyToolsConfig: { includeDescription: 'first-sentence' },
})
```

The same `lazyToolsConfig` option is accepted by plain `chat()` for its own lazy-tool discovery catalog (see `ai-core/tool-calling/SKILL.md`).

## Common Mistakes

### CRITICAL: Passing API keys or secrets to the sandbox environment
Expand Down
10 changes: 8 additions & 2 deletions packages/ai-code-mode/src/create-code-mode-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,17 @@ export function createCodeModeTool(
* Build the tool description including available external functions
*/
function buildToolDescription(tools: Array<CodeModeTool>): string {
const externalFunctions = tools.map((t) => `external_${t.name}`).join(', ')
const eager = tools.filter((t) => !t.lazy)
const hasLazy = tools.some((t) => t.lazy)
const externalFunctions = eager.map((t) => `external_${t.name}`).join(', ')

const discoverable = hasLazy
? ` Additional functions can be discovered via the discover_tools tool.`
: ''

return (
`Execute TypeScript code in a secure sandbox environment. ` +
`The code can use these external API functions: ${externalFunctions}. ` +
`The code can use these external API functions: ${externalFunctions}.${discoverable} ` +
`All external_* calls are async and must be awaited. ` +
`Return a value to pass results back. Use console.log() for debugging.`
)
Expand Down
Loading
Loading