Dynamic Tools
The Problem
In the AI SDK, a tool gives you a lot: typed input and output, custom UI, needsApproval for human-in-the-loop. But every tool you add eats context window. You can't keep adding tools forever. But you don't want to limit what the agent can do.
The Sandbox Approach
Give the model a computer. One tool, infinite capability.
const agent = new ToolLoopAgent({
model: 'anthropic/claude-sonnet-4',
tools: {
runCommand: tool({
description: 'Run a command in the sandbox',
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => sandbox.runCommand(command),
}),
},
});No context bloat. The model figures out the rest by navigating the filesystem. But you lose structure -- everything is opaque runCommand calls. No typed inputs, no custom UI, no meaningful way to put needsApproval on specific operations without approving every single command.
Dynamic Tools
Keep your tools in context for the things the model always needs -- sandbox, web search, knowledge base. For the rest, use a dynamicToolAn AI SDK v6 primitive for tools where input/output types aren't known at compile time. The model discovers their shape at runtime and the server validates inputs against typed schemas..
The model discovers dynamic tools by reading skill files in the sandbox filesystem -- same progressive disclosure it already does for everything else. When it finds one it wants to use, it calls a single dynamicTool with the tool ID and inputs.
Skill files in the sandbox
Each dynamic tool has a TOOL.md in the sandbox filesystem. The model reads them when it needs to figure out how to do something.
/tools/
send-slack-tool/
TOOL.md
create-jira-tool/
TOOL.md
generate-pdf-tool/
TOOL.md
fetch-api-tool/
TOOL.md{
"name": "send-slack-tool",
"description": "Send a message to a Slack channel or user",
"parameters": {
"channel": { "type": "string", "description": "Channel name or user ID" },
"message": { "type": "string", "description": "Message content" }
}
}No extra tool definitions in context. The model only pays for what it reads.
The execution layer
On the server, each dynamic tool has a typed implementation:
const dynamicTools = createDynamicTools({
'send-slack-tool': {
needsApproval: true,
inputSchema: z.object({
channel: z.string(),
message: z.string(),
}),
execute: async ({ channel, message }) => {
return await slack.chat.postMessage({ channel, text: message });
},
},
'fetch-api-tool': {
needsApproval: false,
inputSchema: z.object({
url: z.string().url(),
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).default('GET'),
}),
execute: async ({ url, method }) => {
const res = await fetch(url, { method });
return { status: res.status, data: await res.json() };
},
},
});createDynamicTools returns a single dynamicTool that validates inputs against the Zod schema and routes to the right handler:
// What createDynamicTools builds internally
function createDynamicTools(tools) {
return dynamicTool({
inputSchema: z.object({
toolId: z.string(),
inputs: z.record(z.unknown()),
}),
needsApproval: async ({ args }) => {
return tools[args.toolId]?.needsApproval ?? false;
},
execute: async ({ toolId, inputs }) => {
const def = tools[toolId];
const parsed = def.inputSchema.safeParse(inputs);
if (!parsed.success) return { error: parsed.error.issues };
return await def.execute(parsed.data);
},
// Control what the model sees -- e.g. offload large
// results to the sandbox filesystem instead of context
toModelOutput: async ({ toolCallId, output }) => { ... },
});
}Because it's a real tool definition, you get everything you'd get from a regular tool -- needsApproval, custom UI, type safety -- just without putting every schema in context.
The Agent
const agent = new ToolLoopAgent({
model: 'anthropic/claude-sonnet-4',
instructions: `You have a sandbox and tools.
Additional tools are documented in /tools/.
Read the TOOL.md to learn inputs, then call executeTool
with the tool ID and inputs.`,
tools: {
runCommand, // sandbox + discovery via filesystem
searchWeb, // always available
searchKnowledge, // always available
...dynamicTools, // executeTool
},
stopWhen: stepCountIs(20),
});Regular tools in context. Dynamic tools discovered through the filesystem. One dynamicTool for execution.
You Keep Everything
Because dynamic tools execute through a proper tool definition, not through runCommand, you keep:
- Custom UI per tool --
dynamic-toolparts carry the tool ID, so you route to a Slack preview, a Jira card, whatever needsApproval-- any dynamic tool can require confirmation. The definition decides, not the model.- Type safety -- each dynamic tool has a Zod schema. Inputs are validated. Types are inferrable for UI components.
- Large result handling --
toModelOutputcontrols what the model sees. If a result is too large for context, write it to the sandbox filesystem and tell the model where to find it. - Sandbox still works -- the model can still write code, process data, and query large results that were offloaded to the filesystem
Trade-offs
- Filesystem read cost -- discovering a dynamic tool costs a sandbox tool call to read the file. Same cost as any other filesystem operation the model already does.
- One type cast at the boundary -- the
dynamicToolpart arrives asunknown, but you can infer a discriminated union from the config and cast once. After that, switching ontoolIdnarrows the type like any other union. - Namespace collisions -- dynamic tool IDs live alongside other sandbox content in the filesystem. You need a naming convention (like the
-toolsuffix) to avoid collisions with other sandbox files and skills.
Not every tool needs to be in context all the time. Put the ones the model always needs in as regular tools. Put the rest in the filesystem as skills. Let the model discover what it needs. Execute through a dynamicTool so you keep type safety, custom UI, and needsApproval where you want it.