toolwire
v0.1.4
Published
Framework-agnostic tool registry for LLM agents. Define tools with Zod schemas, validate arguments, export to OpenAI, Anthropic, Gemini, or Vercel AI.
Maintainers
Readme
toolwire
Framework-agnostic tool registry for LLM agents.
Define tools once with Zod schemas. Get input validation, structured error messages the LLM can act on, and one-line schema export to OpenAI, Anthropic, Gemini, or Vercel AI — with zero runtime dependencies.
npm install toolwire zodThe problem
Every team building an LLM agent writes the same three pieces of boilerplate:
- A JSON schema for each tool
- Validation of the LLM's arguments before calling the function
- An error message the LLM can understand and retry
And they do it differently every time, for every framework, in every project. toolwire is the standard.
Quick start
import { tool, registry } from 'toolwire';
import { z } from 'zod';
// 1. Define a tool
const searchWeb = tool({
name: 'search_web',
description: 'Search the web for current information',
input: z.object({
query: z.string().min(1).describe('The search query'),
maxResults: z.number().int().min(1).max(20).default(5),
}),
handler: async ({ query, maxResults }) => {
return await mySearchAPI(query, maxResults);
},
timeout: 10_000,
retries: 2,
});
// 2. Create a registry
const reg = registry([searchWeb, readFile, writeFile]);
// 3. Use with any LLM provider
const openaiTools = reg.toOpenAI();
const anthropicTools = reg.toAnthropic();
// 4. Execute a tool call — always resolves, never throws
const result = await reg.call(llmToolCall);
if (result.success) {
// result.data is the validated return value
messages.push({ role: 'tool', content: JSON.stringify(result.data) });
} else {
// result.error.llmMessage is pre-formatted for the LLM to retry
messages.push({ role: 'tool', content: result.error.llmMessage });
}Features
- Type-safe — Zod schemas infer TypeScript types end-to-end
- Input + output validation — validate arguments in, validate results out
- LLM-readable errors — every failure includes a
llmMessageready to append to messages - Four provider adapters — OpenAI, Anthropic, Gemini, Vercel AI SDK
- Middleware — hook into beforeCall / afterCall / onError for logging, auth, caching
- Hot-swap — replace a tool at runtime without restarting the agent
- Timeout + retries — configurable per-tool, with exponential backoff
- Runtime discovery — load tools from a directory or remote manifest
- Zero runtime dependencies — only Zod (peer dep) required
API
tool(config)
Define a tool. Returns a frozen ToolDefinition with pre-computed JSON Schema.
const myTool = tool({
name: 'my_tool', // 1–64 chars: letters, digits, _ or -
description: string, // shown to the LLM — explain when to use this tool
input: ZodSchema, // validates LLM arguments
output?: ZodSchema, // optional — validates handler return value
handler: async (input, context) => { ... },
timeout?: number, // ms, default 30_000
retries?: number, // additional attempts on execution failure, default 0
annotations?: { // informational hints (not enforced)
readOnly?: boolean,
destructive?: boolean,
expensive?: boolean,
requiresConfirmation?: boolean,
},
});The context object passed to the handler:
interface ToolContext {
signal: AbortSignal; // tied to the timeout — honour this for cooperative cancellation
attempt: number; // 0 = first try, 1 = first retry, …
}registry(tools, options?)
Create a registry from an array of tool definitions.
const reg = registry([searchWeb, readFile, writeFile], {
defaultTimeout: 15_000, // fallback timeout for tools that don't set their own
});reg.call(request)
Execute a tool call from an LLM. Always resolves — never throws.
const result = await reg.call({
name: 'search_web',
arguments: { query: 'TypeScript tips', maxResults: 5 },
});
// ToolResult is a discriminated union
if (result.success) {
console.log(result.data); // validated return value
console.log(result.durationMs); // wall time in ms
} else {
console.log(result.error.code); // error category
console.log(result.error.message); // developer-readable message
console.log(result.error.llmMessage); // ready to send back to the LLM
console.log(result.error.retryable); // should the LLM retry?
}Error codes:
| Code | When | Retryable |
|------|------|-----------|
| NOT_FOUND | Tool name not registered | ✓ |
| DISABLED | Tool is currently disabled | ✗ |
| VALIDATION_INPUT | Arguments fail Zod schema | ✓ |
| VALIDATION_OUTPUT | Return value fails output schema | ✗ |
| TIMEOUT | Handler exceeded timeout | ✓ |
| EXECUTION | Handler threw (all retries exhausted) | ✗ |
Provider adapters
Export tool schemas in whatever format your LLM provider expects. All adapters exclude disabled tools.
// OpenAI function-calling
await openai.chat.completions.create({
model: 'gpt-4o',
tools: reg.toOpenAI(),
// or with strict mode:
tools: reg.toOpenAI({ strict: true }),
messages,
});
// Anthropic tool-use
await anthropic.messages.create({
model: 'claude-opus-4-6',
tools: reg.toAnthropic(), // uses input_schema key
messages,
});
// Google Gemini
await model.generateContent({
tools: [reg.toGemini()], // wraps in functionDeclarations
contents,
});
// Vercel AI SDK
const { text } = await generateText({
model: openai('gpt-4o'),
tools: reg.toVercelAI(), // passes Zod schemas directly
prompt,
});Standalone adapter functions are also exported for use outside a registry:
import { toOpenAI, toAnthropic, toGemini, toVercelAI } from 'toolwire';
const schemas = toOpenAI([searchWeb, readFile]);Middleware
Add hooks for logging, authentication, caching, or tracing.
reg.use({
name: 'logger', // optional — used in error messages
// Runs before execution, in registration order
// Return a value to transform the arguments, or void to keep them
beforeCall: (toolName, args) => {
console.log(`→ ${toolName}`, args);
},
// Runs after success, in reverse registration order
// Return a ToolSuccess to transform the result, or void to keep it
afterCall: (toolName, args, result) => {
console.log(`← ${toolName} (${result.durationMs}ms)`);
tracer.record(toolName, result.data);
},
// Runs on any failure
// Return a ToolResult to recover from the error, or void to propagate it
onError: (toolName, args, failure) => {
alerting.send(toolName, failure.error);
// return a ToolResult here to recover, or return nothing to propagate
},
});Multiple middleware are chained — beforeCall runs in order, afterCall in reverse (stack-style):
reg
.use({ name: 'auth', beforeCall: checkAuth })
.use({ name: 'cache', beforeCall: checkCache, afterCall: writeCache })
.use({ name: 'metrics', afterCall: recordMetrics });Hot-swapping tools
Replace a registered tool in-place without restarting the agent:
// Start with the live implementation
const reg = registry([searchWeb]);
// Mid-run: swap to a cached version
reg.swap('search_web', cachedSearchWeb);
// Disable a tool temporarily (returns DISABLED error if called)
reg.disable('send_email');
reg.enable('send_email');
// Add new tools at any time
reg.register(newTool);reg.describe()
Generate a human-readable tool list for injecting into a system prompt:
const systemPrompt = `You have access to the following tools:\n${reg.describe()}`;
// → "- search_web: Search the web for current information"
// "- calculate: Evaluate a mathematical expression"ToolRegistry.fromDir(path)
Load tools from a directory of compiled JavaScript files:
const reg = await ToolRegistry.fromDir('./tools/');Each file may export:
// Option A: default export
export default tool({ name: 'my_tool', ... });
// Option B: named `tools` array
export const tools = [tool({ ... }), tool({ ... })];
// Option C: any named export that is a ToolDefinition
export const myTool = tool({ ... });Only .js, .mjs, and .cjs files are scanned. Files that fail to import are skipped with a warning.
ToolRegistry.fromManifest(url)
Load tools from a remote JSON manifest and proxy calls over HTTP:
const reg = await ToolRegistry.fromManifest('https://tools.mycompany.com/manifest.json');Manifest format:
{
"version": "1.0",
"tools": [
{
"name": "search_web",
"description": "Search the web",
"inputSchema": { "type": "object", "properties": { ... } },
"endpoint": "https://api.mycompany.com/tools/search"
}
]
}TypeScript
tool() infers input and output types from your Zod schemas — no explicit generics needed:
const greet = tool({
name: 'greet',
description: 'Greet someone',
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
handler: async ({ name }) => ({ message: `Hello, ${name}!` }),
// ^^^^ typed as { name: string }
// ^^^^ typed as { message: string }
});Use the inference helpers to extract types from definitions:
import type { InferInput, InferOutput } from 'toolwire';
type GreetInput = InferInput<typeof greet>; // { name: string }
type GreetOutput = InferOutput<typeof greet>; // { message: string }Provider format reference
| Field | OpenAI | Anthropic | Gemini | Vercel AI |
|-------|--------|-----------|--------|-----------|
| Schema key | parameters | input_schema | parametersJsonSchema | Zod schema |
| Wrapper | { type: "function", function: {...} } | direct object | { functionDeclarations: [...] } | Record by name |
| Strict mode | strict?: boolean | — | — | — |
Zod v3 support
Zod v4 (the default) has built-in JSON Schema generation. For Zod v3 support, install the optional peer:
npm install zod-to-json-schematoolwire detects the Zod version automatically and uses the right conversion path.
Complete agent loop example
import Anthropic from '@anthropic-ai/sdk';
import { registry, tool } from 'toolwire';
import { z } from 'zod';
const searchTool = tool({
name: 'search_web',
description: 'Search the web for current information',
input: z.object({ query: z.string().min(1) }),
handler: async ({ query }) => ({ results: await mySearch(query) }),
});
const reg = registry([searchTool]).use({
beforeCall: (name, args) => console.log(`→ ${name}`, args),
afterCall: (name, _, r) => console.log(`← ${name} ${r.durationMs}ms`),
});
const client = new Anthropic();
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: 'What are the latest TypeScript features?' },
];
while (true) {
const response = await client.messages.create({
model: 'claude-opus-4-6',
max_tokens: 1024,
tools: reg.toAnthropic(),
messages,
});
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') break;
// Process tool calls
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
const result = await reg.call({ name: block.name, arguments: block.input });
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: result.success
? JSON.stringify(result.data)
: result.error.llmMessage,
is_error: !result.success,
});
}
messages.push({ role: 'user', content: toolResults });
}License
MIT
