Toolsets are composable, per-turn tool collections. They let you group, filter, rename, intercept, and dynamically expose tools based on runtime context.
The tools array on an agent is simple and static: every tool in it is sent to the model on every turn. That works well when you have a handful of tools that are always relevant.Real applications are messier. You might have admin tools that only certain users can access. You might have tools that depend on which phase of a workflow the agent is in. You might be integrating tools from an external MCP server. You might want to add logging or rate-limiting across a whole group of tools without touching each one individually.Toolsets solve all of these. A toolset is an object with a single method, tools(ctx), that returns a list of tool definitions. Because it receives the RunContext, it can decide at the start of each turn which tools to expose. Agents accept any number of toolsets via the toolsets option, and Vibes merges them with the static tools array before each model call.
const agent = new Agent({ model: anthropic("claude-sonnet-4-6"), tools: [alwaysAvailableTool], // static, always present toolsets: [conditionalToolset], // resolved fresh each turn});
FunctionToolset is the simplest toolset: a plain wrapper around an array of tool definitions. Use it to logically bundle related tools and pass them as a unit.
import { Agent, FunctionToolset, plainTool, tool } from "jsr:@vibesjs/sdk";import { anthropic } from "@ai-sdk/anthropic";import { z } from "zod";const searchTool = plainTool({ name: "search", description: "Search documents", parameters: z.object({ query: z.string() }), execute: async ({ query }) => doSearch(query),});const fetchTool = plainTool({ name: "fetch_url", description: "Fetch a URL", parameters: z.object({ url: z.string().url() }), execute: async ({ url }) => fetchPage(url),});// (1) Group them into a named collectionconst webTools = new FunctionToolset([searchTool, fetchTool]);// (2) Add tools after construction if neededwebTools.addTool(anotherTool);const agent = new Agent({ model: anthropic("claude-sonnet-4-6"), toolsets: [webTools], // (3) pass the toolset});
FunctionToolset takes an optional array in the constructor.
addTool() lets you add tools incrementally — useful when building a toolset dynamically.
FilteredToolset wraps another toolset and hides it entirely when a predicate returns false. Use it when a whole category of tools should be invisible to the model based on the caller’s context — user role, feature flags, environment, and so on.
import { FilteredToolset, FunctionToolset } from "jsr:@vibesjs/sdk";type Deps = { user: { isAdmin: boolean } };const adminTools = new FunctionToolset([deleteRecordTool, exportDataTool]);// (1) Only expose admin tools when the user is an adminconst gatedAdminTools = new FilteredToolset( adminTools, (ctx: RunContext<Deps>) => ctx.deps.user.isAdmin, // (2) predicate);
The wrapped adminTools is either shown in full or hidden entirely — there’s no partial exposure.
The predicate receives the RunContext and can be async. Return true to expose the inner toolset, false to hide it.
When false, the model won’t see any of these tools. It won’t try to call them and won’t know they exist.
FilteredToolset is all-or-nothing at the toolset level. If you need to filter individual tools within a toolset based on state, use PreparedToolset instead.
PreparedToolset gives you fine-grained control over which tools inside a toolset are exposed each turn. The prepare function receives the full RunContextand the complete list of tools from the inner toolset, and returns whichever tools to expose.
import { FunctionToolset, PreparedToolset } from "jsr:@vibesjs/sdk";type Deps = { workflow: { currentPhase: "research" | "write" | "review"; confirmed: boolean } };const allTools = new FunctionToolset([searchTool, draftTool, editTool, deleteTool]);const phaseAwareTools = new PreparedToolset( allTools, (ctx: RunContext<Deps>, tools) => { const { currentPhase, confirmed } = ctx.deps.workflow; // (1) Filter to tools relevant for the current phase const phaseTools = tools.filter((t) => { if (currentPhase === "research") return t.name === "search"; if (currentPhase === "write") return t.name === "draft" || t.name === "search"; return true; // review phase: all tools available }); // (2) Also hide "delete" unless the user has confirmed return confirmed ? phaseTools : phaseTools.filter((t) => t.name !== "delete"); },);
The tools parameter is the full resolved list from the inner toolset — you return a subset.
You can apply multiple conditions in sequence. The function just returns an array.
When you combine tools from multiple sources, name collisions become a real risk. PrefixedToolset prepends a string to every tool name in the wrapped toolset, giving you a clean namespace.
import { FunctionToolset, PrefixedToolset } from "jsr:@vibesjs/sdk";const githubTools = new FunctionToolset([searchReposTool, createIssueTool]);const linearTools = new FunctionToolset([searchIssuesTool, createIssueTool]); // same name!// (1) Namespace each toolset to avoid the collisionconst namespacedGitHub = new PrefixedToolset(githubTools, "github_");const namespacedLinear = new PrefixedToolset(linearTools, "linear_");// Now the model sees "github_search_repos", "github_create_issue",// "linear_search_issues", "linear_create_issue"
The prefix is prepended to every tool name. "search_repos" becomes "github_search_repos". The model’s tool call will use the prefixed name, and Vibes routes it correctly.
For renaming specific tools rather than all of them, use RenamedToolset:
import { RenamedToolset } from "jsr:@vibesjs/sdk";const renamed = new RenamedToolset(myToolset, { legacy_search: "search", // rename legacy_search → search old_fetch: "fetch_document", // rename old_fetch → fetch_document // other tools are unchanged});
WrapperToolset is an abstract base class that intercepts every tool call in the wrapped toolset. Subclass it and implement callTool to add cross-cutting behavior — logging, metrics, rate limiting, error transformation, argument sanitization — without touching the underlying tools.
import { WrapperToolset } from "jsr:@vibesjs/sdk";import type { ToolCallNext } from "jsr:@vibesjs/sdk";import type { RunContext } from "jsr:@vibesjs/sdk";type Deps = { logger: { info(msg: string, meta?: object): void } };// (1) Extend WrapperToolset with your deps typeclass LoggingToolset extends WrapperToolset<Deps> { async callTool( ctx: RunContext<Deps>, toolName: string, args: Record<string, unknown>, next: ToolCallNext<Deps>, // (2) next invokes the original tool ) { ctx.deps.logger.info(`tool:start ${toolName}`, { args }); const start = Date.now(); try { const result = await next(ctx, args); // (3) delegate to the actual tool ctx.deps.logger.info(`tool:ok ${toolName}`, { ms: Date.now() - start }); return result; } catch (err) { ctx.deps.logger.info(`tool:error ${toolName}`, { ms: Date.now() - start, err }); throw err; // (4) re-throw so the error propagates normally } }}const logged = new LoggingToolset(myFunctionToolset);
Provide the deps type as a generic parameter so ctx.deps is properly typed inside callTool.
next is a function that invokes the original tool’s execute. You must call it (unless you want to short-circuit).
You can modify args before calling next, or transform the result it returns.
Re-throwing preserves normal error handling. You can also wrap in a custom error type here.
CombinedToolset merges any number of toolsets into one. The resolved tools from all member toolsets are combined into a single flat list. If two toolsets expose a tool with the same name, the last one wins.
import { CombinedToolset } from "jsr:@vibesjs/sdk";const combined = new CombinedToolset( searchToolset, // contributes "search", "index" fetchToolset, // contributes "fetch_url", "fetch_page" utilityToolset, // contributes "format_date", "parse_json");// Agent sees all 6 tools merged into one listconst agent = new Agent({ model, toolsets: [combined] });
CombinedToolset is especially useful when you want to aggregate several independently-defined toolsets and expose them as a single unit — for example, packaging a plugin or feature module.
ExternalToolset — tools that execute outside the agent
Sometimes a tool must run in a different environment than the agent’s process. A browser extension needs to manipulate the DOM. A desktop app needs to read local files. A sandboxed service needs to call a restricted API.ExternalToolset handles this. Each tool is described with a JSON Schema instead of Zod. When the model calls any of these tools, the run pauses and throws an ApprovalRequiredError containing the pending DeferredToolRequests. The caller executes the tools externally, collects the results, and calls agent.resume() to continue.
import { Agent, ExternalToolset } from "jsr:@vibesjs/sdk";import { ApprovalRequiredError } from "jsr:@vibesjs/sdk";import { anthropic } from "@ai-sdk/anthropic";// (1) Define tools with plain JSON Schema — no Zod neededconst browserTools = new ExternalToolset([ { name: "click_element", description: "Click a DOM element by CSS selector", jsonSchema: { type: "object", properties: { selector: { type: "string" } }, required: ["selector"], }, }, { name: "read_text", description: "Read the text content of an element", jsonSchema: { type: "object", properties: { selector: { type: "string" } }, required: ["selector"], }, },]);const agent = new Agent({ model: anthropic("claude-sonnet-4-6"), toolsets: [browserTools],});// (2) The run throws when the model calls any external tooltry { await agent.run("Click the submit button and read the confirmation message");} catch (err) { if (err instanceof ApprovalRequiredError) { // (3) err.deferred.requests contains the pending tool calls const results = await executeInBrowser(err.deferred.requests); // (4) Resume with the results — the run continues from where it paused const finalResult = await agent.resume(err.deferred, { results }); console.log(finalResult.output); }}
Tool definitions use raw JSON Schema objects — no Zod dependency in the external environment.
The run pauses immediately when an external tool is called.
err.deferred.requests is an array of { toolName, args, toolCallId } objects describing what the model wants to do.
agent.resume() feeds the results back and continues the run. The run may pause again if more external tool calls follow.
See Human-in-the-Loop for the full DeferredToolRequests / DeferredToolResults API and patterns for multi-step external execution.
The Model Context Protocol (MCP) is an open standard for exposing tools from external servers. MCPToolset connects to an MCP server and exposes all of its tools as a Vibes toolset. Tools are discovered lazily on the first call and cached for 60 seconds by default.
import { Agent } from "jsr:@vibesjs/sdk";import { MCPStdioClient, MCPToolset } from "jsr:@vibesjs/sdk/mcp";import { anthropic } from "@ai-sdk/anthropic";// (1) Create and connect an MCP client (stdio transport launches a subprocess)const client = new MCPStdioClient({ command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/sandbox"],});await client.connect();// (2) Wrap it in a toolsetconst fsToolset = new MCPToolset(client);const agent = new Agent({ model: anthropic("claude-sonnet-4-6"), toolsets: [fsToolset],});await agent.run("List the files in /tmp/sandbox and summarize their contents");// (3) Always disconnect when doneawait client.disconnect();
MCPStdioClient spawns the MCP server as a child process. Use MCPHttpClient for HTTP-based servers.
MCPToolset wraps the client and implements the Toolset interface. Tools are fetched from the server and cached.
Disconnect the client when the agent is done to avoid leaving zombie processes.
For connecting to multiple MCP servers at once, use MCPManager:
import { MCPManager, MCPStdioClient, MCPHttpClient } from "jsr:@vibesjs/sdk/mcp";const manager = new MCPManager();manager.addServer(new MCPStdioClient({ command: "npx", args: ["-y", "my-local-server"] }));manager.addServer(new MCPHttpClient({ url: "https://mcp.example.com" }));await manager.connect(); // connects all servers in parallelconst agent = new Agent({ model, toolsets: [manager] });await agent.run("...");await manager.disconnect();
You can also load server configurations from a JSON file (compatible with Claude Desktop format):
All toolset classes implement the same Toolset<TDeps> interface, so they compose freely. You can nest them to build precisely the behavior you need:
import { CombinedToolset, FilteredToolset, FunctionToolset, PrefixedToolset, WrapperToolset,} from "jsr:@vibesjs/sdk";type Deps = { user: { isAdmin: boolean }; logger: Console };// Start with raw toolsconst coreTools = new FunctionToolset([searchTool, fetchTool]);const adminTools = new FunctionToolset([deleteRecordTool, exportDataTool]);// Gate admin tools by roleconst gatedAdminTools = new FilteredToolset( adminTools, (ctx: RunContext<Deps>) => ctx.deps.user.isAdmin,);// Namespace to avoid collisionsconst namespacedAdmin = new PrefixedToolset(gatedAdminTools, "admin_");// Add logging around everythingclass LoggingToolset extends WrapperToolset<Deps> { async callTool(ctx, toolName, args, next) { ctx.deps.logger.log(`→ ${toolName}`); const result = await next(ctx, args); ctx.deps.logger.log(`← ${toolName}`); return result; }}// Combine into a single toolsetconst allTools = new LoggingToolset( new CombinedToolset(coreTools, namespacedAdmin),);const agent = new Agent({ model, toolsets: [allTools] });
The composition reads from the outside in: LoggingToolset wraps a CombinedToolset that merges coreTools with namespacedAdmin. Each layer adds a single concern. None of the layers need to know about the others.
Tools
Build individual tools with the tool() factory
Human-in-the-Loop
Pause and resume runs for external tool execution and approval