If you give an LLM access to your code, you write tools. A tool is a function plus what a model needs to call it: a name, a description, and a schema for the arguments.
Then you write the same tools again. The first version uses the AI SDK's tool(). The project adds an MCP server, so the tools are rewritten for registerTool. Another team uses Genkit, and the tools are written a third time with defineTool. The functions stay the same. Only the wrapper changes.
Each wrapper is also tied to its framework. tool() comes from the ai package, defineTool is a method of a Genkit instance, and registerTool is a method of the MCP SDK's McpServer. A library that wants to ship tools has to choose one framework, and everyone who uses the library installs it.
Without the framework, a tool is a function that describes itself: the function, plus a name, a description, and schemas for its input and output. That is enough for a model to decide when and how to call it. It is also enough to generate docs, build a form, or add a CLI command. Written as a plain object with those fields, a tool belongs to your code. Moving it to another framework takes a small adapter instead of a rewrite.
The hardest part of that object is the schemas, and the schemas are already standardized.
What is Standard Schema?
Standard Schema is a TypeScript interface for validation libraries, designed by the creators of Zod, Valibot, and ArkType. Code that accepts a Standard Schema works with a schema from any library that implements it, with no adapter per library.
The whole interface is one property, ~standard. Trimmed to its fields:
interface StandardSchemaV1<Input = unknown, Output = Input> {
readonly '~standard': {
readonly version: 1;
readonly vendor: string;
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
readonly types?: { readonly input: Input; readonly output: Output };
};
}
// Result<Output> is { value: Output } on success and { issues: Issue[] } on failure.
Validation code is the same for every library:
const result = await schema['~standard'].validate(data);
if (result.issues) throw new Error(result.issues.map((issue) => issue.message).join('; '));
const value = result.value; // typed as the schema's output
More than 30 libraries implement the spec, including Zod, Valibot, ArkType, yup, and joi. More than 60 tools accept it, including tRPC, TanStack Form and Router, Hono, Elysia, oRPC, and React Hook Form.
The spec is types only. The @standard-schema/spec package has no runtime code, and a library may copy the interface instead of depending on the package.
What is Standard JSON Schema?
Validation is half of what a tool needs from its schemas. The other half is JSON Schema: a model needs a JSON Schema of the arguments before it can call a tool.
Standard JSON Schema is a companion spec by the same authors. It adds a JSON Schema converter under the same ~standard property:
schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
schema['~standard'].jsonSchema.output({ target: 'openapi-3.0' });
target selects the JSON Schema dialect, because consumers need different ones. OpenAI, Anthropic, and MCP take JSON Schema draft 2020-12. Gemini's parameters field takes the OpenAPI 3.0 format.
input and output are separate because a schema can transform values. A schema that accepts "42" and returns 42 has one JSON Schema for its input and another for its output.
The two specs are independent, and an object can implement either or both. Zod 4.2+ and ArkType 2.1.28+ schemas implement both. In Valibot 1.2+, toStandardJsonSchema() from @valibot/to-json-schema wraps a schema so that it implements both.
What is Standard Tool?
Once the schemas validate and emit JSON Schema on their own, the rest of a tool is a name, a description, and a function. That part has no standard, so every framework defines its own object for it.
StandardToolV0 is a proposal for that object:
import type { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';
interface StandardToolV0<
Input = unknown, Output = unknown, FormattedOutput = Output, Context = unknown,
> {
name: string;
title?: string;
description: string;
inputSchema?: StandardSchemaV1<Input, unknown> & StandardJSONSchemaV1<Input, unknown>;
outputSchema?: StandardSchemaV1<unknown, Output> & StandardJSONSchemaV1<unknown, Output>;
meta?: Record<string, unknown>;
execute(input: Input, context?: Context): FormattedOutput | Promise<FormattedOutput>;
}
-
nameis the identifier the model uses to call the tool. -
descriptiontells the model what the tool does and when to use it. -
titleis an optional label for people, which MCP clients can show in tool lists. -
inputSchemaandoutputSchemamust implement both specs, so each one validates and emits JSON Schema.Inputis the input side of the input schema, andOutputis the output side of the output schema, so schemas that transform values fit. -
metais static data about the tool, such as{ destructive: true }. Consumers read it, andexecutenever sees it. -
executeruns the tool. Its optional second argument,context, carries per-call data such as a locale or an auth token.contextis not validated and does not appear in the JSON Schema. -
FormattedOutputis whatexecutereturns when a wrapper changes the result, for example to return errors as data. It defaults toOutput.
Like the two specs, StandardToolV0 is a type. Any object with these fields conforms:
import { z } from 'zod'; // or ArkType, or Valibot
import type { StandardToolV0 } from 'standard-tool';
export 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: await fetchTemperature(city) }),
};
The import is types only, and you can paste the interface into your project instead.
The standard-tool package also contains an optional reference implementation of about 90 lines. standardTool() wraps a definition so that execute validates the input before your function runs and the output after it, and throws StandardToolValidationError on a mismatch. withFormattedOutput() catches errors and returns them as data, so a model can read what went wrong.
How it compares
Every framework has this object. The differences are mostly names and argument positions:
| Package | Identifier | Input schema | Output schema | Function | |
|---|---|---|---|---|---|
| AI SDK | ai |
key in the tools object | inputSchema |
outputSchema |
execute |
| Mastra | @mastra/core |
id |
inputSchema |
outputSchema |
execute |
| Genkit | genkit |
name |
inputSchema |
outputSchema |
2nd argument of defineTool
|
| LangChain | @langchain/core |
name |
schema |
none | 1st argument of tool
|
| MCP SDK | @modelcontextprotocol/sdk |
1st argument of registerTool
|
inputSchema |
outputSchema |
3rd argument of registerTool
|
StandardToolV0 |
none, it is a type | name |
inputSchema |
outputSchema |
execute |
What each framework accepts as a schema differs more:
- AI SDK: Standard Schema, Zod, or JSON Schema
- Mastra: Standard Schema with Standard JSON Schema, Zod, or JSON Schema
- Genkit: Zod or JSON Schema
- LangChain: Zod or JSON Schema
- MCP SDK: Zod only
Checked against ai 7.0, @mastra/core 1.72, genkit 1.42, @langchain/core 1.2, and @modelcontextprotocol/sdk 1.31.
The objects look alike, but they are not interchangeable, and each one needs its framework's package. As a rule, moving a tool to another framework means rewriting its wrapper. Mastra is an exception in one direction: its agents also accept AI SDK tools. Reusing a tool written for another framework means installing that framework.
This matters even with one framework. A framework's tool object is made for that framework. A plain object can also be called from a script or a test, read by a docs generator, or exported from a library whose users don't install your framework.
How it can be used
The object has more than one reader. A model is one of them.
Call it. execute is a function:
const { tempC } = await getWeather.execute({ city: 'Paris' });
Give it to a model. name, description, and the JSON Schema from inputSchema become the provider's tool definition. When the model calls the tool, its arguments go to execute. The next section shows this per provider.
Read it. The fields are enough for reference docs, a list of tools in a prompt, a form built from inputSchema, or a CLI command:
function describeTools(tools: StandardToolV0[]) {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
input: tool.inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12' }),
output: tool.outputSchema?.['~standard'].jsonSchema.output({ target: 'draft-2020-12' }),
}));
}
Ship it from a library. A library can export tools as ordinary values:
export const getOrders: StandardToolV0<{ userId: string }, Order[]> = {
name: 'get_orders',
description: "List a user's orders",
inputSchema: z.object({ userId: z.string() }),
execute: ({ userId }) => api.get(`/orders/${userId}`),
};
The library's users can run the tool, document it, or give it to a model, and the library depends on no AI framework.
Reuse RPC procedures. A tRPC or oRPC procedure already has input and output schemas and a handler. If its schemas implement Standard JSON Schema, they become the tool's schemas, and execute calls the procedure through the framework's server-side caller. Example with tRPC.
Adapting to frameworks and models
Every integration does two things. It builds the provider's tool definition from name, description, and the JSON Schema. Then, when the model calls the tool, it runs execute and sends the result back. Only the field names and the JSON Schema dialect change between providers:
| Consumer | Schema field | target |
Result goes back as |
|---|---|---|---|
| OpenAI Responses API | parameters |
draft-2020-12 |
a function_call_output item |
| Anthropic | input_schema |
draft-2020-12 |
a tool_result block |
| Gemini | parameters |
openapi-3.0 |
a functionResponse part |
| MCP |
inputSchema in the tool descriptor |
draft-2020-12 |
{ content, structuredContent?, isError? } |
| AI SDK |
inputSchema, which takes the Standard Schema as is |
none | the SDK runs the loop |
Anthropic, both halves:
import type Anthropic from '@anthropic-ai/sdk';
import type { StandardToolV0 } from 'standard-tool';
export function toAnthropicTool(tool: StandardToolV0): Anthropic.Tool {
const schema = tool.inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
return {
name: tool.name,
description: tool.description,
input_schema: (schema ?? { type: 'object', properties: {} }) as Anthropic.Tool.InputSchema,
};
}
export async function runToolUse(
tools: StandardToolV0[],
block: Anthropic.ToolUseBlock,
): Promise<Anthropic.ToolResultBlockParam> {
try {
const tool = tools.find((t) => t.name === block.name);
if (!tool) throw new Error(`Unknown tool: ${block.name}`);
const result = await tool.execute(block.input);
return { type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(result) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { type: 'tool_result', tool_use_id: block.id, content: message, is_error: true };
}
}
execute receives the model's arguments unchecked. A tool made with standardTool() validates them against inputSchema; a hand-written tool has to check them itself.
For another provider, change the field and the target from the table. Each adapter is written once, and adding a provider changes no tools.
Conclusion
A tool written as a function that describes itself belongs to your code. You can call it, test it, document it, and give it to any model or framework, and it stays the same object.
StandardToolV0 is one TypeScript interface with no runtime. It is a proposal. The V0 shape is frozen, so feedback that changes it goes into a new interface, StandardToolV1. The spec, the reference implementation, and the reasoning behind them are at standard-tool.js.org.
The obvious objection is XKCD 927: until other projects produce or read this shape, it is one more competing format. Standard Schema shows that a small interface with no runtime can be adopted widely, but it started with the authors of Zod, Valibot, and ArkType behind it. This proposal has one maintainer and no such backing.
The most useful feedback now is where the shape is wrong: open an issue.
Top comments (0)