Ultimate Guide to JSON Contracts in OpenAI Automations
Learn to enforce JSON contracts with Zod for seamless OpenAI integrations and robust background job management.

📚 Get Practical Development Guides
Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.
Last month I wired OpenAI into a handful of background jobs—frontmatter generation, classification, some Sanity sync glue. Everything looked fine until one run slipped a trailing sentence into the YAML block and the publish pipeline keeled over. That was the moment I stopped treating model output like prose and started treating it like an API response. This guide walks you through the JSON Structured Outputs pattern I now use everywhere: define the schema in Zod, hand the same contract to OpenAI, validate the response, and only then let it touch your workflow. I’ll show the approach with a frontmatter example, but you can drop it into any automation that expects typed data.
Why Free-Form Responses Break Real Pipelines
Plain-text prompts (“please return YAML” or “reply with JSON”) work right up until they don’t. One extra comma, missing key, or over-eager explanation and your parser explodes. When a script powers deployments, migrations, or publish workflows, “almost correct” is still a failure. The fix is to stop relying on convention and let the model know exactly what shape you expect—then verify it just like you would an HTTP payload. OpenAI calls this Structured Outputs; I anchor it with Zod so TypeScript keeps me honest on the client side.
Step 1 – Model Your Response With Zod
Start by codifying the shape you need. Zod gives you runtime validation and TypeScript types in one go. Here’s the schema I use for our frontmatter generator, but the idea applies to financial summaries, translation metadata—anything you expect to be structured.
// File: content/scripts/generate-frontmatter.ts
const FrontmatterResponseSchema = z.object({
slug: z.string().min(1),
title: z.string().min(1),
subtitle: z.string().nullable(),
excerpt: z.string().nullable(),
metaDescription: z.string().nullable(),
authorId: z.string().nullable(),
categories: z.array(z.string()).nullable(),
keywords: z.array(z.string()).nullable(),
isHowTo: z.boolean().nullable(),
difficulty: z.enum(['beginner', 'intermediate', 'advanced', 'expert']).nullable(),
rating: z.number().nullable(),
tools: z.array(z.string()).nullable(),
totalTime: z.string().nullable(),
steps: z
.array(
z.object({
name: z.string(),
text: z.string(),
url: z.string().nullable(),
})
)
.nullable(),
faqs: z
.array(
z.object({
question: z.string(),
answer: z.string(),
category: z.enum(['general', 'technical', 'pricing', 'support']).nullable(),
order: z.number().nullable(),
})
)
.nullable(),
articleImage: z.string().nullable(),
imagePromptOverride: z.string().nullable(),
publishedAt: z.string().nullable(),
})
Once this exists you can call FrontmatterResponseSchema.parse(raw) and immediately detect missing fields or type mismatches. More importantly, you can reuse the same schema across jobs so the contract lives in one place.
Step 2 – Share The Same Schema With OpenAI
Structured Outputs tell the model “this is the JSON schema you must satisfy.” I convert the Zod structure into OpenAI’s JSON schema format and pass it through the Responses API. The strict mode flag forces the model to respect every required field instead of guessing.
// File: content/scripts/generate-frontmatter.ts
const FRONTMATTER_RESPONSE_FORMAT: ResponseFormatTextJSONSchemaConfig = {
type: 'json_schema',
name: 'frontmatter_payload',
description:
'Structured frontmatter for Sanity CMS articles including SEO metadata and tutorial scaffolding',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
slug: { type: 'string', description: 'URL slug for the article' },
title: { type: 'string', description: 'SEO-optimized article title' },
subtitle: { anyOf: [{ type: 'string' }, { type: 'null' }] },
excerpt: { anyOf: [{ type: 'string' }, { type: 'null' }] },
metaDescription: { anyOf: [{ type: 'string' }, { type: 'null' }] },
authorId: { anyOf: [{ type: 'string', format: 'uuid' }, { type: 'null' }] },
categories: {
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }],
},
keywords: {
anyOf: [{ type: 'array', items: { type: 'string' }, minItems: 0 }, { type: 'null' }],
},
isHowTo: { anyOf: [{ type: 'boolean' }, { type: 'null' }] },
difficulty: {
anyOf: [{ type: 'string', enum: ['beginner', 'intermediate', 'advanced', 'expert'] }, { type: 'null' }],
},
rating: { anyOf: [{ type: 'number', minimum: 1, maximum: 5 }, { type: 'null' }] },
tools: { anyOf: [{ type: 'array', items: { type: 'string' }, minItems: 0 }, { type: 'null' }] },
totalTime: { anyOf: [{ type: 'string' }, { type: 'null' }] },
steps: {
anyOf: [
{
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string' },
text: { type: 'string' },
url: { anyOf: [{ type: 'string' }, { type: 'null' }] },
},
required: ['name', 'text', 'url'],
},
},
{ type: 'null' },
],
},
faqs: {
anyOf: [
{
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
question: { type: 'string' },
answer: { type: 'string' },
category: {
anyOf: [
{ type: 'string', enum: ['general', 'technical', 'pricing', 'support'] },
{ type: 'null' },
],
},
order: { anyOf: [{ type: 'number' }, { type: 'null' }] },
},
required: ['question', 'answer', 'category', 'order'],
},
},
{ type: 'null' },
],
},
articleImage: { anyOf: [{ type: 'string' }, { type: 'null' }] },
imagePromptOverride: { anyOf: [{ type: 'string' }, { type: 'null' }] },
publishedAt: { anyOf: [{ type: 'string', format: 'date-time' }, { type: 'null' }] },
},
required: [
'slug',
'title',
'subtitle',
'excerpt',
'metaDescription',
'authorId',
'categories',
'keywords',
'isHowTo',
'difficulty',
'rating',
'tools',
'totalTime',
'steps',
'faqs',
'articleImage',
'imagePromptOverride',
'publishedAt',
],
},
}
If you already describe your types in Zod, you can generate this block automatically. The important piece is that the model now knows the contract up front instead of inferring it from prompt wording.
Step 3 – Generate, Parse, And Fail Fast
Now you can call the Responses API, turn the JSON string back into a JavaScript object, and let Zod guard the gate. I run this inside a CLI that updates Markdown, but the pattern drops into queues, webhooks, or wherever you need structured output.
// File: content/scripts/generate-frontmatter.ts
const response = await client.responses.create({
model: 'gpt-4o-mini',
input: [
{
role: 'user',
content: prompt,
},
],
text: {
format: FRONTMATTER_RESPONSE_FORMAT,
},
})
const outputText =
response.output_text ??
response.output
?.map((item) =>
item.type === 'message'
? item.content
.map((contentItem) => (contentItem.type === 'output_text' ? contentItem.text : ''))
.join('')
: ''
)
.join('')
if (!outputText) {
console.error('OpenAI response did not contain text output.')
process.exit(1)
}
let parsedPayload: FrontmatterPayload
try {
const raw = JSON.parse(outputText)
parsedPayload = FrontmatterResponseSchema.parse(raw)
} catch (error) {
// Log the raw payload for inspection; exit non-zero so pipelines halt.
writeFrontmatterLog({ slug: slugFallback, response: outputText, promptSummary })
process.exit(1)
}
This is where the safety kicks in. If OpenAI drops a field, the Zod parser throws, we stash the raw payload for debugging, and the script exits. Structured outputs combined with validation give you predictable failure modes instead of corrupt state sneaking into production.
Step 4 – Normalize And Reuse The Pattern Anywhere
Once the payload is trustworthy, map it into whatever shape your system expects. In the frontmatter flow, I trim strings, dedupe arrays, enforce default author IDs, and rewrite the Markdown. You could just as easily insert the data into a database or hand it to another microservice.
// File: content/scripts/generate-frontmatter.ts
const { data, slug } = mapPayloadToFrontmatter(parsedPayload, slugFallback, nextPublishAt)
const updatedFile = matter.stringify(parsedDraft.content.trimStart(), data)
fs.writeFileSync(options.draftPath, updatedFile, 'utf8')
writeFrontmatterLog({
slug,
response: outputText,
promptSummary,
})
Treat this function as the place to enforce business rules: default values, canonical slug formatting, deduping keywords, anything that keeps downstream services consistent. The win is that you now control the entire lifecycle—schema definition, model generation, validation, and normalization—before touching persistent state.
Beyond Frontmatter: Where This Pays Off
Once I had this flow working, I reused it everywhere: classifying categories, generating LLM backfill payloads, even building newsletter outlines. Anywhere the old prompt might have said “reply with JSON,” I now hand the model a schema and let Zod police the aftermath. The publish orchestrator got a lot calmer, but the same pattern applies to webhook handlers, pricing calculators, or AI-assisted migrations.
Wrapping Up
We went from hand-wavy “please reply with YAML” to a hard contract enforced on both sides. OpenAI’s JSON Structured Outputs tell the model what to return; Zod and TypeScript confirm it before your automation runs with it. The result is deterministic LLM output that plays nicely with CI, cron jobs, and production pipelines. Define the schema once, feed it to the model, validate the payload, then normalize and save it. Your future self—and your deployment logs—will thank you.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija