PDF extraction
PDF to JSON for AI Agents: Schemas, Evidence, and Failure Modes
Build PDF-to-JSON pipelines for AI agents with typed schemas, page evidence, parser evaluation, retries, and checks that catch silent extraction errors.
Most PDF-to-JSON demos stop when they print an object. Production starts one line later: when an agent uses that object to approve an invoice, update a ledger, or answer a customer.
At that point, valid JSON is a weak guarantee. A parser can return syntactically perfect data with the wrong total, a row joined across two tables, or a value copied from the footer. An agent will usually accept the object and continue. That is why a useful PDF-to-JSON pipeline needs three things beyond conversion: an explicit schema, source evidence for every consequential field, and a failure path that does not disguise uncertainty as success.
This guide builds that contract from ingestion through evaluation. The examples use TypeScript and JSON Schema, but the architecture is parser-agnostic. You can run it with a local text layer, an OCR engine, a document model, or a router such as okraPDF. The important part is that the downstream agent sees one stable interface.
Table of Contents
- Start with the output contract
- Treat every PDF as untrusted input
- Preserve evidence before extracting fields
- Extract into a strict JSON Schema
- Validate facts, not only types
- Give the agent one small document tool
- Evaluate the pipeline you will actually ship
Start with the output contract
“JSON” can mean three different products
Teams use PDF to JSON to describe at least three outputs. Mixing them creates brittle integrations.
| Output | Example | Best use |
|---|---|---|
| Page representation | text blocks, tables, coordinates | search, citations, reprocessing |
| Document schema | invoice number, total, line items | database writes and workflow rules |
| Agent answer | recommendation plus sources | review, research, decision support |
A page representation tries to preserve the document. A document schema throws most of the document away and keeps business fields. An agent answer is a claim derived from one or both. They should be related, but they should not be the same object.
If the parser only returns a flat object, you lose the material needed to debug it. If the agent receives every raw block, it spends context on headers, footers, and layout noise. Keep the rich parse as an internal artifact; expose the smallest schema that supports the task.
Write the schema before choosing a parser
The schema is the acceptance test for the workflow. Define it from the decision your application needs to make, not from whatever fields a sample parser happened to return.
For an invoice approval agent, a useful first contract might be:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["invoiceNumber", "vendor", "currency", "total", "lineItems"],
"properties": {
"invoiceNumber": { "type": "string", "minLength": 1 },
"vendor": { "type": "string", "minLength": 1 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"total": { "type": "number" },
"lineItems": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["description", "amount"],
"properties": {
"description": { "type": "string" },
"quantity": { "type": ["number", "null"] },
"amount": { "type": "number" }
}
}
}
}
}
JSON Schema 2020-12 gives you a portable contract rather than a prompt that only one model understands. additionalProperties: false is deliberate: unexpected keys should cause a visible schema failure, not quietly become part of your API.
Separate missing, unreadable, and absent
Do not force the extractor to invent a value because a property is required. There are three materially different states:
- The document states a value.
- The document does not contain the field.
- The field may exist, but the page is unreadable or ambiguous.
Model that distinction. Use null for a confirmed absence and a separate review issue for unreadable evidence. An empty string collapses both into one value and makes downstream logic guess.
This is the same reason regex-first PDF extraction breaks: a match says that characters were found, not that the right business fact was understood.
Write a short data dictionary beside the schema. For each field, record:
- Its business meaning, in one sentence
- Whether the value must be printed or may be derived
- The allowed unit, currency, timezone, or date convention
- What counts as confirmed absence
- Which source regions are acceptable evidence
- Whether a human must approve it before an external action
That dictionary prevents prompt wording from becoming the only definition of a field. It also gives reviewers and evaluators the same target. If two people disagree about the expected answer before a model runs, the problem is the contract, not the parser.
Treat every PDF as untrusted input
Validate bytes, limits, and ownership at ingestion
File ingestion is part of the extraction system. Check the PDF signature and parseability; do not trust a filename or Content-Type header. Set explicit limits for bytes, pages, decompressed images, and processing time. Store the file hash so retries can reuse a known source rather than create duplicate work.
If the input is a URL, protect the fetcher against private-network access, redirects to disallowed hosts, and oversized responses. The OWASP SSRF prevention guidance is a better starting point than a hand-written hostname check.
A safe ingestion record is small:
type IngestRecord = {
fileId: string;
sha256: string;
bytes: number;
pageCount: number;
source: 'upload' | 'url' | 'integration';
receivedAt: string;
};
Keep the original immutable. Parsing is derived work. You should be able to rerun a new parser against the same bytes and compare outputs without asking the user to upload again.
Detect the document path before paying for it
Not every PDF needs OCR or a vision model. A born-digital PDF may already have a usable text layer; a scan has pixels but no meaningful text; a hybrid file may have both, sometimes misaligned.
Use a cheap first pass to record page count, text coverage, image coverage, encryption state, rotation, and obvious corruption. Route the document after that probe.
| Signal | Likely route | Caveat |
|---|---|---|
| Dense text layer with sensible reading order | text/layout parser | tables may still need layout recovery |
| Little text, page-sized images | OCR or vision parser | check resolution and rotation first |
| Text plus large images | hybrid, page by page | hidden OCR may be stale or misaligned |
| Password or damaged xref | reject or repair queue | never report a successful empty parse |
This routing step reduces cost, but its larger benefit is explainability. When a result is wrong, you know which path produced it.
Make ingestion idempotent
Agents retry. Networks time out. Queue consumers restart. The same document will eventually arrive twice.
Use a content hash plus a caller-scoped idempotency key. If an earlier attempt is still running, return its job ID. If it completed under the same parser and schema versions, return the stored artifact. If either version changed, start an explicit reparse rather than overwriting history.
Preserve evidence before extracting fields
Normalize parser output into page nodes
Different parsers return Markdown, HTML, proprietary blocks, or raw model text. Normalize those formats before writing application logic.
A compact internal node needs an ID, page number, role, text, and optional bounding box. Keep the original vendor payload by reference for debugging rather than leaking it through every consumer.
type ParseNode = {
id: string;
page: number;
role: 'heading' | 'text' | 'table' | 'row' | 'cell' | 'figure' | 'unknown';
text: string;
bbox?: { x: number; y: number; w: number; h: number };
confidence?: number;
vendorPayloadRef?: { sha256: string; uri?: string };
};
This resembles the element model documented by Unstructured: the useful abstraction is not “one long string,” but typed elements with page and coordinate metadata.
Parse once, keep more than one projection
An agent-friendly pipeline often needs both nodes and Markdown. Nodes are better for evidence, filters, and deterministic processing. Markdown is compact and readable when a model needs broader context.
With the okraPDF SDK, a passive file parse can request both projections and an optional schema:
import { OkraClient } from '@okrapdf/sdk';
const okra = new OkraClient({ apiKey: process.env.OKRA_API_KEY! });
const file = await okra.files.upload('./invoice.pdf');
let job = await okra.parse({
fileId: file.id,
parser: 'textlayer',
outputs: { nodes: true, markdown: true },
schema: invoiceSchema,
metadata: { workflow: 'invoice-approval', schemaVersion: 'invoice@3' },
});
while (!job.terminal) {
await new Promise((resolve) => setTimeout(resolve, job.next_poll_after_ms ?? 1000));
job = await okra.getJob(job.id);
}
if (job.status !== 'succeeded' || !job.result) {
throw new Error(job.user_message ?? job.error_code ?? 'PDF parse failed');
}
The parser name is intentionally explicit. Record it with the model or variant, options, schema version, duration, and cost. “We parsed the invoice” is not enough provenance to reproduce a bad result.
For more parser-specific setup and benchmark commands, see Document Parsing for AI Agents.
Evidence is part of the value
For every field that can trigger an action, keep a source pointer:
{
"field": "total",
"value": 4280.17,
"source": {
"nodeId": "node_p2_018",
"page": 2,
"bbox": { "x": 0.71, "y": 0.82, "w": 0.18, "h": 0.04 },
"snippet": "Invoice total USD 4,280.17"
}
}
Coordinates make review fast, but page plus snippet is still useful when a parser has no bounding boxes. Our guide to pixel-grounded PDF citations covers how to turn that pointer into a reviewable URL instead of a dead page number.
Extract into a strict JSON Schema
Ask for fields from bounded context
Do not send an entire 300-page document to a model because one table may contain the answer. Use headings, page candidates, keywords, or a retrieval step to narrow the context. Then include adjacent nodes so row labels, units, and footnotes survive.
The extraction prompt should state the schema semantics, not repeat the JSON syntax. Explain what counts as the invoice total, whether credit notes are negative, and how to represent an absent purchase order. Those definitions are the domain contract.
Compile and validate at the boundary
Use a real validator such as Ajv or Zod after the extraction call even if the model provider advertises structured output. Provider-side constraints improve generation; your validator protects your application.
import Ajv from 'ajv';
const ajv = new Ajv({ allErrors: true, strict: true });
const validateInvoice = ajv.compile(invoiceSchema);
export function requireInvoice(value: unknown) {
if (!validateInvoice(value)) {
return {
ok: false as const,
code: 'schema_invalid',
issues: validateInvoice.errors ?? [],
};
}
return { ok: true as const, value };
}
Store validation errors as data. They tell you whether a schema is unrealistic, a prompt is unclear, or a parser dropped the source context.
Version every meaning-changing component
The output changes when the parser, model, prompt, schema, or normalization code changes. Give each one a version and put the tuple on the artifact.
{
"pipeline": {
"parser": "textlayer@2026-08",
"extractor": "invoice-fields@7",
"schema": "invoice@3",
"normalizer": "money@2"
}
}
This makes staged migrations possible. Reprocess a representative set, compare old and new output, then promote the new tuple. Without versions, a parser upgrade becomes an unmeasured production experiment.
Keep the decision trail with the version tuple. A complete run record should answer:
- Which source hash was read?
- Which pages were included or skipped?
- Which parser and extractor produced each artifact?
- Which schema and domain checks ran?
- Which checks failed or were waived?
- Who or what approved the final state?
Do not store prompts, page text, or credentials in ordinary application logs. Store stable artifact references and structured events, then protect the underlying document data according to its sensitivity. Observability should make a run reproducible without turning the logging system into a second ungoverned document store.
Validate facts, not only types
Add deterministic business checks
A number can satisfy JSON Schema and still be wrong. Add validators that know the document’s arithmetic and invariants.
For invoices, compare the subtotal, tax, discounts, and total within a defined tolerance. Check that line-item amounts roughly reconcile. Confirm the currency is stated in evidence rather than inferred from locale. For statements, verify opening balance plus signed transactions equals closing balance.
function validateInvoiceMath(invoice: Invoice) {
const lineTotal = invoice.lineItems.reduce((sum, item) => sum + item.amount, 0);
const delta = Math.abs(lineTotal - invoice.total);
return delta <= 0.02
? { ok: true as const }
: { ok: false as const, code: 'line_total_mismatch', delta };
}
These checks do not prove the extraction is correct. They catch a valuable class of silent errors cheaply.
Require evidence coverage
Define high-risk fields and refuse automation when they lack evidence. An invoice number may be low risk; bank coordinates, totals, dates, and identity fields are not.
Evidence coverage is a straightforward metric:
coverage = consequential fields with valid source pointers
-------------------------------------------------
all consequential fields
The threshold for an autonomous action can be 100% even when the threshold for a draft answer is lower. Risk belongs in policy, not in a vague confidence score.
Route uncertainty to a useful review state
“Human review” should not mean dropping a PDF into an inbox. Return the proposed value, the page crop, the rule that failed, and the competing candidates. The reviewer should confirm or correct a field in seconds.
Record the correction as evaluation data. Do not silently fine-tune or alter prompts from individual edits; aggregate and review them first.
Give the agent one small document tool
Keep parsing behind a deterministic boundary
An agent should not know every parser flag. Give it one tool with a narrow input, a stable result, and explicit review states.
type ExtractDocumentArgs = {
fileId: string;
schemaName: 'invoice@3' | 'bank-statement@2';
};
type ExtractDocumentResult =
| { status: 'verified'; data: unknown; evidence: FieldEvidence[] }
| { status: 'needs_review'; draft: unknown; issues: ReviewIssue[] }
| { status: 'failed'; code: string; retryable: boolean };
That union forces the agent to handle uncertainty. It cannot mistake a failed extraction for an empty document or a draft for verified data.
Return references, not a context dump
The tool response should contain the typed data, compact evidence, and IDs for deeper reads. Keep raw pages, images, and vendor payloads behind follow-up tools such as read_page, get_crop, or inspect_parse.
This two-step interface keeps context small and makes access control enforceable. A reviewer can see page 12 without the agent receiving every page in the file.
Make policy visible to the agent
Include the decision constraints in the tool description:
- Never approve when
statusisneeds_review. - Never calculate a missing field from outside knowledge unless the workflow permits derivation.
- Cite the returned page and evidence ID in user-facing claims.
- Retry only when
retryableis true, using the same idempotency key.
The model is still nondeterministic. The tool contract and the system performing side effects do not have to be.
Evaluate the pipeline you will actually ship
Build a small, adversarial gold set
Twenty representative documents with verified fields are more useful than a thousand unrelated public PDFs. Include born-digital files, scans, rotated pages, repeated headers, split tables, blank fields, handwritten notes, and at least one damaged or protected file.
Use public suites such as ParseBench to compare general parsing capabilities, then add your own field-level set. A public benchmark cannot encode your definition of “invoice total” or your tolerance for a missing shipment identifier.
When you assemble the domain set, include more than the clean happy path:
- One file from every major template family
- Rare but expensive cases, even if they occur only monthly
- A scan with a missing or duplicated page
- A multi-page table with a repeated header
- Negative amounts, credits, and parentheses notation
- Empty optional fields beside populated neighboring fields
- A visually plausible decoy value in a header or footer
- A document that must be rejected rather than parsed
Split documents by source family before creating train, tuning, and evaluation sets. Putting two months of the same vendor template on opposite sides of the split will exaggerate generalization. Keep the final evaluation set read-only and record every time someone changes an expected answer.
For each failure, label the earliest stage that could have prevented it: ingestion, rendering, parsing, retrieval, extraction, schema validation, domain validation, or policy. That attribution matters. Changing the model will not fix an expired URL, and changing a prompt will not fix a table whose columns were lost before extraction.
Score the layers separately
| Metric | What it diagnoses |
|---|---|
| Page/node coverage | ingestion or parser omissions |
| Table structure score | row/column reconstruction |
| Field exact match | extraction correctness |
| Numeric tolerance match | formatting versus material error |
| Evidence precision | whether citations point to the claimed value |
| Review rate | operational cost and autonomy ceiling |
| Silent-error rate | dangerous accepted results |
Do not collapse these into one “accuracy” number. A pipeline with 96% field accuracy and a 2% silent-error rate may be worse than one with 93% accuracy that routes every uncertain case to review.
Ship with a failure budget
Set thresholds before comparing parsers. For example: zero silent errors on bank coordinates, 100% evidence coverage for totals, less than 5% schema failures, and a review rate the operations team can actually staff.
Run the full pipeline—not just the parser—on every change to parsing, normalization, prompts, or schemas. Keep the source PDFs and expected outputs pinned. Report the failures by document and field so a good average cannot hide one catastrophic class.
The practical checklist is short:
- Immutable source and content hash
- Explicit parser and schema versions
- Structured output plus page evidence
- Schema and domain validation
- Idempotent jobs with honest terminal states
- Review artifacts that show the failing field in context
- A representative regression set with silent errors tracked separately
That is the difference between converting a PDF to JSON and giving an agent data it is allowed to act on. If you want to inspect the shape with your own file, the PDF to JSON tool exposes the schema-confirmation flow; if tables are the hard part, start with the Python table extraction guide.