LangExtract - Efficiently extract structured data for your agents
The one thing my agents were quietly bad at, and the Google library that fixed it đ§žđ
Our family travels enough that the paperwork became its own chore. Every trip lands in my inbox as hotel confirmations, flight itineraries, a rental car voucher, a shipping notice for whatever I ordered for the trip, and an invoice or two. For years I retyped all of it into the systems I care about, one confirmation number at a time.
What I want is simple. An email arrives, and the right record lands in the right place without me reading it. Travel into Travel Hub, packages into Parcel, money into YNAB. The databases were the easy part. The work sits at the seam where a confirmation email turns into a row.
I built that seam the obvious way first. Hand the document to an agent, describe the fields I want, get JSON back, hope. It works most of the time, which sounds like a passing grade and isnât.
The fix came from changing what the model is permitted to say. Mine no longer hands me values. It points at a location in the document, and a library checks the pointer. Anything it canât find comes back flagged for me instead of filed. I babysat the old version. I leave this one alone.
I papered over this gap in public once. When I wrote about Travel Hub, one sentence slid right by:
My AI assistant [...] extracts hotel confirmations, parses invoices, and creates the corresponding records in Travel Hub automatically.
That sentence carried more weight than the rest of the post. Cloudflare D1 holds the data, a Worker runs the cron jobs, the MCP server exposes it to agents, and I can predict what every piece of that will do on any given morning. That one sentence covered the only step the system couldnât prove.
Why I stopped trusting clean JSON
Ask a language model to read a confirmation email and return JSON, and you get JSON back every time.
Ask for a confirmation number and one appears, with the right digit count and the right shape. Sometimes it isnât the number printed in the email. Ask for a total and you might get $154.00 out of a document that charged $20.00, because a trip ID in the next column looked like money. That one happened in my own data.
Wrong answers and right answers arrive in the same format with the same confidence. I would find out three months later at a front desk, holding a number nobody recognized.
Instructions donât fix this. âOnly use values that appear in the documentâ already sits in every prompt I write, and a model that drifts has no way to tell it drifted. The check belongs outside the model.
LangExtract
LangExtract is a Python library from Google, built first for clinical text, where an invented dosage costs more than a botched hotel booking.
Its rule: the model returns quotes, never values. Rather than âthe confirmation number is 84421997,â it has to hand back a passage lifted out of the document, and the library then proves that passage exists.
A stripped-down hotel confirmation:
Subject: Your Reservation Confirmation #84421997
Confirmation Number: 84421997
Check-In: Friday, September 4, 2026 (4:00 PM)
Check-Out: Monday, September 7, 2026 (11:00 AM)
I declare the fields I want as extraction classes with attributes, and teach the model with a few worked examples. It returns each quote alongside its reading of that quote:
{"check_in": "Friday, September 4, 2026 (4:00 PM)",
"check_in_attributes": {"date": "2026-09-04", "time": "16:00"}}
The check_in value has to be text copied out of the document. The interpretation, which is the part a model can get wrong, sits apart from it in the attributes.
Then the library goes to work. It splits long documents into buffers and runs them in parallel, the resolver parses the modelâs output, and the aligner tokenizes each quote and hunts for it in the source. Exact token match first. Failing that, a fuzzy pass on longest-common-subsequence overlap, gated on how much of the quote matched and how tightly the match packs together, so it canât stitch your quote together out of three separate paragraphs.
Survivors come back carrying a character interval and an alignment status. What my pipeline hands the database:
"hotel_conf_num": "84421997",
"start_date": "2026-09-04",
"start_time": "16:00",
"provenance": {
"hotel_conf_num": {"text": "84421997", "start": 44, "end": 52,
"verified": true, "match": "exact"}
}
I can check that confirmation number against characters 44 through 52 of the email.
An extraction the aligner canât place comes back with no character interval at all. A number the model invented appears nowhere in the document, so nothing matches, and the record arrives flagged for me to look at.
The model handles fuzzy recognition. The library handles verification. I quit asking either one to cover for the other.
LangExtract also renders the source as HTML with every extraction highlighted, and you can step through them one at a time. This is the libraryâs own demo, running on a line of prose:
The Pos [0-11] counter at the bottom anchors each highlight to a character range, and the attributes panel keeps the modelâs reading separate from the text it read. When a document comes back wrong, I can see which characters the model chose rather than guess at my prompt.
Make it a CLI, not a feature
I didnât build any of this into an agent. I wrote a command line tool and let the agents call it.
Claude suggested naming it cos-extract, after the Chief-of-Staff plugin that ships it. I went with glean, the word for gathering what the harvest left behind, which is what this does to a confirmation email. I still handle naming myself.
glean hotel /tmp/confirmation.pdfPoint it at a file and it returns the payload, the provenance, and the validation report. A utility that speaks stdin and stdout runs anywhere. Claude Code agents call it. Codex can call it. An OpenClaw skill can call it. The grounding and the verification live in the tool rather than inside any agentâs prompt, so moving this work between harnesses costs me nothing. Each agent decides what to do with a record that already checks out.
That subprocess boundary paid off a second way I hadnât planned for.
The context you donât pay for
Confirmation emails arrive as HTML, and marketing HTML wastes tokens at a scale thatâs hard to overstate. Nested tables, inline styles, tracking pixels, a mobile stylesheet, three copies of the logo, and one confirmation number buried in the middle of it. Paste that into an agentâs context and you pay for every closing </td>.
glean handles ingestion before any model sees the document. It pulls the plain text part out of an .eml, strips the HTML down when no plain part exists, and runs PDFs through pdfplumber. So the raw document never enters my conversation. No markup, no boilerplate, no 40-line unsubscribe footer. Those tokens burn inside a subprocess I throw away, and my session keeps the compact record. Across a dozen emails in one triage run, that gap decides whether the run fits at all.
Four things I added on top of LangExtract:
My parser outranks the model. Once a span is grounded, glean re-reads the raw text itself. When my parser and the model disagree about what âAug 3, 2026â means, I take my parser. The integer cents come from the same rule. Travel Hub stores money as integers, because floating point and money donât mix, and glean hands over values already shaped for the database, which removes one more place a rounding error can hide.
A stricter check on top of the aligner. LangExtractâs fuzzy fallback runs generous by design, so glean re-verifies every span itself. Exact match, then whitespace-normalized for chaotic PDFs, then token-grounded for tables that shred a value across columns. Each field records the tier it cleared, so I can see which numbers barely made it through.
Validation as data. glean exits clean when fields are missing and reports whatever it couldnât verify. An itinerary PDF has no confirmation number yet, and thatâs expected. If a price or an ID comes back unverified, the calling agent stops and asks me before writing anywhere.
No API key. My favorite piece of plumbing. A custom LangExtract provider shells out to claude -p, so inference bills against my Claude subscription. It runs nested inside a Claude Code agent, which I did not expect to work.
Does it actually work
Seven document types so far: hotels, flights, shipping notices, ground transport, and a few flavors of invoice and billing statement.
Two results sold me. A monthly billing statement came back with all 13 line items, and the categories summed to the balance due with no unknowns, which I could check by hand in a minute. A multi-hotel itinerary PDF split into two stays with the right date boundaries and flagged the confirmation numbers it couldnât find.
The 49 tests run without calling a model. glean records real model output keyed by prompt hash and replays it, so I test the pipeline end to end at no cost. Editing a prompt invalidates the recordings for that doctype.
A new document type is a YAML file: a prompt, the classes, a few examples, a mapping to output fields. One test checks that every exampleâs extracted text appears verbatim in its own example document, because few-shot examples that teach a model to hallucinate cost me an afternoon once.
What Iâd take away from it
I care less about model accuracy now than about whether my system catches the model being wrong.
Freeform extraction canât catch it, because every output looks equally plausible. Grounding gives me something to check against, and with that check in place I can automate the rest without wondering what got written into the database last Tuesday.
Travel Hub holds the data. glean decides what gets in. Hotels, flights, packages and invoices file themselves now, and I trust the numbers when I get to the front desk, because the model has to show its work.
One caveat: glean lives inside my private Chief-of-Staff repo, so thereâs no link for it below. Itâs a few hundred lines of Python wrapped around a library thatâs public and free, and the part worth copying was never my code anyway. Make the model point instead of answer, then go check where it pointed.
â Omar
Links:
đ LangExtract on GitHub: github.com/google/langextract
âď¸ The system this feeds: Travel Hub


