One schema, both providers
Everything today rests on one idea, that the model fills in a schema you give it instead of writing free text. On Anthropic, that is tool use or the structured-outputs API, and on OpenAI it is a strict JSON Schema. With the Vercel AI SDK, the same Zod schema serves both, so you write each schema once.
Study first
- The 4 ways to get structure. You can ask for JSON in the prompt and parse it, or ask for XML tags and parse them, for a model with no constrained mode. You can also define a tool whose input schema is the output, or use a provider's structured-output mode. The last 2 constrain the output to the schema's shape. What a schema cannot express, such as a
z.refinerule or a date's format, can still come back wrong, and validation and the retry catch that. Output.objectin the AI SDK, from its guide to generating structured data.generateText({ output: Output.object({ schema }) })picks the provider's mode and validates with Zod. When the output does not fit, the call rejects withNoObjectGeneratedError(see its reference page), which carries the raw text and the cause.- The model is
chatModel("small"), which was Haiku in the reference run. It reads PDFs and images itself, so the pipeline needs no optical character recognition (OCR) step and nopdf-parse. Those belong to a model without document input, or to a text-only pipeline where a page's text is enough and cheaper. - Zod for documents. Use
nullable()for what a document may not state,describe()on every field, and enums for the fixed vocabularies.
Build
- Create
src/lib/document-model.tswith 4 schemas.Invoicehas vendor, number, dates, currency, lines, subtotal, tax, total and terms, andEmailhas from, to, subject, intent, actions and urgency.Letter, for letters and filled forms, has from, to, date, subject, summary, fields and requested.JobPostinghas title, company, location, salary range, requirements, responsibilities and workplace. Export them asschemas, keyed by kind. - Describe the enums in words the model can apply. For example, an email is a complaint when something went wrong, even if it asks for a fix.
Done when
pnpm test src/lib/documentspasses thetoRequestcases for 3 kinds. You can run it once you writetoRequestin the last part.pnpm extract content/eval/documents/email-1.txt --as emailprints anEmailrecord whoseintentiscomplaint. You can run it once you add the command in the next part and writeemail-1.txt, the damaged-delivery email, in the third.
Technical notes
- In the installed
@ai-sdk/anthropic,structuredOutputModedefaults toauto, which uses the structured-outputs API on the models that support it and a JSON tool on the rest. The OpenAI provider sends the JSON Schema withstrict: true, and your code does not change between them. - Ask for dates as
YYYY-MM-DD, and keep them as strings in the schema. A schema constraint cannot parse "October 1, 2026", but the model can. - Define each intent sharply. In the first live test of the reference run, the model read a damaged-delivery email as a request because it asked for replacements. The definition of a complaint in step 2 fixes that.

