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 and Standard 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
V0shape is frozen — a breaking change would beStandardToolV1; the reference package follows its own0.xsemver. Critiques and counter-proposals welcome.
Why one type, how existing tool objects compare, and the case against: WHY.md.
Defining a tool
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:
| Call it directly | Use it as an AI tool | Render docs or a prompt |
|---|---|---|
await getWeather
.execute({ city: 'Paris' });
// { tempC: 21 }
|
getWeather.inputSchema
?.['~standard'].jsonSchema
.input({ target });
// → the provider's schema field
await getWeather.execute(args);
// → result for the model
|
getDocs([getWeather]);
// your renderer ↓
# Available functions
- get_weather({ city: string })
=> { tempC: number }:
Current temperature for a city
|
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
| field | type | purpose |
|---|---|---|
name |
string |
identifier the model emits |
description |
string |
what the tool does |
title? |
string |
human label for MCP-style tool lists; ignored by plain function-calling APIs |
inputSchema? |
StandardSchemaV1<Input, unknown> & StandardJSONSchemaV1<Input, unknown> |
validates and emits JSON Schema; Input is its input side |
outputSchema? |
StandardSchemaV1<unknown, Output> & StandardJSONSchemaV1<unknown, Output> |
validates and emits JSON Schema; Output is its output side |
meta? |
Record<string, unknown> |
static data about the tool; read by consumers, never passed to execute |
execute |
(input: Input, context?: Context) => FormattedOutput | Promise<FormattedOutput> |
runs the tool; input untrusted until checked against inputSchema; may throw |
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-toolstandardTool(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:
| Consumer | schema field | target |
result goes back as |
|---|---|---|---|
| OpenAI | parameters |
draft-2020-12 |
function_call_output item |
| Anthropic | input_schema |
draft-2020-12 |
tool_result block |
| Gemini | parameters (or parametersJsonSchema) |
openapi-3.0 |
functionResponse part |
| Vercel AI SDK | inputSchema — takes the Standard Schema as-is |
— | SDK runs the loop |
| MCP | inputSchema in the descriptor |
draft-2020-12 |
{ content, structuredContent?, isError? } — map the result and errors onto it |
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