Every LLM framework rebuilt the same tool object
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 Mastra, and the tools are written a third time with `createTool`. The functions stay the same. Only the wrapper changes.
Each wrapper is also tied to its framework. `tool()` comes from the `ai` package, `createTool` from `@mastra/core`, 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>;
}
* `name` is the identifier the model uses to call the tool.
* `description` tells the model what the tool does and when to use it.
* `title` is an optional label for people, which MCP clients can show in tool lists.
* `inputSchema` and `outputSchema` must implement both specs, so each one validates and emits JSON Schema. `Input` is the input side of the input schema, and `Output` is the output side of the output schema, so schemas that transform values fit.
* `meta` is static data about the tool, such as `{ destructive: true }`. Consumers read it, and `execute` never sees it.
* `execute` runs the tool. Its optional second argument, `context`, carries per-call data such as a locale or an auth token. `context` is not validated and does not appear in the JSON Schema.
* `FormattedOutput` is what `execute` returns when a wrapper changes the result, for example to return errors as data. It defaults to `Output`.
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. Moving a tool to another framework means rewriting its wrapper. 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.