Spec 29 — Pluggable Validation via Standard Schema
Today the public AI-tool API is hardwired to Zod: defineTool({ input: z.ZodType }). This spec retypes the tool input surface to Standard Schema so any compliant validator — Zod 4, Valibot, ArkType — works unchanged, while Zod stays the framework's internal default. No DI container, no runtime cost, full backward compatibility.
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
ToolInputInvaliderror shape.
✗ Non-goals
- Config validation stays Zod.
PattiesConfigSchemais internal; not user-pluggable. - UI registry stays Zod.
ComponentEntrySchemais 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;
parameters JSON Schema. The resolver is a small vendor→converter table, Zod registered by default.Resolution order
- 1 · ExplicitIf
tool.parametersis 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 dynamicimport("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
parametersJSON 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
| Package | Change | Why |
|---|---|---|
@standard-schema/spec | Add to packages/patties dependencies | Types-only, zero runtime, ~1 kB. Provides StandardSchemaV1. |
zod | Keep in dependencies | Still 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 satisfiesStandardSchemaV1with no edit — existing apps compile and run unchanged. - The fixture app is the regression oracle.
tests/fixtures/ai-app/app/tools/search.tskeeps its Zod schema; the generatedCLAUDE.mdtool JSON Schema must be byte-identical after the change. - No API rename.
defineTool,ToolConfig, andToolInputInvalidkeep their names and import paths; only types widen. - This is additive → a minor release. No deprecation, no codemod.
Touch points
| File | Change |
|---|---|
src/ai/types.ts | ToolConfig.input → StandardSchemaV1; handler input via InferOutput; add optional parameters. |
src/ai/run.ts | safeParse → ~standard.validate (awaited); replace zodToJsonSchemaLite with the resolver (§4). |
src/ai/errors.ts | ToolInputInvalid.issues → ReadonlyArray<StandardSchemaV1.Issue>. |
src/ai/json-schema.ts new | The vendor→converter resolver: explicit parameters → Zod auto-convert → fail loud. Shared by run.ts and agents-md. |
src/agents-md/zod-to-json-schema.ts | Route 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
parametersfails the build with an actionable error naming the tool and the fix — never ships{ type: "object" }. - A non-Zod tool with
parametersemits that JSON Schema verbatim on the Anthropic wire and inCLAUDE.md. - Invalid tool input throws
ToolInputInvalidcarryingStandardSchemaV1.Issue[]; the response surfaces the messages. @standard-schema/specis the only new dependency;zodis still only reached by dynamicimport()for non-AI users.bun run validatepasses; the ai-app fixture and agents-md drift checks are green.