{"id":51081199,"url":"https://github.com/finom/standard-tool","last_synced_at":"2026-06-23T18:02:57.186Z","repository":{"id":361219879,"uuid":"1253431320","full_name":"finom/standard-tool","owner":"finom","description":"Proposal (RFC): a common type for defining LLM tools — one neutral shape any framework, SDK, or app can produce or consume, validating input/output and emitting JSON Schema for any model. Built on Standard Schema + Standard JSON Schema. Zero dependencies.","archived":false,"fork":false,"pushed_at":"2026-06-15T09:55:11.000Z","size":428,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-15T11:34:37.707Z","etag":null,"topics":["agents","arktype","function-calling","json-schema","llm","mcp","standard-json-schema","standard-schema","tool-calling","typescript","valibot","zod"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/finom.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-29T13:04:02.000Z","updated_at":"2026-06-15T09:55:15.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/finom/standard-tool","commit_stats":null,"previous_names":["finom/standard-tool"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/finom/standard-tool","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/finom%2Fstandard-tool","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/finom%2Fstandard-tool/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/finom%2Fstandard-tool/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/finom%2Fstandard-tool/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/finom","download_url":"https://codeload.github.com/finom/standard-tool/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/finom%2Fstandard-tool/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34700915,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-23T02:00:07.161Z","response_time":65,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agents","arktype","function-calling","json-schema","llm","mcp","standard-json-schema","standard-schema","tool-calling","typescript","valibot","zod"],"created_at":"2026-06-23T18:02:52.937Z","updated_at":"2026-06-23T18:02:57.172Z","avatar_url":"https://github.com/finom.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# StandardTool \u0026nbsp;[![npm](https://img.shields.io/npm/v/standard-tool)](https://www.npmjs.com/package/standard-tool) [![CI](https://github.com/finom/standard-tool/actions/workflows/ci.yml/badge.svg)](https://github.com/finom/standard-tool/actions/workflows/ci.yml) [![docs](https://img.shields.io/badge/docs-standard--tool.js.org-blue)](https://standard-tool.js.org)\n\nOne type for an LLM tool. Define it once, use it with any provider, SDK, or framework instead of rewriting the same object for each.\n\n```ts\ninterface StandardToolV0\u003c\n  Input = unknown, Output = unknown, FormattedOutput = Output, Meta = unknown,\n\u003e {\n  name: string;\n  title?: string; // human label; shown by MCP-style clients in tool lists\n  description: string;\n  inputSchema?: StandardSchemaV1\u003cInput\u003e \u0026 StandardJSONSchemaV1\u003cInput\u003e;\n  outputSchema?: StandardSchemaV1\u003cOutput\u003e \u0026 StandardJSONSchemaV1\u003cOutput\u003e;\n  execute(input: Input, meta?: Meta): FormattedOutput | Promise\u003cFormattedOutput\u003e;\n  formatted\u003cF\u003e(\n    format?: (result: Output | Error) =\u003e F | Promise\u003cF\u003e,\n  ): StandardToolV0\u003cInput, Output, F, Meta\u003e;\n}\n```\n\nThat's all of it. It's an **interface, not a library you depend on**: any object of this shape is a StandardTool, so you can conform with a plain object and zero dependencies, the same way Zod, Valibot, and ArkType conform to Standard Schema. The package ships a reference builder, `standardTool()`, but nothing makes you use it.\n\nThe schemas pull double duty: they validate runtime data (a model's arguments are untrusted) and emit JSON Schema for the model via `inputSchema['~standard'].jsonSchema.input({ target })`. Any library implementing both [Standard Schema](https://standardschema.dev) and [Standard JSON Schema](https://standardschema.dev/json-schema) works: Zod 4.2+ and ArkType 2.1.28+ expose it on the schema directly; Valibot via `toStandardJsonSchema()` from `@valibot/to-json-schema` 1.5+.\n\n\u003e **Status: RFC, `0.x`.** The shape is settled; the `standardTool()` surface (helper names, the formatting layer) may still change, so treat `0.x` as potentially breaking. [Critiques and counter-proposals welcome.](https://github.com/finom/standard-tool/issues)\n\n## Quick start\n\n```sh\nnpm i standard-tool zod   # or arktype, or valibot + @valibot/to-json-schema\n```\n\n```ts\nimport { standardTool } from 'standard-tool';\nimport { z } from 'zod';\n\nconst getWeather = standardTool({\n  name: 'get_weather',\n  description: 'Current temperature for a city',\n  inputSchema: z.object({ city: z.string() }),\n  outputSchema: z.object({ tempC: z.number() }),\n  execute: async ({ city }) =\u003e ({ tempC: 21 }), // city typed; return validated\n});\n\n// { tempC: 21 } — validated in \u0026 out; throws on a violation\nawait getWeather.execute({ city: 'Paris' });\n// same call; failures come back as { error } instead of throwing\nawait getWeather.formatted().execute({ city: 'Paris' });\n\n// JSON Schema for the model (empty object when the tool takes no input):\nconst parameters = getWeather.inputSchema?.['~standard'].jsonSchema\n  .input({ target: 'draft-2020-12' }) ?? { type: 'object', properties: {} };\n```\n\n`standardTool()` is the reference builder: it validates input and output and throws `StandardToolValidationError` on a mismatch. The package adds no runtime dependencies of its own — the Standard Schema interfaces are vendored in — though you still install a schema library for the schemas. Prefer not to depend on the package at all? [Copy-paste the ~40-line source.](#copy-paste-the-source)\n\n## Why\n\nEvery LLM ecosystem ships its own tool object: Vercel AI SDK, MCP, Mastra, Genkit, LangChain, oRPC, Effect. They all describe the same six things (name, description, input schema, output schema, `execute`, a little metadata), yet none is portable and most are welded to a runtime.\n\nThe hard part of that list is already solved. [Standard Schema](https://standardschema.dev) unified validation; [Standard JSON Schema](https://standardschema.dev/json-schema) unified JSON Schema emission. Once the schemas cover both jobs, everything left in a tool is two strings and a function.\n\nSo the work is backwards. Frameworks keep reinventing the trivial envelope and binding it to their runtime, while the one shared piece gets treated as proprietary. StandardTool standardizes the envelope too: a ~30-line interface, no runtime, no lock-in. [The full survey is below.](#how-it-compares)\n\n## Formatting the result\n\nA plain tool returns its `Output` and throws on failure. That's right for typed code, but inside a model loop you usually want a failure to come back as *data* the model can correct from, and some consumers (MCP) want a specific result envelope. `formatted()` opts into that without touching `Input` or `Output`:\n\n```ts\n// throws StandardToolValidationError\nawait getWeather.execute({ city: 123 } as never);\n// returns { error: 'input validation failed: …' }\nawait getWeather.formatted().execute({ city: 123 } as never);\n```\n\n`formatted` takes any `(result: Output | Error) =\u003e FormattedOutput`. It gets the validated `Output` on success and an `Error` on failure (a `StandardToolValidationError` carrying `target` and the Standard Schema `issues`). With no argument it uses the default `{ error }` envelope. The return type becomes the third generic:\n\n```ts\nconst asText = getWeather.formatted((r) =\u003e\n  r instanceof Error ? `error: ${r.message}` : `${r.tempC}°C`,\n);\nawait asText.execute({ city: 'Paris' }); // '21°C'\n```\n\nRe-formatting **replaces, it doesn't compose**: each `formatted()` re-derives from the validated `execute`, never from the previous formatter. So a framework can ship a tool pre-formatted for itself and you can still re-target it for another consumer.\n\n## Per-call context (`meta`)\n\n`execute` takes an optional second argument, `meta`, passed to your handler untouched. It's never validated and never in the JSON Schema. Use it for a locale, an auth token, a request-scoped DB handle: the tool stays defined once at module scope while you inject context at call time.\n\nAnnotate `meta` on the handler and the type propagates to every caller:\n\n```ts\nconst greet = standardTool({\n  name: 'greet',\n  description: 'Greet a person in the caller-supplied locale',\n  inputSchema: z.object({ name: z.string() }),\n  execute: ({ name }, meta: { locale: string }) =\u003e\n    meta.locale === 'fr' ? `bonjour ${name}` : `hi ${name}`,\n});\n\nawait greet.execute({ name: 'Ada' }, { locale: 'fr' }); // 'bonjour Ada'\n// compile error: Meta is { locale: string }\nawait greet.execute({ name: 'Ada' }, { locale: 7 });\n```\n\n## Using it with any provider\n\nOne array of tools, wired into OpenAI, Anthropic, the Vercel AI SDK, and MCP. Two parts do the work everywhere:\n\n- `inputSchema['~standard'].jsonSchema.input({ target })` is the JSON Schema you hand the model.\n- `execute(args)` runs the call. Inside a loop use `formatted().execute(args)`, which returns `{ error }` instead of throwing so the model can self-correct.\n\nExamples use Zod; the model only ever sees emitted JSON Schema, so ArkType and Valibot produce identical calls — ArkType the same way as Zod, Valibot by wrapping each schema in `toStandardJsonSchema()` from `@valibot/to-json-schema`. They assume you've installed the relevant provider SDK.\n\n### The tools\n\n```ts\n// tools.ts\nimport { standardTool, type StandardToolV0 } from 'standard-tool';\nimport { z } from 'zod';\n\nexport const tools: StandardToolV0[] = [\n  standardTool({\n    name: 'get_weather',\n    description: 'Get the current temperature for a city.',\n    inputSchema: z.object({ city: z.string() }),\n    outputSchema: z.object({ tempC: z.number() }),\n    execute: async ({ city }) =\u003e ({ tempC: 21 }),\n  }),\n  standardTool({\n    name: 'get_time',\n    description: 'Get the current time in an IANA timezone.',\n    inputSchema: z.object({ timezone: z.string() }),\n    outputSchema: z.object({ iso: z.string() }),\n    execute: async ({ timezone }) =\u003e\n      ({ iso: new Date().toLocaleString('en-US', { timeZone: timezone }) }),\n  }),\n  standardTool({\n    name: 'convert_currency',\n    description: 'Convert an amount between two currencies.',\n    inputSchema: z.object({ amount: z.number(), from: z.string(), to: z.string() }),\n    outputSchema: z.object({ amount: z.number() }),\n    execute: async ({ amount }) =\u003e ({ amount: Math.round(amount * 1.08 * 100) / 100 }),\n  }),\n];\n```\n\n### OpenAI\n\nTool calls arrive as `function_call` items in `res.output`; results go back as `function_call_output`.\n\n```ts\nimport OpenAI from 'openai';\nimport { tools } from './tools';\n\nconst client = new OpenAI();\nconst input: OpenAI.Responses.ResponseInput = [\n  { role: 'user', content: 'What is the weather in Paris?' },\n];\n\nconst res = await client.responses.create({\n  model: 'gpt-5.5',\n  input,\n  tools: tools.map((tool): OpenAI.Responses.Tool =\u003e ({\n    type: 'function',\n    name: tool.name,\n    description: tool.description,\n    parameters: tool.inputSchema?.['~standard'].jsonSchema\n      .input({ target: 'draft-2020-12' }) ?? { type: 'object', properties: {} },\n    strict: false,\n  })),\n});\n\ninput.push(...res.output);\nfor (const item of res.output) {\n  if (item.type !== 'function_call') continue;\n  const tool = tools.find((t) =\u003e t.name === item.name);\n  if (!tool) continue;\n  const result = await tool.formatted().execute(JSON.parse(item.arguments));\n  input.push({\n    type: 'function_call_output',\n    call_id: item.call_id,\n    output: JSON.stringify(result),\n  });\n}\n\nconst final = await client.responses.create({ model: 'gpt-5.5', input });\nconsole.log(final.output_text);\n```\n\nChat Completions is the same idea with a different envelope: tools nest under a `function` key, calls come back on `message.tool_calls`, and each result is a `role: 'tool'` message.\n\n### Anthropic\n\nThe Messages API uses `input_schema`, returns `tool_use` blocks in the assistant message, and expects `tool_result` blocks in the next user message.\n\n```ts\nimport Anthropic from '@anthropic-ai/sdk';\nimport { tools } from './tools';\n\nconst client = new Anthropic();\nconst messages: Anthropic.MessageParam[] = [\n  { role: 'user', content: 'What is the weather in Paris?' },\n];\n\nconst res = await client.messages.create({\n  model: 'claude-sonnet-4-6',\n  max_tokens: 1024,\n  messages,\n  tools: tools.map((tool): Anthropic.Tool =\u003e ({\n    name: tool.name,\n    description: tool.description,\n    input_schema: (tool.inputSchema?.['~standard'].jsonSchema\n      .input({ target: 'draft-2020-12' }) ??\n      { type: 'object', properties: {} }) as Anthropic.Tool.InputSchema,\n  })),\n});\n\nmessages.push({ role: 'assistant', content: res.content });\nconst results: Anthropic.ToolResultBlockParam[] = [];\nfor (const block of res.content) {\n  if (block.type !== 'tool_use') continue;\n  const tool = tools.find((t) =\u003e t.name === block.name);\n  if (!tool) continue;\n  const result = await tool.formatted().execute(block.input);\n  results.push({\n    type: 'tool_result',\n    tool_use_id: block.id,\n    content: JSON.stringify(result),\n  });\n}\nmessages.push({ role: 'user', content: results });\n\nconst final = await client.messages.create({\n  model: 'claude-sonnet-4-6',\n  max_tokens: 1024,\n  messages,\n});\nconsole.log(final.content.flatMap((b) =\u003e (b.type === 'text' ? [b.text] : [])).join(''));\n```\n\n### Vercel AI SDK\n\nThe AI SDK (v6) runs the loop itself. Its `tool()` accepts a Standard Schema directly, so pass `inputSchema` as-is and hand it `execute`:\n\n```ts\nimport { generateText, tool, stepCountIs } from 'ai';\nimport { openai } from '@ai-sdk/openai';\nimport { tools } from './tools';\n\nconst { text } = await generateText({\n  model: openai('gpt-5.5'),\n  prompt: 'What is the weather in Paris?',\n  stopWhen: stepCountIs(5),\n  tools: Object.fromEntries(\n    tools.map(({ name, description, inputSchema, execute }) =\u003e [\n      name,\n      tool({ description, inputSchema, execute }),\n    ]),\n  ),\n});\n\nconsole.log(text);\n```\n\nThe SDK validates input; `execute` re-checks it (cheap) and adds the output validation the SDK skips — input guards your code from the model, output guards the model from your code. A result that fails `outputSchema` means `execute` is broken, so it throws instead of feeding the model garbage; to skip the re-check, hand the SDK your own handler.\n\n### MCP\n\nAn [MCP](https://modelcontextprotocol.io) tool returns a result envelope, `{ content, structuredContent?, isError? }`, and a descriptor whose schemas are JSON Schema. Both come from the same two parts: `jsonSchema.input()` for the descriptor, and a `.formatted(toMcpResult)` formatter that maps `execute`'s result onto the envelope.\n\nThe formatter below is text-only: an object becomes a JSON text block mirrored into `structuredContent` (per MCP's [back-compat guidance](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content)), and errors return `isError: true` so the model can self-correct. Image, audio, and resource blocks are out of scope.\n\n```ts\ntype McpToolResult = {\n  content: { type: 'text'; text: string }[];\n  structuredContent?: Record\u003cstring, unknown\u003e;\n  isError?: boolean;\n};\n\nconst toMcpResult = (result: unknown): McpToolResult =\u003e {\n  if (result instanceof Error)\n    return { content: [{ type: 'text', text: result.message }], isError: true };\n  if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };\n  const text = JSON.stringify(result);\n  if (result !== null \u0026\u0026 typeof result === 'object' \u0026\u0026 !Array.isArray(result)) {\n    return {\n      content: [{ type: 'text', text }],\n      structuredContent: result as Record\u003cstring, unknown\u003e,\n    };\n  }\n  return { content: [{ type: 'text', text }] };\n};\n\nconst mcpTools = tools.map((t) =\u003e t.formatted(toMcpResult));\n\n// tools/list — emit the descriptor's JSON Schema directly\nconst descriptors = mcpTools.map((t) =\u003e ({\n  name: t.name,\n  title: t.title,\n  description: t.description,\n  inputSchema: t.inputSchema?.['~standard'].jsonSchema\n    .input({ target: 'draft-2020-12' }) ?? { type: 'object', properties: {} },\n}));\n\n// tools/call — execute validates once, then returns the MCP result shape\nasync function call(name: string, args: unknown) {\n  const tool = mcpTools.find((t) =\u003e t.name === name);\n  if (!tool) throw new Error(`Unknown tool: ${name}`);\n  // → { content: [{ type: 'text', text: '{\"tempC\":21}' }],\n  //     structuredContent: { tempC: 21 } }\n  return tool.execute(args);\n}\n```\n\n### Testing\n\nNo model, no framework. Data in, data out:\n\n```ts\nimport { tools } from './tools';\n\nconst getWeather = tools.find((t) =\u003e t.name === 'get_weather')!;\n\nexpect(await getWeather.execute({ city: 'Paris' })).toEqual({ tempC: 21 });\nawait expect(getWeather.execute({ city: 123 as never })).rejects.toThrow();\nexpect(await getWeather.formatted().execute({ city: 123 as never }))\n  .toMatchObject({ error: expect.any(String) });\n```\n\n### Notes\n\n- **Who validates.** OpenAI and Anthropic don't check arguments against your schema, so `formatted().execute` is the only validation; call it on the model's raw args. The AI SDK validates input against `inputSchema`, but not `execute`'s output — its `outputSchema` is types-only.\n- **Bad JSON.** `JSON.parse` runs before `execute`, so guard it if the model might emit invalid JSON syntax; that throws before `execute` can turn a failure into `{ error }`.\n- **JSON Schema targets.** `draft-2020-12` fits OpenAI and Anthropic; use `openapi-3.0` for the OpenAPI subset (Gemini), or `draft-07`.\n\n## Beyond LLM tools\n\n\"LLM tool\" is the obvious use, but the shape is really a **self-describing function**: a callable bundled with everything needed to understand it without running it: a stable `name`, a `description`, and typed `inputSchema`/`outputSchema` that both validate and emit JSON Schema. A model is one consumer that happens to need exactly that bundle. The same bundle drives others:\n\n- **prompt construction** — tell a model what it can call\n- **documentation** — `name` + `description` + schemas → reference docs\n- **UI / forms** — `inputSchema` → a typed form\n- **command palettes / CLIs** — a tool is a described command with typed args\n- **RPC / endpoints** — `name` + schemas + `execute` is a procedure\n\nDescribing a tool needs only its metadata, so the docs and prompt uses read `name`/`description`/schemas without ever calling `execute`.\n\n\u003e [!NOTE]\n\u003e That opens a use this hasn't had a clean shape for: **portable tools as ordinary library exports.** A library closes auth and config over each `StandardToolV0` and ships them as a client — `new OrdersClient(...)` gives you `client.getOrders`, a member that both runs and self-describes, so a model or framework picks it up with no extra wiring:\n\n```ts\n// in the library: members are StandardTools, auth closed over at construction\nclass OrdersClient {\n  constructor(private auth: { apiKey: string }) {}\n\n  getOrders = standardTool({\n    name: 'get_orders',\n    description: \"List a user's orders\",\n    inputSchema: z.object({ userId: z.string() }),\n    execute: ({ userId }) =\u003e { /* …hit the API with this.auth… */ },\n  });\n}\n\n// in the consumer\nconst client = new OrdersClient({ apiKey: '…' });\nawait client.getOrders.execute({ userId: 'u_1' }); // run it\nawait client.getOrders.formatted().execute({ userId: 'u_1' }); // → to a model\nclient.getOrders.description; // self-describing\n```\n\nAlternatively, when the bare function should stay directly callable, hang the tool off it as a property — `getOrders` is the function, `getOrders.tool` the descriptor:\n\n```ts\nexport async function getOrders(input: { userId: string }) { /* …hit the API… */ }\ngetOrders.tool = standardTool({\n  name: 'get_orders',\n  description: \"List a user's orders\",\n  inputSchema: z.object({ userId: z.string() }),\n  execute: getOrders,\n});\n\nawait getOrders({ userId: 'u_1' }); // call it directly\nawait getOrders.tool.formatted().execute({ userId: 'u_1' }); // → to a model\n```\n\nIt ships like any other library code: a value your caller imports and runs. MCP, by contrast, is a protocol — you stand up a server to speak it. How you build the tool stays idiomatic per library (a class, a factory, a bare export); only the result is fixed — every member is a `StandardToolV0`.\n\n## Copy-paste the source\n\nDon't want `standard-tool` in your dependency list? Own the ~40 lines. Paste this and pull the spec types from the types-only [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) (`npm i -D @standard-schema/spec`) — same logic as the published package, with the vendored interfaces swapped for that import. (You still bring a Standard Schema library for the schemas themselves, exactly as with the package.)\n\n```ts\nimport type { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';\n\n/** Portable LLM tool: `execute` validates in \u0026 out and throws; `formatted()` re-targets the result. */\nexport interface StandardToolV0\u003cInput = unknown, Output = unknown, FormattedOutput = Output, Meta = unknown\u003e {\n  name: string;\n  title?: string;\n  description: string;\n  inputSchema?: StandardSchemaV1\u003cInput\u003e \u0026 StandardJSONSchemaV1\u003cInput\u003e;\n  outputSchema?: StandardSchemaV1\u003cOutput\u003e \u0026 StandardJSONSchemaV1\u003cOutput\u003e;\n  // `input` is optional only when no `inputSchema` fixes its type (`Input` stays `unknown`); a schema makes it required.\n  execute(\n    ...args: unknown extends Input ? [input?: Input, meta?: Meta] : [input: Input, meta?: Meta]\n  ): FormattedOutput | Promise\u003cFormattedOutput\u003e;\n  formatted\u003cF = Output | { error: string }\u003e(\n    format?: (result: Output | Error) =\u003e F | Promise\u003cF\u003e\n  ): StandardToolV0\u003cInput, Output, F, Meta\u003e;\n}\n\n/** A tool minus the synthesized `formatted` — what you pass to `standardTool()`. */\nexport type StandardToolV0Definition\u003cInput = unknown, Output = unknown, FormattedOutput = Output, Meta = unknown\u003e = Omit\u003c\n  StandardToolV0\u003cInput, Output, FormattedOutput, Meta\u003e,\n  'formatted'\n\u003e;\n\nexport function standardTool\u003cInput = unknown, Output = unknown, Meta = unknown\u003e(def: {\n  name: string;\n  title?: string;\n  description: string;\n  inputSchema?: StandardSchemaV1\u003cInput\u003e \u0026 StandardJSONSchemaV1\u003cInput\u003e;\n  outputSchema?: StandardSchemaV1\u003cOutput\u003e \u0026 StandardJSONSchemaV1\u003cOutput\u003e;\n  // plain, required `input` so TS infers `Input`/`Meta` from your handler and the handler never sees `undefined`\n  execute(input: Input, meta?: Meta): Output | Promise\u003cOutput\u003e;\n}): StandardToolV0\u003cInput, Output, Output, Meta\u003e {\n  const { execute: handler, ...rest } = def;\n  const execute = async (input?: Input, meta?: Meta): Promise\u003cOutput\u003e =\u003e {\n    const value = def.inputSchema ? await validate('input', def.inputSchema, input) : (input as Input);\n    const output = await handler(value, meta);\n    return def.outputSchema ? await validate('output', def.outputSchema, output) : output;\n  };\n  const tool: StandardToolV0\u003cInput, Output, Output, Meta\u003e = {\n    ...rest,\n    execute,\n    formatted\u003cF = Output | { error: string }\u003e(\n      format?: (result: Output | Error) =\u003e F | Promise\u003cF\u003e\n    ): StandardToolV0\u003cInput, Output, F, Meta\u003e {\n      const fmt = (format ?? ((r: Output | Error) =\u003e (r instanceof Error ? { error: r.message } : r))) as (\n        result: Output | Error\n      ) =\u003e F | Promise\u003cF\u003e;\n      // re-derive from the validated execute, never the previous formatting, so formatting never stacks\n      return {\n        ...tool,\n        execute: async (input?: Input, meta?: Meta): Promise\u003cF\u003e =\u003e {\n          try {\n            return fmt(await execute(input, meta));\n          } catch (error) {\n            return fmt(error instanceof Error ? error : new Error(String(error)));\n          }\n        },\n      };\n    },\n  };\n  return tool;\n}\n\n/** Thrown when input or output fails validation; carries the side and the Standard Schema issues. */\nexport class StandardToolValidationError extends Error {\n  readonly name = 'StandardToolValidationError';\n  constructor(\n    readonly target: 'input' | 'output',\n    readonly issues: readonly StandardSchemaV1.Issue[]\n  ) {\n    super(\n      `${target} validation failed: ${issues\n        .map((i) =\u003e {\n          const at = (i.path ?? []).map((s) =\u003e String(typeof s === 'object' ? s.key : s)).join('.');\n          return at ? `${at}: ${i.message}` : i.message;\n        })\n        .join('; ')}`\n    );\n  }\n}\n\nasync function validate\u003cS extends StandardSchemaV1\u003e(\n  target: 'input' | 'output',\n  schema: S,\n  value: unknown\n): Promise\u003cStandardSchemaV1.InferOutput\u003cS\u003e\u003e {\n  const result = await schema['~standard'].validate(value);\n  if (result.issues) throw new StandardToolValidationError(target, result.issues);\n  return result.value;\n}\n```\n\n## API\n\n`StandardToolV0` (top of this README) is the contract — program against the type. `standardTool()` is the reference builder that produces a conforming value; adapt another library's tool to the type and it works just as well.\n\n```ts\nimport { standardTool } from 'standard-tool';\nimport type { StandardToolV0, StandardToolV0Definition } from 'standard-tool';\n\n// validates in \u0026 out, throws on a violation\nstandardTool(def: StandardToolV0Definition): StandardToolV0\u003cInput, Output\u003e;\n// opt into a consumer-specific result\ntool.formatted(format?): StandardToolV0\u003cInput, Output, F\u003e;\n```\n\n| field | type | purpose |\n| --- | --- | --- |\n| `name` | `string` | identifier the model emits |\n| `description` | `string` | what the tool does |\n| `title?` | `string` | human label for MCP-style tool lists; ignored by plain function-calling APIs |\n| `inputSchema?` | `StandardSchemaV1\u003cInput\u003e \u0026 StandardJSONSchemaV1\u003cInput\u003e` | validates and emits JSON Schema |\n| `outputSchema?` | `StandardSchemaV1\u003cOutput\u003e \u0026 StandardJSONSchemaV1\u003cOutput\u003e` | validates and emits JSON Schema |\n| `execute` (yours) | `(input: Input, meta?: Meta) =\u003e Output \\| Promise\u003cOutput\u003e` | your logic; gets validated input and the optional `meta` |\n| `execute` (built) | `(input: Input, meta?: Meta) =\u003e Promise\u003cOutput\u003e` | validates in, runs yours, validates out; throws `StandardToolValidationError` |\n\n`Input` and `Output` are inferred from the schemas, or from `execute` when a schema is omitted. With no `inputSchema`, `Input` stays `unknown` and `execute` is callable with no argument (`tool.execute()`); a schema makes the input required. Schemas are optional; when present they must implement both Standard Schema and Standard JSON Schema — Zod 4.2+ and ArkType 2.1.28+ directly, Valibot via `toStandardJsonSchema()` from `@valibot/to-json-schema` 1.5+.\n\nYou pass `standardTool()` a `StandardToolV0Definition` (the fields above). It returns a `StandardToolV0`, which adds the synthesized `formatted()`. The thrown `StandardToolValidationError` carries `target: 'input' | 'output'` and the Standard Schema `issues`.\n\n## How it compares\n\nThe claim in [Why](#why) is that every ecosystem reinvents the envelope while the schema layer underneath is already standardized. The evidence:\n\n**Every tool is the same six things.**\n\n| Concern | What it is | Who consumes it |\n| --- | --- | --- |\n| name | stable identifier the model emits | the model |\n| description | natural-language \"what / when to use\" | the model |\n| input schema | parameter shape, as JSON Schema | the model (to emit args), your code (to validate) |\n| output schema | result shape | your code, some clients (MCP) |\n| execute | the function that runs | your runtime |\n| metadata | title, annotations, hints | clients / UIs |\n\nThe two schemas carry all the complexity, because each serves two masters: emit JSON Schema (so a model can call the tool) and validate runtime data (because a model's arguments are untrusted). Everything else is a string or a function.\n\n**The wire formats have converged on JSON-Schema parameters but disagree on the wrapper and dialect.** OpenAI uses `parameters` (with a `strict` mode that constrains the schema); Anthropic uses `input_schema`; MCP uses `inputSchema` plus `outputSchema`; Gemini uses `functionDeclarations` and accepts only an OpenAPI-3.0 subset. Same data, four shapes.\n\n**The framework objects are where it gets worse.** Each invents its own object and welds it to its own runtime:\n\n| Ecosystem | params key | output schema | execute | schema source | standalone? |\n| --- | --- | --- | --- | --- | --- |\n| OpenAI / Anthropic / Gemini | `parameters` / `input_schema` | n/a | you wire it | JSON Schema (dialects vary) | wire format only |\n| MCP | `inputSchema` | `outputSchema` | server handler | JSON Schema | wire format only |\n| Vercel AI SDK | `inputSchema` | `outputSchema` | `execute` | Zod / JSON Schema | needs `ai` |\n| Mastra | `inputSchema` | `outputSchema` | `execute` | Standard JSON Schema | needs `@mastra/core` (~50 MB) |\n| Genkit | `inputSchema` | `outputSchema` | fn | Zod | needs a live `genkit()` |\n| LangChain | `schema` | n/a | fn | Zod / inferred | needs `@langchain/core` |\n| **StandardTool** | `inputSchema` | `outputSchema` | `execute` | Standard (JSON) Schema | **plain object, zero deps** |\n\nThe columns are nearly identical; the objects are mutually incompatible, and none is obtainable on its own. There's no `createTool` without `@mastra/core`, no `defineTool` without a live `genkit()` instance, no `tool()` without `ai` or `@langchain/core`. So \"just reuse framework X's tool\" means adopting framework X. The neutral, zero-dependency slot is empty. (Mastra already builds its schemas on Standard JSON Schema, so the foundation is shared; only the envelope isn't.)\n\n**The schema layer, by contrast, is solved.** [Standard Schema](https://standardschema.dev) is a ~60-line interface co-designed by the authors of Zod, Valibot, and ArkType, already consumed by tRPC and TanStack; it unifies validation. [Standard JSON Schema](https://standardschema.dev/json-schema) adds emission, with the dialect selectable per call (`target` spans multiple JSON Schema standards) and zero runtime dependencies. Validation and emission are both handled and dependency-free to consume. The envelope is the easy part, and it's the part that's still missing. That inversion is what StandardTool answers.\n\n## The case against\n\n- **Adoption** ([XKCD 927](https://xkcd.com/927/)). A shape nobody else produces or consumes is just a tidy wrapper for its author, and that's roughly where this sits today. The bet: the shape is obvious enough to make adapters trivial, and Standard Schema already showed a neutral interface can spread by adoption rather than mandate. There's no runtime and no lock-in, so the surface to \"win\" is small, but it's still one more shape on the pile until others pick it up. This is the honest weak point.\n- **Why not extend an existing primitive?** Mastra's `createTool` and the AI SDK's `tool()` are the closest prior art, but each is bundled inside a framework and returns a framework-coupled value. The neutral slot is empty; this exists to make it concrete enough to argue about.\n- **`outputSchema` is rarely consumed.** Most provider APIs ignore output schemas; only MCP-style clients validate them. Today it earns its place through your own runtime safety and docs, not the model.\n\n## Scope\n\nNot an agent runtime (no loop, plan, or model call), not a model client (no HTTP), not a transport (MCP serves tools over a wire; this shapes them in memory), not a schema library (it consumes Standard Schema), not an orchestrator (no registries, retries, routing). Bring your own.\n\n## Links\n\n- [Standard Schema](https://standardschema.dev) · [Standard JSON Schema](https://standardschema.dev/json-schema) · [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema)\n- [OpenAI function calling](https://developers.openai.com/api/docs/guides/function-calling) · [Anthropic tool use](https://platform.claude.com/docs/en/build-with-claude/tool-use) · [Gemini function calling](https://ai.google.dev/gemini-api/docs/function-calling) · [MCP tools](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)\n- [Vercel AI SDK `tool()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool) · [Mastra `createTool`](https://mastra.ai/reference/tools/create-tool) · [Genkit](https://genkit.dev/docs/tool-calling/) · [LangChain](https://www.npmjs.com/package/@langchain/core)\n\n## License\n\nMIT © Andrey Gubanov\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffinom%2Fstandard-tool","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffinom%2Fstandard-tool","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffinom%2Fstandard-tool/lists"}