Skip to main content
Guides / Zod Schemas for Agents

Designing Deterministic Schema Interfaces Using Zod for Agentic Pipelines

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: types drift, unexpected keys slip through, unions become ambiguous, and validation errors are swallowed. TypeScript types vanish entirely at runtime, offering no validation for raw JSON from models.

Architecture: Strict, Discriminated, Bounded, Self-Repairing

Four design rules govern robust schemas: strict object keys, discriminated unions on action fields, strict bounds on sizes and lengths, and self-repair loops fed back to the model.

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
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`);
}

Best Practices and Trade-offs

  • Validate on both sides: Check model outputs into the pipeline and tool arguments before execution.
  • Use safeParse: Never use plain parse at model boundaries since failures are expected and recoverable.
  • Keep schemas small: Deeply nested structures raise error rates; split into multi-step actions instead.

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.