What you'll build
A function that turns a customer message into a typed support ticket, and asks the model to correct itself when its answer is wrong. It works with any provider, because the model call is passed in as a function.
Models usually return the shape you asked for, but "usually" isn't good enough for code that writes to a database. Treat model output like user input: validate it first.
npm install zod1. Define the shape once
A Zod schema gives you runtime validation and a TypeScript type from one definition. Zod 4 can also produce JSON Schema, so the prompt and the validator can't drift apart.
import { z } from "zod";
export const Ticket = z.object({
category: z.enum(["billing", "bug", "question"]),
priority: z.number().int().min(1).max(3),
summary: z.string().min(5).max(120),
});
export type Ticket = z.infer<typeof Ticket>;
export const ticketJsonSchema = z.toJSONSchema(Ticket);2. Handle a bad reply
A reply can fail in two ways: it isn't JSON at all, or it's JSON with the wrong fields. This helper turns both into a normal validation result, so the calling code only has one case to handle.
function parseReply(reply: string) {
try {
return Ticket.safeParse(JSON.parse(reply));
} catch {
return Ticket.safeParse(undefined);
}
}3. Retry with the error message
Don't send the same prompt again. Tell the model exactly what was wrong. z.prettifyError produces text that's easy for it to act on.
type CallModel = (prompt: string) => Promise<string>;
export async function extractTicket(message: string, callModel: CallModel, maxAttempts = 3): Promise<Ticket> {
let prompt = `Return only JSON matching this schema:\n${JSON.stringify(ticketJsonSchema)}\n\nMessage:\n${message}`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const reply = await callModel(prompt);
const result = parseReply(reply);
if (result.success) return result.data;
prompt += `\n\nYour last reply was invalid:\n${reply}\n\nProblems:\n${z.prettifyError(result.error)}\n\nReturn corrected JSON only.`;
}
throw new Error(`No valid ticket after ${maxAttempts} attempts`);
}4. Try it without an API key
A fake model that fails once proves the retry path works, and it makes a good unit test:
const replies = [
'{"category":"refund","priority":5,"summary":"Refund"}',
'{"category":"billing","priority":2,"summary":"Customer was charged twice"}',
];
const ticket = await extractTicket("I was charged twice!", async () => replies.shift()!);
console.log(ticket);The first reply fails validation, and the model receives this feedback in its next prompt:
✖ Invalid option: expected one of "billing"|"bug"|"question"
→ at category
✖ Too big: expected number to be <=3
→ at priorityThe second reply passes, and ticket is fully typed.
Next steps
- Replace the fake with a real call to your provider's SDK.
- If your provider supports structured outputs, pass it
ticketJsonSchemaand keep the Zod check as a second line of defence. - Log every failed attempt. A rise in retries is often the first sign that a prompt or model change went wrong.