Agent Surface

Tool Design

Schemas

Typed input and output schemas with Zod and JSON Schema for agent reasoning

Last verified 2026-09-24

Summary

Tool schemas define input parameters and output structure, enabling agents to reason reliably over results and avoiding hallucination. Three rules: (1) Flat structure (one or two levels max; each level needs additionalProperties: false under OpenAI strict mode), (2) Every field must have description (.describe() in Zod, description in JSON Schema), (3) Enums over free text (constrain values where server enforces them). Input examples reduce errors. Declare output schemas so downstream tools get typed results. OpenAI strict mode requires every property in required, with optional fields made nullable rather than omitted; nested union types are rejected outright.

  • Flat structures: Max 1-2 levels deep
  • Descriptions: Every field documented with examples
  • Enums: Constrained values where possible
  • Input examples: On parameters to guide agent
  • Output schema: Declare return structure for downstream consumers
  • OpenAI strict mode: every property stays in required; optional means nullable, not omitted

Tool schemas are the contract between the agent and the server. They define what parameters the agent can pass and what the server returns. Well-designed schemas reduce parameter errors and cost, and give agents typed results to reason over.

Input Schemas: Flat, Typed, Annotated

The Three Rules

  1. Flat structure preferred. Avoid deep nesting; one or two levels maximum. Deeply nested objects are harder for agents to reason about, and OpenAI strict mode requires additionalProperties: false and a full required list at every nested level, so each level of nesting adds another place to get that wrong.
  2. Every field must have a description. Use .describe() in Zod or description in JSON Schema.
  3. Enums over free text. Constrain values wherever the server enforces them.

Zod Example

import { z } from "zod";

const searchIssuesSchema = z
  .object({
    query: z
      .string()
      .describe(
        'Free text search. Examples: "status:Open", "assignee:alice@company.com", "label:bug"',
      ),

    assignee: z
      .string()
      .optional()
      .describe("Filter by assignee name or email. Omit to search all assignees."),

    status: z
      .enum(["Open", "In Progress", "Done", "Closed"])
      .optional()
      .describe("Issue workflow state. Omit to search all statuses."),

    priority: z
      .enum(["low", "medium", "high", "critical"])
      .optional()
      .describe("Severity: low=cosmetic, medium=feature, high=blocker, critical=outage"),

    limit: z
      .number()
      .int()
      .min(1)
      .max(100)
      .default(50)
      .describe("Max results to return. Default 50, capped at 100 for performance."),

    offset: z
      .number()
      .int()
      .min(0)
      .default(0)
      .describe(
        "Pagination offset. Use next_offset from the previous response to fetch the next page.",
      ),
  })
  .strict(); // Strict mode rejects unexpected fields

JSON Schema Equivalent

{
  "type": "object",
  "required": ["query"],
  "additionalProperties": false,
  "properties": {
    "query": {
      "type": "string",
      "description": "Free text search. Examples: \"status:Open\", \"assignee:alice@company.com\""
    },
    "status": {
      "type": "string",
      "enum": ["Open", "In Progress", "Done", "Closed"],
      "description": "Issue workflow state. Omit to search all statuses."
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100,
      "default": 50,
      "description": "Max results to return. Default 50, capped at 100."
    }
  }
}

Field-Level Best Practices

UUID Fields

Use .uuid() to reject numeric IDs that look like strings:

customer_id: z.string().uuid()
  .describe('The UUID of the customer. Get from `billing_list_customers` or `billing_get_customer`.'),

Numeric Constraints

For monetary amounts and counts, use .int() to prevent 2.5 items:

amount_cents: z.number().int().positive().max(100_000_000)
  .describe('Invoice amount in cents (100 = $1.00). Max $1,000,000.'),

quantity: z.number().int().positive()
  .describe('Number of units. Must be at least 1.'),

Dates and Times

Use regex constraints for format validation:

due_date: z.string()
  .regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format')
  .describe('Payment due date in YYYY-MM-DD format (e.g., "2025-02-28"). Must be today or later.'),

start_time: z.string()
  .regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format')
  .describe('Start time in 24-hour HH:MM format (e.g., "09:30").'),

Enums for Constrained Values

Never accept free text when a fixed set is valid:

// Bad:
status: z.string()
  .describe('Issue status'),

// Good:
status: z.enum(['open', 'in_progress', 'closed', 'archived'])
  .describe('Workflow state: open=new issue, in_progress=assigned, closed=resolved, archived=historical'),

Optional Fields with Clear Defaults

Document what happens when optional fields are omitted:

include_resolved: z.boolean().optional()
  .describe('Include closed/resolved items. Defaults to false (only show open issues).'),

sort_by: z.enum(['created', 'updated', 'priority']).optional()
  .describe('Sort order. Defaults to "updated" (most recently changed first).'),

Arrays and Pagination

Cap arrays to prevent accidental giant payloads:

labels: z.array(z.string()).max(10).optional()
  .describe('Filter by labels. Max 10 labels per search.'),

file_ids: z.array(z.string().uuid()).max(50).optional()
  .describe('File IDs to include. Max 50 files.'),

Context Parameters vs Tool Parameters

An input schema should contain only the domain inputs the agent legitimately decides. Authentication tokens, session-derived user IDs, and internal system identifiers are context the execution layer injects at runtime - they must never appear as tool parameters. Asking the agent to supply them invites hallucinated or leaked credentials and couples the tool contract to implementation details.

// Bad: agent is responsible for auth context it should never handle
{
  name: "create_document",
  schema: {
    properties: {
      auth_token: { type: "string" },      // Wrong: execution layer concern
      user_id: { type: "string" },         // Wrong: comes from session
      organization_id: { type: "string" }, // Wrong: comes from session
      title: { type: "string" },
      content: { type: "string" }
    }
  }
}

// Good: only domain inputs; auth/session context injected at runtime
{
  name: "create_document",
  schema: {
    properties: {
      title: { type: "string" },
      content: { type: "string" },
      folder_id: { type: "string", description: "Optional folder to place the document in" }
    },
    required: ["title"]
  }
}

Frameworks provide dedicated mechanisms for injecting this context outside the schema - LangChain's InjectedToolArg, an execute-context object, or a closure over the request. Keep the schema free of anything the session already knows.

Output Schemas: Semantic Content

Declare an outputSchema when the tool's result will be consumed by other tools or stored in agent state. This enables downstream agents to receive typed structured data.

const InvoiceOutputSchema = z.object({
  invoice_id: z.string().uuid().describe("Unique invoice identifier"),

  status: z.enum(["draft", "open", "paid", "void", "overdue"]).describe("Current invoice state"),

  amount_cents: z.number().int().positive().describe("Total invoice amount in cents"),

  currency: z.enum(["usd", "eur", "gbp"]).optional().describe("ISO 4217 currency code"),

  payment_url: z.string().url().optional().describe("Hosted payment page URL if invoice is open"),

  due_date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .optional()
    .describe("Payment due date in YYYY-MM-DD format"),
});

server.tool(
  "billing_create_invoice",
  "Creates a new invoice and returns the invoice details...",
  inputSchema,
  { outputSchema: InvoiceOutputSchema },
  async (params) => {
    const invoice = await createInvoice(params);

    return {
      // Required when outputSchema is declared
      structuredContent: {
        invoice_id: invoice.id,
        status: invoice.status,
        amount_cents: invoice.amountCents,
        currency: invoice.currency,
        payment_url: invoice.hostedUrl ?? undefined,
        due_date: invoice.dueDate,
      },
      // Human-readable content for display
      content: [
        {
          type: "text",
          text: `Invoice ${invoice.id} created: $${(invoice.amountCents / 100).toFixed(2)} due ${invoice.dueDate}`,
        },
      ],
    };
  },
);

Strict Mode for OpenAI Compatibility

OpenAI requires strict: true and additionalProperties: false at every object level, and every property listed in required - including fields that are logically optional (see Nullable Parameters below). Required when targeting OpenAI Agents .

// Zod
const schema = z.object({
  name: z.string(),
  email: z.string().email(),
}).strict();

// JSON Schema
{
  "type": "object",
  "additionalProperties": false,
  "required": ["name", "email"],
  "properties": {
    "name": { "type": "string" },
    "email": { "type": "string", "format": "email" }
  }
}

When a nested object exists, apply additionalProperties: false to it as well:

const schema = z
  .object({
    customer: z
      .object({
        name: z.string(),
        email: z.string().email(),
      })
      .strict(), // Inner object must also be strict
    items: z.array(
      z
        .object({
          sku: z.string(),
          quantity: z.number().int(),
        })
        .strict(),
    ), // Each array element must be strict
  })
  .strict();

Common Anti-Patterns

Nullable Parameters

// Bad: OpenAI strict mode has no concept of an omitted field; every
// property must be listed in `required`, so plain .optional() doesn't work
assignee: z.string().optional();

// Good: keep the field in `required`, make it nullable instead
assignee: z.string().nullable(); // emits { "type": ["string", "null"] }, stays in `required`

OpenAI strict mode requires every property to appear in the schema's required array - there's no way to mark a field as absent. To express "this field is optional," make it nullable ("type": ["string", "null"]) and have the caller pass null rather than omitting the key.

Nested Union Types

// Bad: OpenAI strict mode rejects
result: z.union([
  z.object({ type: z.literal('success'), data: z.any() }),
  z.object({ type: z.literal('error'), message: z.string() })
])

// Good: use flat discriminated union or separate fields
result_type: z.enum(['success', 'error']),
result_data: z.any().optional(),
error_message: z.string().optional(),

Free-Text Parameters That Accept Fixed Values

// Bad
sort_order: z.string().describe("How to sort results"); // Vague; agent will guess

// Good
sort_order: z.enum(["ascending", "descending"]).describe("ascending=A-Z/0-9, descending=Z-A/9-0");

Inconsistent ID Types

Use UUID everywhere or use explicit string + format constraints, never mix:

// Bad
user_id: z.string().uuid(),
order_id: z.string(),  // Is this a UUID? a number?

// Good: all IDs are UUIDs
user_id: z.string().uuid(),
order_id: z.string().uuid(),

Token Efficiency

Schemas are included in every tool-call request. Reduce schema size by:

  • Using .describe() with short, punchy sentences (not paragraphs)
  • Removing redundant descriptions that are obvious from the field name
  • Deferring tool metadata until needed with provider-side tool search (defer_loading plus a tool search tool) or client-side activeTools
  • Splitting a large tool with 30 parameters into two smaller tools
// Bad: bloated description
due_date: z.string()
  .describe('The date that the customer is expected to pay the invoice. This should be set to a business day if possible, and you should avoid weekends and holidays. The format must be YYYY-MM-DD with a four-digit year, two-digit month, and two-digit day of the month.'),

// Good: concise but complete
due_date: z.string()
  .describe('Payment due date in YYYY-MM-DD format. Avoid weekends/holidays.'),

Cross-Framework Compilation

Use zod-to-json-schema to emit OpenAI-compatible JSON Schema from Zod:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const schema = z
  .object({
    query: z.string(),
    limit: z.number().int().min(1).max(100).default(50),
  })
  .strict();

const jsonSchema = zodToJsonSchema(schema);
console.log(JSON.stringify(jsonSchema, null, 2));

See also

On this page