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
- 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: falseand a fullrequiredlist at every nested level, so each level of nesting adds another place to get that wrong. - Every field must have a description. Use
.describe()in Zod ordescriptionin JSON Schema. - 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 fieldsJSON 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_loadingplus a tool search tool) or client-sideactiveTools - 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
/docs/tool-design/naming-and-descriptions- descriptions as prompts/docs/tool-design/anti-patterns- schema design mistakes- (JSON Schema 2020-12)
- (OpenAI strict mode)
- (Gemini function calling)
zod-to-json-schemaon npm