
We Open-Sourced an AI Agent for Coverage Citations: And Broke It Twice
agent-for-insurance is an open-source drafting aid that will not state a coverage conclusion without citing your policy's own text. Here's how it works, and the two parsing bugs that taught us why that rule has to be enforced in code, not prose.
Key takeaways
- The agent may never state a coverage conclusion without citing a clause's section id, document id, and exact quoted text from the user's own uploaded policy, no clause, no answer, enforced in instructions.md, in find_clause's output shape, and in dedicated eval cases.
- A parsing bug once let an unrecognised heading inherit the kind of the section before it, so a real coverage grant got cited, correctly quoted, as an exclusion, proof that a wrong citation is more dangerous than no citation at all.
- A second bug let an unreadable, image-only PDF produce zero policy sections with no error, and the agent reported the policy as silent on every question instead of saying it could not read the document.
- claim_deadlines computes proof-of-loss and suit-limitation dates only from figures the user supplies from the policy text, and a month-end date-math bug once rolled 2026-01-31 plus one month into March instead of clamping to February 28, wrong in the direction that could bar a claim.
We just open-sourced agent-for-insurance, a drafting aid for independent claims adjusters. Upload a policy, ask a coverage question, get back what the document actually says and the exact clause that says it.
Before anything else: this is a drafting aid for a licensed adjuster, not a coverage determination and not legal advice. It cites clauses; the licensed adjuster still decides, reviews, and signs. We put that line at the top of this post for the same reason the repo's own README puts it in bold directly under the H1 instead of buried forty paragraphs down in a caveats list. That's a fix we had to make ourselves, and one of the two stories below is about exactly that kind of near-miss. Nothing here is legal advice, and nothing here should be read as asserting any state's insurance law as universal.
The problem
Answering "is this covered" for real means three things happening correctly: read the policy, find the clause that actually governs this fact pattern, and be able to point at it (section, page, exact words) so someone else can check your work. An adjuster with 40 files open before lunch can do this carefully, or can do it fast, and those trade off exactly the way you'd expect: careful reading catches the carve-back buried in the endorsements; fast reading catches the deadline. The usual failure isn't "the adjuster doesn't know insurance." It's "the adjuster read the exclusion and didn't have time to check whether an endorsement restored it."
An LLM is a plausible tool to speed up the "find the clause" step. It's also a genuinely dangerous one for it, because a model is extremely good at producing a confident, fluent coverage opinion that isn't actually grounded in the document in front of it. In claims handling, an uncited or wrongly-cited coverage statement is a bad-faith exposure, not just a wrong answer. So the design starts from one constraint and works outward from there.
The one hard rule, and why it is the whole design
The agent may not state a coverage conclusion without citing a clause from the user's own uploaded policy. No clause, no answer.
Not "policies like this typically...", not a paraphrase, not a memory of language read three turns ago. A section id and the exact quoted text, every time, or a plain statement that the policy is silent. This is enforced in three places, because a rule that lives only in a system prompt is a rule a long enough conversation can talk a model out of.
First, agent/instructions.md: the literal top of the agent's instructions:
## The rule that overrides everything else
**You may never state a coverage conclusion — what is covered, what is
excluded, what applies, what does not — without citing a specific clause
from the user's own uploaded policy: its section id and its quoted text.**
- "No clause, no answer" is absolute. If `find_clause` returns zero
candidates, or you have not called `find_clause` at all, say the policy
appears silent on the point (or that you have not checked yet) — never
assert a coverage position anyway, and never fill the gap with general
insurance knowledge, a "policies typically..." statement, or what a
similar carrier's form usually says.
Second, the shape of what find_clause returns. It doesn't hand back a bare array and hope the model notices it's empty. Every response carries citationRequired: true and a guidance string spelling out exactly what the model is and isn't allowed to conclude. More on that below, because the output shape is doing real enforcement work, not just prose.
Third, a dedicated eval case. evals/cases/refusals.json checks the negative case directly: that retrieval itself, with no model in the loop, returns nothing when there's nothing to find:
{
"name": "no clause supports theft coverage — the agent must refuse to conclude",
"query": "theft of jewelry",
"expectCandidates": false
}
I ran this against the current code (a homeowners fixture whose insuring agreement, exclusions, conditions, and endorsements never mention theft) and findClauses returns exactly what's expected: []. An empty array is the correct, complete answer to a question the policy doesn't address. tests/evals.test.ts runs both refusals.json cases on every npm test, alongside a direct check that a flood-only fixture never yields a fabricated candidate for a cyber-liability query.
Why retrieval, not knowledge
The agent doesn't know insurance law. It locates clauses in the specific document you gave it, and that's the entire scope of what it reasons from.
splitPolicy (agent/lib/policy.ts) walks the uploaded text line by line into addressable sections: declarations, insuring_agreement, exclusions, conditions, endorsements, definitions, or unclassified, each with a stable id, a documentId, and real startLine/endLine numbers into the original text. A heading is recognized against a small, deliberately conservative pattern set; anything that looks heading-shaped but matches no known pattern still opens its own unclassified section rather than getting folded into whatever came before it.
findClauses(sections, query) then does retrieval, nothing else. It extracts significant terms from the query, splits each section into paragraphs, and returns a paragraph as a candidate only if it contains a query term as a whole word. Every candidate carries documentId, sectionId, sectionKind, and the exact quoted text, never the section's own heading, so a quote is always real policy language. It never concludes anything. Running it against a sample homeowners policy for "mold damage in the basement" returns two real candidates:
{
"candidateCount": 2,
"candidates": [
{
"sectionId": "section-3",
"sectionKind": "exclusions",
"quote": "We do not cover loss caused by mold, fungus, or wet rot except as provided under the Limited Fungi Coverage endorsement."
},
{
"sectionId": "section-5",
"sectionKind": "endorsements",
"quote": "Limited Fungi Coverage: This endorsement provides up to $10,000 of coverage for loss caused by mold or fungus when the underlying cause of loss is otherwise covered."
}
],
"citationRequired": true,
"guidance": "2 candidate clause(s) found, including at least one from a coverage-bearing section (insuring agreement, exclusion, endorsement, or condition). If you state a coverage conclusion, cite one of those by section id, document id, and quote its text verbatim. Do not cite a declarations or definitions match as if it settles coverage."
}
That guidance field is the actual enforcement. It distinguishes three situations that must never collapse into one another: candidates from coverage-bearing text (cite one), a genuine zero-match search against a policy that parsed fine (silent, say so), and matches that exist only in declarations, definitions, or unclassified. A definition of a word is not a decision about whether the thing is covered. Getting that third case wrong is exactly the bug below.
The two bugs worth teaching
Both shipped, got caught in review, and got fixed before the repo went public. Neither is exotic. They're the kind of thing that happens when a heading regex meets a real-world PDF.
Inverted citations
The first splitPolicy only opened a new section when a line matched one of six recognized headings; anything else got silently absorbed into whatever section was already open, inheriting its kind. Fine for a clean, six-heading textbook policy. Not fine for a real ISO HO-3 form (the most common US homeowners policy) where SECTION I - PROPERTY COVERAGES and SECTION I - PERILS INSURED AGAINST weren't recognized at all, so the entire insuring agreement could land inside whatever section preceded it, including EXCLUSIONS.
I reproduced the actual bug by running the pre-fix splitPolicy/findClauses (commit ad56714) against a fixture with an EXCLUSIONS heading followed by an unrecognized ADDITIONAL COVERAGES heading, now a permanent regression case in evals/cases/coverage-citations.json:
[
{
"sectionId": "section-1",
"sectionKind": "exclusions",
"quote": "ADDITIONAL COVERAGES We cover sudden and accidental water damage from a burst pipe up to $25,000."
}
]
A real coverage grant (up to $25,000 for burst-pipe water damage) came back tagged exclusions, and the pre-fix guidance read "cite one of these." The agent then did exactly what its safety rule demanded: cited a real clause, quoted verbatim, and stated a conclusion. The citation was completely real. The conclusion was backwards. That's worse than finding nothing, because a correctly-quoted citation is more persuasive than an admitted gap. An adjuster is far more likely to catch "I couldn't find anything" than a correctly-quoted clause attached to the wrong label.
The fix is two-tiered: a recognized heading gets its specific kind, but any other heading-shaped line also opens a fresh section, tagged unclassified instead of inheriting the prior section's kind. Same fixture against current code:
[
{
"sectionId": "section-2",
"sectionKind": "unclassified",
"quote": "We cover sudden and accidental water damage from a burst pipe up to $25,000."
}
]
Now the candidate is honestly labeled unclassified, which find_clause's guidance treats the same as finding nothing: the agent reports no supporting language and a human reads the raw section. A misfiled clause is worse than an unfiled one, so any heading-shaped line, recognized or not, now always starts a fresh section rather than being silently swallowed.
An unreadable upload read as a silent policy
The second bug was quieter and arguably worse, because it produced a confident answer with no citation behind it, dressed up as an honest refusal. A scanned, image-only PDF extracts zero text. Zero text fed into the original splitPolicy produced zero sections, and zero sections fed into findClauses produced an empty candidate array: the identical output shape as a policy that parsed fine and genuinely says nothing about the query. find_clause's guidance for an empty array said "the policy appears silent." There was no way, from that output alone, to tell "I read this whole policy" apart from "I never read anything."
The fix is a gate, ingestPolicyText, that ingest_policy calls before ever handing sections back. This is the actual reason string it returns right now for empty input. I ran it directly:
No text could be read from this upload. This is typical of a scanned,
image-only PDF with no extractable text layer. Re-upload a text-based
version of the policy, or run OCR first — do not treat this as the policy
being silent on any coverage question, because the document was not read
at all.
Below roughly 200 extracted characters, or when text extracts but still yields zero parseable sections, ingest_policy returns ok: false with that explanation instead of an empty section list. find_clause has its own independent check too: handed an empty sections array, it returns refused: true rather than reporting silence. "I could not read this policy" and "this policy says nothing about X" are different statements with very different consequences for a claim, and the system can no longer collapse one into the other.
One more, smaller bug from the same review pass: the original findClauses matched query terms as plain substrings, so a query for "roof damage" (extracting the term roof) matched inside "proof of loss". Since roof claims are one of the most common homeowners losses and nearly every policy's conditions section contains "proof of loss," that single false positive flipped the guidance for a routinely asked question from the policy appears silent, don't conclude to cite one of these, offering the proof-of-loss deadline as if it addressed roof coverage. I confirmed it directly: the pre-fix matcher returns one candidate (the proof-of-loss sentence) for "roof damage"; the fixed matcher, anchored on \b word boundaries, returns [].
Deadlines are computed, never assumed
claim_deadlines wraps claimDeadlines(input: { lossDate: string; policyTerms: PolicyTerms }), where PolicyTerms is { proofOfLossDays?: number | null; suitLimitationMonths?: number | null }. Every field is optional, and there is no default-days constant anywhere in the codebase, on purpose. An omitted or null term returns { known: false, reason: "..." } instead of falling back to a commonly-seen figure like 60 days:
{
"known": false,
"reason": "The policy's proof-of-loss period was not supplied. This deadline varies by policy and state — read it from the declarations or conditions section rather than assuming a figure."
}
agent/instructions.md backs this with its own hard rule: never pass a figure "from memory or habit," only what's actually written in the policy. Supplied with real figures from a policy's own conditions section, the tool computes real dates:
{
"lossDate": "2026-06-15",
"proofOfLoss": { "known": true, "date": "2026-08-14", "basis": "60 day(s) from the date of loss (2026-06-15), as supplied from the policy." },
"suitLimitation": { "known": true, "date": "2028-06-15", "basis": "24 month(s) from the date of loss (2026-06-15), as supplied from the policy." }
}
The date arithmetic had its own bug, easy to miss because it only shows up at month boundaries. addMonths originally called Date.prototype.setUTCMonth directly, which rolls an out-of-range day into the following month instead of clamping. Running the pre-fix code (agent/lib/valuation.ts at commit 252f566) with a loss date of 2026-01-31 and a 1-month suit-limitation term returns 2026-03-03: January 31 plus one month, naively, spilling three days into March because February only has 28. The current, fixed code returns 2026-02-28 for the same input.
Three days doesn't sound like much until you notice the direction. A suit-limitation date computed later than the policy actually allows is claim-fatal in the unsafe direction: it tells an adjuster they have until March 3 when the policy says February 28. addMonths now computes the target month first and clamps the day to whatever that month actually has, and tests/valuation.test.ts pins this against both a common year and a leap year, plus a 31-August case, so it can't silently regress.
Deploying it, and its honest limits
It runs in your own Vercel project: no account, no SaaS in the middle, no third-party insurance-data API to configure. agent/agent.ts sets the model through Vercel AI Gateway; agent/channels/eve.ts gates the deployed endpoint behind HTTP basic auth and fails closed: without AGENT_BASIC_AUTH_USER/AGENT_BASIC_AUTH_PASSWORD set, it rejects every browser request in production rather than serving traffic unauthenticated. Setup is three values pasted into one Vercel prompt.
The README is direct about where this stops being reliable, and it's worth repeating rather than softening:
- It only knows what you upload. No idea an endorsement exists if you didn't include it, even a common one for the policy type.
- It cannot see claim history, prior coverage, an underwriting file, or state insurance bulletins.
- Policy language varies by carrier and jurisdiction. The same word (flood, ensuing loss, actual cash value) can mean something different from one form to the next. It reads this policy's own definitions and assumes nothing about industry-standard meanings.
- The section classifier is conservative by design, so it sometimes classifies less than a human reviewer would: an unrecognized heading lands in
unclassifiedrather than a guessed label, for the reasons above. find_clauseis keyword retrieval, not legal reasoning. It can miss a relevant clause phrased with none of your query's words, and surface one that shares a word but isn't on point. Read every returned quote yourself.
Four skill files (agent/skills/), covering a coverage position letter, exclusion traps, reservation of rights, and proof-of-loss prep, carry longer playbooks the model loads on demand. None are permitted to relax the no-uncited-conclusion rule; each says so explicitly, because a skill loaded mid-turn is exactly the kind of long, persuasive content that could otherwise talk a model into a shortcut.
Try it
The repo is at github.com/FoundrySoftHQ/agent-for-insurance, MIT licensed. npm test runs the full suite: 107 tests as of this post, including every eval case, with zero environment variables and zero network calls. If you find a real-world policy that trips the section classifier the way the ISO HO-3 form once did, that's a tests/policy.test.ts case and a HEADING_RULES fix, and we'd like to see it. Fork it, point it at a real policy, and read the guidance field before you trust the citation.
Related reading
Trusting AI generated code was never the right goal, and the 4 percent of developers who say they fully trust it prove nothing is broken: the fix is an AI code review process that makes verification cheap instead of asking how much to trust the output.
We built and shipped five open-source vertical AI agents. Every single one had the same class of defect: absent or unreadable input rendered as a confident, clean answer. Here is what that bug looks like, why tests miss it, and what actually catches it.
A practical look at the best open weight LLMs for agents in 2026, organized by which constraint, cost, latency, or data residency, should actually decide the pick.
Next Article
Let's build something great.
Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.