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:

Trade-offs


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.