LLM output is probabilistic; your pipeline shouldn't be. Every boundary where model text becomes program input needs a contract that is enforced at runtime, not hoped for.

The Engineering Challenge

Without enforced schemas, agent pipelines fail in quiet ways:

  • Numbers arrive as strings, enums arrive with new variants, and optional fields go missing.
  • Extra fields slip through into downstream code or database writes.
  • Ambiguous unions make it unclear which action the model meant.
  • Validation errors are swallowed, or crash the run instead of being fed back to the model.

TypeScript types vanish at runtime. They validate nothing about a JSON string produced by a model.

Architecture: Strict, Discriminated, Bounded, Self-Repairing

Four design rules:

  1. Strict objects: Unknown keys are errors.
  2. Discriminated unions on an explicit action field, so there is exactly one valid shape per action.
  3. Bounds on everything: string length, array size, numeric ranges.
  4. A repair loop that returns structured validation errors to the model, with a hard retry cap.
 ts import { z } from "zod"; const ReadFile = z.strictObject({ action: z.literal("read_file"), path: z.string().min(1).max(500), }); const WriteFile = z.strictObject({ action: z.literal("write_file"), path: z.string().min(1).max(500), content: z.string().max(100_000), overwrite: z.boolean().default(false), }); const Search = z.strictObject({ action: z.literal("search"), query: z.string().min(1).max(200), limit: z.number().int().min(1).max(50).default(10), }); export const AgentAction = z.discriminatedUnion("action", [ReadFile, WriteFile, Search]); export type AgentAction = z.infer<typeof AgentAction>; // Publish the same schema to the model as JSON Schema (Zod 4) export const actionJsonSchema = z.toJSONSchema(AgentAction); type ModelCall = (messages: { role: "user" | "assistant"; content: string }[]) => Promise<string>; export async function getValidAction( callModel: ModelCall, prompt: string, maxAttempts = 3, ): Promise<AgentAction> { const messages: { role: "user" | "assistant"; content: string }[] = [ { role: "user", content: prompt }, ]; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const text = await callModel(messages); let json: unknown; try { json = JSON.parse(text); } catch { messages.push( { role: "assistant", content: text }, { role: "user", content: "Output was not valid JSON. Return only a JSON object." }, ); continue; } const result = AgentAction.safeParse(json); if (result.success) return result.data; const issues = result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message, })); messages.push( { role: "assistant", content: text }, { role: "user", content: `Schema validation failed: ${JSON.stringify(issues)}. Fix and resend.` }, ); } throw new Error(`No valid action after ${maxAttempts} attempts`); } Because the same schema generates the JSON Schema shown to the model, validates its output, and infers the TypeScript type, the three can't drift apart. Best Practices and Trade-offs Validate on both sides: model output into the pipeline, and tool arguments before execution. Also validate tool results that flow into the next stage. Use safeParse, never parse, at model boundaries. Failures are expected and recoverable. Keep schemas small and explicit. Deeply nested optional structures raise model error rates. Split into multi-step actions instead. Version your schemas (schemaVersion field or module path) when persisting actions or replaying logs. Conclusion Determinism in an agent pipeline comes from contracts at every boundary, not from better prompting. Zod gives you one definition that serves as runtime validation, static types, and model-facing documentation. Castov's local-first architecture runs these validation layers next to your tools, so contracts are enforced where execution happens. Learn more at castov.com/guides/designing-deterministic-schema-interfaces-with-zod-for-agentic-pipelines.