Crux
CookbookBasics

Structured extraction

Pull a typed object out of unstructured text, with auto-retry on schema failure.

This recipe shows the canonical structured-output pattern: define a Zod schema, let the model fill it, and auto-recover when the model returns malformed output. Production-grade with one extra option.

Primitives used

  • prompt() with output: z.object({...})
  • validationRetry: text-repair + auto-retry on Zod-parse failure
  • Optional: constraint() for business-rule validation
  • @use-crux/ai generate(): the Vercel AI SDK adapter

When to reach for this pattern

  • You have unstructured text (an email, a support ticket, a PDF page) and need a typed object out
  • Schema occasionally fails to parse: wrong types, missing fields, hallucinated enum values
  • You want production reliability without writing manual parse-and-retry logic

Full code

lib/ai/extract.ts

import { prompt } from "@use-crux/core";
import { generate } from "@use-crux/ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const TicketSchema = z.object({
  category: z.enum(["bug", "feature", "question", "billing"]),
  priority: z.enum(["low", "medium", "high", "urgent"]),
  affectedArea: z
    .string()
    .describe('Product area, e.g. "checkout", "login", "search"'),
  summary: z.string().max(280),
  customerSentiment: z.enum(["neutral", "frustrated", "angry", "happy"]),
  needsHumanReview: z.boolean(),
});

export type Ticket = z.infer<typeof TicketSchema>;

export const classifyTicket = prompt({
  id: "classify-ticket",
  input: z.object({ body: z.string() }),
  output: TicketSchema,
  system: `You classify customer support tickets into a structured record.
Pick the single most accurate category and priority. Set needsHumanReview to true
when the message contains threats, legal language, or is from a high-tier customer.`,
  prompt: ({ input }) => input.body,
  settings: { temperature: 0 },
});

export async function classify(body: string): Promise<Ticket> {
  const result = await generate(classifyTicket, {
    model: openai("gpt-4o-mini"),
    input: { body },
    validationRetry: { maxRetries: 2 },
  });
  return result.object;
}

Calling it

import { classify } from "@/lib/ai/extract";

const ticket = await classify(`
  My subscription was double-charged this morning. I've tried to cancel
  but the cancel button doesn't work. This is the third time this has
  happened. I want my money back.
`);

// {
//   category: 'billing',
//   priority: 'high',
//   affectedArea: 'subscription',
//   summary: 'Customer double-charged, cancel button broken, third occurrence, requesting refund.',
//   customerSentiment: 'angry',
//   needsHumanReview: true
// }

How it works

  1. Zod schema as the contract. prompt({ output: TicketSchema }) tells Crux this is a structured-generation prompt. The adapter calls generateObject() (not generateText()) and validates the result against the schema.

  2. Schema-failure recovery via validationRetry. When the model returns malformed JSON or wrong types, Crux runs three tiers of recovery:

    • Text repair (zero-cost): strips markdown fences, trailing commas, extracts JSON from prose
    • Schema validation: safeParse() against the Zod schema
    • Model retry: appends the model's failed output + the Zod error as a corrective message and re-calls the model
  3. temperature: 0 for determinism. Classification benefits from deterministic output. Bumping temperature here costs you reliability without buying creativity.

  4. .describe() on schema fields is the under-rated bit. Zod descriptions become part of the JSON Schema sent to the model. Concrete hints ("Product area, e.g. 'checkout', 'login'") significantly reduce schema failures.

Variations

Add business-rule constraints

validationRetry recovers from schema failures. To enforce semantic rules ("summary must mention the affected area"), layer in a constraint:

import { boundary, constraint } from "@use-crux/core/safety";

const summaryMentionsArea = constraint({
  id: "summary-mentions-area",
  on: boundary.output.object<Ticket>(),
  severity: "assert",
  run: async (ticket) => {
    if (
      !ticket.summary.toLowerCase().includes(ticket.affectedArea.toLowerCase())
    ) {
      return {
        pass: false,
        feedback: "Summary must mention the affected area.",
      };
    }
    return { pass: true };
  },
});

const result = await generate(classifyTicket, {
  model: openai("gpt-4o-mini"),
  input: { body },
  validationRetry: { maxRetries: 2 },
  constraints: [summaryMentionsArea],
});

validationRetry runs first (handles parse failures); constraints run after (handle semantic failures).

Streaming partial objects

If the schema is large and you want progressive UI updates, stream the prompt and read partialOutputStream:

const result = await stream(classifyTicket, {
  model: openai("gpt-4o"),
  input: { body },
});
for await (const partial of result.partialOutputStream) {
  // Canonical partial input, published only once each value is release-ready.
}
const { object } = await result.completion;

Do not reach for the AI SDK's streamObject directly to get partials. It resolves before terminal Safety and sees only one attempt, so its partials can carry content a guardrail, constraint, or validation retry was about to reject. partialOutputStream is a projection of the same canonical text Crux publishes on textStream, so it can only ever describe committed output.

Cheaper model with fallback

For high-volume extraction, start with a cheap model and fallback() to a stronger one on qualifying failures or unusable parsed results:

import { fallback } from "@use-crux/core";

const model = fallback([openai("gpt-4o-mini"), openai("gpt-4o")]);

await generate(classifyTicket, {
  model,
  input: { body },
  validationRetry: { maxRetries: 2 },
});
// If gpt-4o-mini exhausts retries, automatically tries gpt-4o

Where to next

On this page