Use Zod M
When building AI tools, you often need to return data that the model shouldn't see: personal info, API keys, internal fields. Your tool might fetch a full order record from a database, but the model only needs the order status and total — not the customer's home address.
The AI SDK gives you toModelOutput() to filter a tool's output before it reaches the LLM. But maintaining that manually is error-prone. Your schema and your filter logic can easily drift out of sync, and every new sensitive field means updating two places.
What if the schema itself knew which fields to hide?
Flagging fields with Zod metadata
Zod lets you attach arbitrary metadata to any schema field with .meta(). You can use this to mark fields that should never reach the model:
const outputSchema = z.object({ orderId: z.string(), status: z.enum(["pending", "shipped", "delivered"]), total: z.number(), name: z.string(), address: z .object({ street: z.string(), city: z.string(), country: z.string(), }) .meta({ hidden: true }), // mark as hidden });const outputSchema = z.object({ orderId: z.string(), status: z.enum(["pending", "shipped", "delivered"]), total: z.number(), name: z.string(), address: z .object({ street: z.string(), city: z.string(), country: z.string(), }) .meta({ hidden: true }), // mark as hidden });
The address field is part of the schema — it gets validated, it's available in your execute function, and it shows up in tool results. But we've flagged it as something the model shouldn't see.
Deriving a model-safe schema
Next, write a small utility that walks the schema's shape and omits any field carrying the hidden flag:
function stripHidden<SHAPE extends z.ZodRawShape>(schema: z.ZodObject<SHAPE>) { const hiddenKeys: Partial<Record<keyof SHAPE, true>> = {}; for (const key in schema.shape) { const field = schema.shape[key] as z.ZodType; if (field.meta()?.hidden) hiddenKeys[key] = true; } return schema.omit(hiddenKeys); }function stripHidden<SHAPE extends z.ZodRawShape>(schema: z.ZodObject<SHAPE>) { const hiddenKeys: Partial<Record<keyof SHAPE, true>> = {}; for (const key in schema.shape) { const field = schema.shape[key] as z.ZodType; if (field.meta()?.hidden) hiddenKeys[key] = true; } return schema.omit(hiddenKeys); }
This works because Zod objects default to strip mode — when you parse data through a schema, any keys not in the schema are silently dropped. By omitting the hidden keys from the derived schema, parsing through it automatically strips those fields from the output.
const modelOutputSchema = stripHidden(outputSchema);const modelOutputSchema = stripHidden(outputSchema);
modelOutputSchema now contains only orderId, status, total, and name. The address field is gone.
Wiring it into a tool
With the derived schema in hand, toModelOutput() becomes a one-liner:
const tools = { fetch_order: tool({ description: "Fetch an order by id", inputSchema, outputSchema, execute: async ({ orderId }) => ({ orderId, status: "shipped" as const, total: 129.99, name: "Jane Doe", address: { street: "742 Evergreen Terrace", city: "Springfield", country: "USA", }, }), toModelOutput: ({ output }) => ({ type: "json", value: modelOutputSchema.parse(output), }), }), };const tools = { fetch_order: tool({ description: "Fetch an order by id", inputSchema, outputSchema, execute: async ({ orderId }) => ({ orderId, status: "shipped" as const, total: 129.99, name: "Jane Doe", address: { street: "742 Evergreen Terrace", city: "Springfield", country: "USA", }, }), toModelOutput: ({ output }) => ({ type: "json", value: modelOutputSchema.parse(output), }), }), };
The execute function returns the full record — address and all. But toModelOutput parses it through the derived schema before it reaches the model. The address is stripped out automatically.
What the model actually sees
Run the tool and inspect what gets sent back to the model on the next step:
const result = await generateText({ model: openai("gpt-4o-mini"), tools, prompt: "What is the status of order #1234?", stopWhen: stepCountIs(3), });const result = await generateText({ model: openai("gpt-4o-mini"), tools, prompt: "What is the status of order #1234?", stopWhen: stepCountIs(3), });
The raw tool result (what execute returned) still has everything:
{ "orderId": "1234", "status": "shipped", "total": 129.99, "name": "Jane Doe", "address": { "street": "742 Evergreen Terrace", "city": "Springfield", "country": "USA" } }{ "orderId": "1234", "status": "shipped", "total": 129.99, "name": "Jane Doe", "address": { "street": "742 Evergreen Terrace", "city": "Springfield", "country": "USA" } }
But the function call output sent to the model on the next step has the address stripped:
{ "orderId": "1234", "status": "shipped", "total": 129.99, "name": "Jane Doe" }{ "orderId": "1234", "status": "shipped", "total": 129.99, "name": "Jane Doe" }
The model responds with only what it can see:
The order #1234 is shipped. The total amount is $129.99 and it was placed by Jane Doe.
No address leaked.
Why this works well
The key insight is that your schema becomes the single source of truth. Want to hide a new field? Add .meta({ hidden: true }) and you're done. No second schema to update, no manual filtering, one source of truth.
The stripHidden function is generic — it works with any Zod object schema, so you can reuse it across all your tools. And because it's driven by metadata on the schema itself, you can see at a glance which fields are hidden just by reading the schema definition.
This pattern keeps sensitive data out of model context windows without sacrificing type safety or adding maintenance burden. Your tools return full data for your application to use, while the model only sees what it needs.