interface StandardToolV0< Input = unknown, Output = unknown, FormattedOutput = Output, Context = unknown,
{ name: string; title?: string; // human label; shown by MCP-style clients in tool lists description: string; inputSchema?: StandardSchemaV1<Input, unknown> & StandardJSONSchemaV1<Input, unknown>; outputSchema?: StandardSchemaV1<unknown, Output> & StandardJSONSchemaV1<unknown, Output>; meta?: Record<string, unknown>; // static data about the tool, for consumers to read execute(input: Input, context?: Context): FormattedOutput | Promise; }
`StandardToolV0` is the shape of a **self-describing function**: a callable together with its name, description, and schemas. Any object of this shape conforms; nothing is required beyond a schema library implementing [Standard Schema](https://standardschema.dev) and [Standard JSON Schema](https://standardschema.dev/json-schema) (Zod 4.2+, ArkType 2.1.28+, Valibot via `@valibot/to-json-schema`). The schemas provide static types, runtime validation, and JSON Schema emission via `inputSchema['~standard'].jsonSchema.input({ target })`. The npm package is a reference implementation.
> **Status: RFC.** The `V0` shape is frozen — a breaking change would be `StandardToolV1`; the reference package follows its own `0.x` semver. [Critiques and counter-proposals welcome.](https://github.com/finom/standard-tool/issues)
Why one type, how existing tool objects compare, and the case against: **[WHY.md](./WHY.md)**.
## Defining a tool
```ts
import { z } from 'zod'; // or arktype, or valibot
import type { StandardToolV0 } from 'standard-tool'; // types only — or paste the interface above
const getWeather: StandardToolV0<{ city: string }, { tempC: number }> = {
name: 'get_weather',
description: 'Current temperature for a city',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ tempC: z.number() }),
execute: async ({ city }) => ({ tempC: 21 }),
};
Three ways to use it:
Provider field names and dialects: wiring table. The same fields also serve UIs, forms, and CLIs: Beyond LLM tools. Tools can be derived from RPC procedures you already have: tRPC, oRPC.
Per-call context
context is execute's optional second argument — per-call data like a locale, an auth token, a request-scoped handle. Never validated, never in the JSON Schema. Annotate it on the handler (execute: (input, context: { locale: string }) => …) and it types every caller.
Tool-level meta
meta is per-tool data consumers read and execute never sees: { destructive: true, tags: ['fs'] } — confirmation hints, tool selection, ownership. Untyped by design (a generic erases to unknown in StandardToolV0[]); narrow with & { meta: { budget: number } } when you want types. The spec fixes no keys — agree within your system, or follow MCP's tool annotations.
The interface
The reference implementation
Copy src/index.ts into your project — ~90 lines — replacing its first import with the types-only @standard-schema/spec. Or install the package:
npm i standard-tool
standardTool(def)returns the definition with validation wired intoexecute: input checked before the handler runs, output after. The handler receives the validated input (the input schema's output side) and returns the raw result the output schema validates. Violations throwStandardToolValidationError, carryingtarget: 'input' | 'output'and the Standard Schemaissues.withFormattedOutput(tool, format?)is the bare-catchrecipe written once, with types: a throw insideexecutereaches the caller as data.
import { standardTool, withFormattedOutput } from 'standard-tool';
import { z } from 'zod';
const getWeather = standardTool({
name: 'get_weather',
description: 'Current temperature for a city',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ tempC: z.number() }),
execute: async ({ city }) => ({ tempC: 21 }),
}); // execute validates input and output
await getWeather.execute({ city: 123 } as never); // throws StandardToolValidationError
await withFormattedOutput(getWeather).execute({ city: 123 } as never);
// { error: 'input validation failed: city: …' }
const asText = withFormattedOutput(getWeather, (r) =>
r instanceof Error ? `error: ${r.message}` : `${r.tempC}°C`);
await asText.execute({ city: 'Paris' }); // '21°C'
The formatter receives the validated Output or an Error, runs once per call, and its own throws propagate unformatted. It accepts only tools whose execute still returns the plain Output, so wrapping an already-wrapped tool is a compile error. Frameworks with their own formatting hook (toModelOutput in the AI SDK, Mastra) don't need it — hand them the tool unwrapped.
Wiring a provider
Every integration hands the provider the same descriptor — name, description, and the emitted JSON Schema — then runs execute on the model's call. Typed here for Anthropic; the table maps the rest:
import type Anthropic from '@anthropic-ai/sdk';
const descriptor: Anthropic.Tool = {
name: tool.name,
description: tool.description,
input_schema: (tool.inputSchema?.['~standard'].jsonSchema
.input({ target: 'draft-2020-12' }) ??
{ type: 'object', properties: {} }) as Anthropic.Tool.InputSchema,
};
// execute throws on failure; catch to hand the model something to correct from
let result: unknown;
try { result = await tool.execute(args); }
catch (e) { result = { error: e instanceof Error ? e.message : String(e) }; }
What varies is where the schema goes and which dialect:
Links
- Standard Schema · Standard JSON Schema ·
@standard-schema/spec - OpenAI function calling · Anthropic tool use · Gemini function calling · MCP tools
- Vercel AI SDK
tool()· MastracreateTool· Genkit · LangChain
License
MIT © Andrey Gubanov