Status: Draft

Foundational — sequenced before the scaffold rewrite (CLI Spec 21) so new modular templates ship validator-agnostic tools written once. Framework changes: ToolConfig.input type, runtime validation path in ai/run.ts, JSON-Schema resolution, and the ToolInputInvalid error shape.

Purpose & motivation

Zod plays two roles in patties. Only one of them should be the user's concern.

Today — Zod is the public contract

Every defineTool must hand patties a z.ZodType. A team standardised on Valibot or ArkType can't reuse their schemas — they must rewrite each tool's input in Zod or hand-roll a bridge. The validator is a hidden lock-in.

Spec 29 — Standard Schema is the contract

The tool input is typed to StandardSchemaV1<TInput>. Zod 4, Valibot, and ArkType all implement that spec, so any of them drops in. Patties keeps using Zod internally for its own config and registry — that's an implementation detail users never touch.

Standard Schema

Standard Schema is a zero-dependency, types-only interop spec authored by the Zod, Valibot, and ArkType maintainers. A schema advertises a ~standard property carrying { version, vendor, validate, types }. Patties only needs validate (runtime) and vendor (to pick a JSON-Schema converter). This is the same boundary discipline as the rest of the framework: standards at the edge.

Scope & non-goals

✓ In scope

  • The public AI-tool input surface: ToolConfig.input / defineTool.
  • Runtime input validation in ai/run.ts.
  • JSON-Schema generation for the Anthropic wire format and the agents-md manifest.
  • The ToolInputInvalid error shape.

✗ Non-goals

  • Config validation stays Zod. PattiesConfigSchema is internal; not user-pluggable.
  • UI registry stays Zod. ComponentEntrySchema is patties-ui's own source of truth.
  • No generic "validate this Request body" helper — that's a separate feature.
  • No removal of Zod from dependencies; it remains the bundled default.

API surface change

One type changes in src/ai/types.ts. The generic TInput is now inferred from the Standard Schema's output type rather than Zod's.

Before

import type { z } from "zod"

export interface ToolConfig<
  TInput = unknown,
  TOutput = unknown,
> {
  name: string
  description: string
  input: z.ZodType<TInput>
  handler: (
    input: TInput,
    ctx: AiContext,
  ) => Promise<TOutput> | TOutput
}

After

import type { StandardSchemaV1 } from "@standard-schema/spec"

export interface ToolConfig<
  TSchema extends StandardSchemaV1 = StandardSchemaV1,
  TOutput = unknown,
> {
  name: string
  description: string
  input: TSchema
  // optional escape hatch — see §4
  parameters?: JsonSchema
  handler: (
    input: StandardSchemaV1.InferOutput<TSchema>,
    ctx: AiContext,
  ) => Promise<TOutput> | TOutput
}

Note

defineTool keeps its single-argument shape. StandardSchemaV1.InferOutput<TSchema> gives the handler the exact parsed type — identical inference ergonomics to today's z.infer, but vendor-neutral.

The JSON-Schema problem

This is the real engineering crux. Standard Schema defines runtime validation only — it carries no JSON-Schema export. But patties needs JSON Schema in two places: the Anthropic tool input_schema (wire format) and the agents-md CLAUDE.md manifest.

flowchart TB
    T["defineTool({ input })"]
    V["~standard.validate (runtime)"]
    R{"JSON Schema needed"}
    P["tool.parameters provided?"]
    Z["vendor === 'zod'"]
    O["other vendor"]
    JS["JSON Schema"]
    T --> V
    T --> R
    R --> P
    P -->|yes| JS
    P -->|no| Z
    Z -->|"dynamic import('zod') → z.toJSONSchema"| JS
    P -->|no| O
    O -->|"no converter → build error"| ERR["Actionable error:\nadd parameters: {...}"]
    classDef warn fill:#fef3c7,stroke:#d97706,color:#7c2d12;
    class ERR warn;
      
Zod stays zero-config (auto-derived JSON Schema). Any other validator works the moment the tool supplies an explicit parameters JSON Schema. The resolver is a small vendor→converter table, Zod registered by default.

Resolution order

  • 1 · ExplicitIf tool.parameters is set, use it verbatim. Always wins — the escape hatch for any validator.
  • 2 · Vendor converterElse read input['~standard'].vendor. If a converter is registered (Zod ships by default via dynamic import("zod").toJSONSchema), use it.
  • 3 · Fail loudElse throw a build-time error naming the tool and the fix: "Tool 'x' uses validator '<vendor>' which patties can't auto-convert to JSON Schema. Add a parameters JSON Schema to the tool." Never silently fall back to { type: "object" } — that would ship a broken tool definition to the model.

Warning

The current zodToJsonSchemaLite swallows failures and returns { type: "object" } (src/ai/run.ts). Under Spec 29 a conversion failure for a known vendor is a build error, not a silent empty schema — a wrong tool schema corrupts model behaviour invisibly. The lite fallback is removed.

Runtime validation

In src/ai/run.ts, the per-tool-call safeParse becomes a Standard Schema validate. The spec allows validate to be async, so the call site awaits.

Before

const parsed =
  tool.config.input.safeParse(use.input)
if (!parsed.success) {
  throw new ToolInputInvalid(
    use.name,
    parsed.error.issues,
  )
}
const out = await tool.config.handler(
  parsed.data, ctx,
)

After

const result = await tool.config
  .input["~standard"].validate(use.input)
if (result.issues) {
  throw new ToolInputInvalid(
    use.name,
    result.issues,
  )
}
const out = await tool.config.handler(
  result.value, ctx,
)

Note

Standard Schema's validate returns { value } on success or { issues } on failure — no exceptions thrown for validation failures, same control flow as safeParse. Most validators run synchronously; awaiting a non-promise is a no-op, so there's no measurable cost for Zod.

Error shape

ToolInputInvalid currently stores z.ZodIssue[]. It becomes vendor-neutral.

import type { StandardSchemaV1 } from "@standard-schema/spec"

export class ToolInputInvalid extends Error {
  readonly issues: ReadonlyArray<StandardSchemaV1.Issue>
  readonly tool: string
  constructor(tool: string, issues: ReadonlyArray<StandardSchemaV1.Issue>) {
    super(`Tool "${tool}" received invalid input: ${issues.length} issue(s).`)
    this.name = "ToolInputInvalid"
    this.tool = tool
    this.issues = issues
  }
}

A StandardSchemaV1.Issue is { message: string; path?: ReadonlyArray<PropertyKey | { key }> } — a strict subset of what ZodIssue carried, so anything formatting these issues for a response keeps working with a narrower read.

Validator examples

Same defineTool, three validators. Zod needs nothing extra; the others supply parameters until a converter is registered for them.

Zod 4 — unchanged, auto JSON Schema

import { z } from "zod"
import { defineTool } from "patties/ai"

export default defineTool({
  name: "search",
  description: "Search hotel inventory.",
  input: z.object({
    city: z.string(),
    from: z.string(),
    to: z.string(),
  }),
  handler(input) {
    // input: { city, from, to }
  },
})

Valibot — explicit parameters

import * as v from "valibot"
import { defineTool } from "patties/ai"

export default defineTool({
  name: "search",
  description: "Search hotel inventory.",
  input: v.object({
    city: v.string(),
    from: v.string(),
    to: v.string(),
  }),
  parameters: {
    type: "object",
    properties: {
      city: { type: "string" },
      from: { type: "string" },
      to:   { type: "string" },
    },
    required: ["city", "from", "to"],
  },
  handler(input) { /* fully typed */ },
})

Note

Valibot and ArkType both expose their own toJsonSchema helpers. A follow-up can register them in the vendor table so parameters becomes optional for those too — but that's additive and out of this spec's critical path.

Dependency change

PackageChangeWhy
@standard-schema/specAdd to packages/patties dependenciesTypes-only, zero runtime, ~1 kB. Provides StandardSchemaV1.
zodKeep in dependenciesStill the internal default for config + registry, and the default JSON-Schema converter. The dynamic import("zod") in the converter resolves the bundled copy.

Optional-AI rule

Per the optional-AI rule, the Zod JSON-Schema converter is loaded by dynamic import() inside the resolver — never at module top level — so non-AI users and edge bundles don't pull it eagerly.

Backward compatibility

  • Zod 4 is Standard-Schema-compliant. Every existing z.object() tool input satisfies StandardSchemaV1 with no edit — existing apps compile and run unchanged.
  • The fixture app is the regression oracle. tests/fixtures/ai-app/app/tools/search.ts keeps its Zod schema; the generated CLAUDE.md tool JSON Schema must be byte-identical after the change.
  • No API rename. defineTool, ToolConfig, and ToolInputInvalid keep their names and import paths; only types widen.
  • This is additive → a minor release. No deprecation, no codemod.

Touch points

FileChange
src/ai/types.tsToolConfig.inputStandardSchemaV1; handler input via InferOutput; add optional parameters.
src/ai/run.tssafeParse~standard.validate (awaited); replace zodToJsonSchemaLite with the resolver (§4).
src/ai/errors.tsToolInputInvalid.issuesReadonlyArray<StandardSchemaV1.Issue>.
src/ai/json-schema.ts newThe vendor→converter resolver: explicit parameters → Zod auto-convert → fail loud. Shared by run.ts and agents-md.
src/agents-md/zod-to-json-schema.tsRoute through the shared resolver so the manifest matches the wire schema.
src/ai/define.ts (defineTool)Generic signature widened; runtime behaviour unchanged.

Acceptance criteria

  • A tool defined with a Zod 4 schema works with no source change, and its generated JSON Schema is byte-identical to today's output.
  • A tool defined with a Valibot or ArkType schema validates inputs correctly at runtime via ~standard.validate.
  • A non-Zod tool with no parameters fails the build with an actionable error naming the tool and the fix — never ships { type: "object" }.
  • A non-Zod tool with parameters emits that JSON Schema verbatim on the Anthropic wire and in CLAUDE.md.
  • Invalid tool input throws ToolInputInvalid carrying StandardSchemaV1.Issue[]; the response surfaces the messages.
  • @standard-schema/spec is the only new dependency; zod is still only reached by dynamic import() for non-AI users.
  • bun run validate passes; the ai-app fixture and agents-md drift checks are green.