InvoiceAgent is a working C#/.NET reference implementation of a self-correcting document-extraction agent for
accounts-payable automation. It OCRs a scanned invoice with IronOCR
(Tesseract under the hood), sends the extracted text to an LLM (OpenAI's gpt-5.5 in this build) to structure
the fields, and then hands everything to deterministic C# for arithmetic validation, field-level provenance
against the original OCR tokens, a two-rung retry ladder (alternate OCR character candidates, then a targeted
crop-and-reread at higher DPI), and escalation to a human reviewer when the numbers still don't add up.
It's built as a companion codebase to the article Building a Self-Correcting Document Extraction Agent in C# with IronOCR and Claude, turning that article's code samples into a compiling, testable, actually-runnable .NET solution — verified against a real IronOCR + real LLM run on a real (synthetically degraded) scanned invoice, not mocked.
Use this repo if you're evaluating: invoice OCR in C#/.NET, IronOCR + LLM extraction pipelines, OCR confidence scoring and field provenance, an arithmetic-validation retry ladder for noisy OCR, or how to wire Tesseract-based OCR output into an LLM structured-extraction call for AP / invoice-processing automation.
- Real failure→recovery case, not staged: a genuine OCR misread (
£412.50→£472.50, 58.9% confidence) caught by arithmetic validation, run through both retry rungs, and escalated with full provenance — seeartifacts/run-log.txtandartifacts/failure_before.png. - Field-level provenance: every extracted value is traced back to the exact OCR token(s) it came from, with that token's real confidence score and bounding box — never a model-invented confidence.
- A cost-ordered retry ladder: cheapest fix first (Tesseract's own alternate character candidates, no re-scan), then a targeted crop-and-reread at higher DPI, then escalation — never a blind retry loop.
- 3 real SDK/API drifts documented, found by actually compiling against current package versions rather than trusting the article's pinned versions — see "What changed vs. the article's code" below.
- 2 real bugs found only by running it for real (a DPI mismatch between OCR passes, a crop-reread that needs to fail gracefully) — see the "Run the pipeline for real" commit.
The article this repo is built from is specifically about Claude/Anthropic. The schema prompt, the
Temperature-deprecation finding, the model-ID comments — all of it was written and verified against the
Anthropic C# SDK. This repo's src/InvoiceAgent.Core/Extraction/OpenAiExtraction.cs calls OpenAI's Chat
Completions API instead, using the official OpenAI NuGet package and model gpt-5.5.
This was a deliberate, explicit choice made mid-build, not a silent substitution: no working
ANTHROPIC_API_KEY was available in this environment (only OPENAI_API_KEY, and the first value found there
was itself invalid — a fresh key had to be issued), and the user chose to switch providers rather than wait on
Anthropic credentials. If you want this repo to actually match the article, get an Anthropic API key and either
restore ClaudeExtraction.cs from git history (see the "Scaffold" commit) or write an equivalent against the
current Anthropic SDK using the API notes below.
Everything else in this repo — OCR, provenance mapping, validation, the retry ladder — is provider-agnostic and
unaffected by this swap; only Extraction/ and the OpenAI package reference in InvoiceAgent.Core.csproj
are OpenAI-specific.
Reviewer screen — what a human sees when the pipeline escalates. Real bounding box, real OCR confidence,
real escalation summary, generated from an actual run against
test-assets/invoice_scan.audit.json:
The disputed field, before escalation — IronOCR read £472.50 at 58.9% confidence where the printed amount
is £412.50:
- LLM provider: OpenAI, not Claude
- Pinned versions
- What changed vs. the article's code, and why
- Project layout
- Setup
- Running the pipeline
- What's not implemented here
The article's "Tested environment" note pins IronOcr 2025.11 and Anthropic 12.9.0. IronOcr was long
superseded on NuGet by the time this repo was built, so the newest available version was used instead, per the
task's own instruction to do that and report the difference. Anthropic was dropped entirely — see above.
| Package | Article pin | Actually installed | Notes |
|---|---|---|---|
IronOcr |
2025.11 | 2026.8.1 | |
IronPdf |
(unpinned) | 2026.8.1 | |
OpenAI |
— (article uses Anthropic) |
2.13.0 | Provider swap — see above |
SixLabors.ImageSharp |
— | 4.1.0 | Added for Phase 2's scan-degradation step (ImageMagick was not available in this environment) |
Target framework: net8.0, as the article specifies — but this machine only has the .NET 10 SDK/runtime
installed, no .NET 8 runtime. Every executable project (InvoiceAgent.Cli, InvoiceAgent.Tests) sets
<RollForward>LatestMajor</RollForward> so the net8.0-targeted apphost/testhost launches against the installed
net10 runtime instead of failing with "You must install or update .NET to run this application." Remove that
property once a real net8.0 runtime is installed, if you want a strict match.
Verified by actually restoring these package versions and compiling against them — not assumed from the article's text.
Anthropic SDK, found before the provider swap (kept here as a real, verified finding even though this repo no
longer uses the SDK — worth knowing if you restore ClaudeExtraction.cs):
MessageCreateParams.Temperatureis[Obsolete]in Anthropic SDK 12.40.0: "Models released after Claude Opus 4.6 do not support setting temperature." Sinceclaude-sonnet-5postdates that cutoff, the article'sTemperature = 0is not just unnecessary, it risks a 400.- No
TextContenttype exists in that SDK version'sAnthropic.Models.Messagesnamespace.Message.ContentisIReadOnlyList<ContentBlock>, whereContentBlockis a discriminated union (TryPickText,TryPickToolUse, ...), not a class hierarchy you can.OfType<T>()against — useblock.TryPickText(out var textBlock)instead.
IronOcr, still live in this repo:
OcrResult.Character.ChoicesisChoice[](a plain array) in IronOcr 2026.8.1, not a type exposing an instance.Countproperty —.Countresolved to the LINQ extension method group and failed to compile as.Count > 0.Reconciliation.csuses.Lengthinstead.
Everything else in the article's code blocks (the schema prompt, the provenance sliding-window matcher, the
per-line/subtotal/total validation checks, the character-substitution reconciliation logic, the crop-and-reread
mechanism, TargetDPI/EnhanceResolution/WhiteListCharacters) compiled as described once ported into this
project's namespaces.
InvoiceAgent/
├── src/
│ ├── InvoiceAgent.Core/ # OCR, extraction, provenance, validation, escalation — the article's C# logic
│ └── InvoiceAgent.Cli/ # `dotnet run -- invoice.pdf` entry point, wires the full pipeline together
├── tests/InvoiceAgent.Tests/ # xUnit tests for InvoiceValidator against the synthetic Bright Path invoice
├── test-assets/ # Synthetic test invoice (generated, not the article's real-world data)
└── artifacts/ # Run logs, cropped before/after images, reviewer-screen mockup
- Get a 30-day trial key from ironsoftware.com (one key validated for both IronOCR
and IronPdf in this build — Iron Software normally licenses them separately, so don't assume that holds for
your account) and an API key from platform.openai.com, then
set:
IRONOCR_LICENSE_KEY=... IRONPDF_LICENSE_KEY=... OPENAI_API_KEY=... dotnet builddotnet test tests/InvoiceAgent.Tests/InvoiceAgent.Tests.csproj
dotnet run --project src/InvoiceAgent.Cli -- test-assets/invoice_scan.pdf --verbose
Stages are logged as [LOAD], [OCR], [EXTRACT], [PROVENANCE], [VALIDATE], [RETRY 1], [RETRY 2],
[RESULT], matching the article's "Running the complete pipeline" section. --verbose additionally writes a
<document>.audit.json file next to the input, in the shape described under "Making it auditable".
Exit code 0 = accepted, 2 = escalated to human review, 1 = a hard failure before validation ran at all
(missing file, unparseable extraction).
- MCP layer: the article's "Wiring it to an agent with MCP" section is explicitly conceptual, not working
code. See
src/InvoiceAgent.Core/Mcp/README.md. - Three-way match: the PO/delivery-note matching described in "The three-way match" needs a second,
already-structured data source this repo doesn't have.
EscalationTrigger.MissingPurchaseOrderexists in the enum for completeness but is never raised.
- The full write-up this repo implements: Building a Self-Correcting Document Extraction Agent in C# with IronOCR and Claude — the architecture, the reasoning behind the OCR-confidence/LLM-classification/arithmetic-validation split, prompt injection considerations, and where this pattern is (and isn't) worth the complexity versus a template-based extractor.
- IronOCR documentation — the underlying Tesseract-based OCR engine for .NET used throughout this repo.
- IronPdf documentation — used here for HTML→PDF rendering (the synthetic test invoice), PDF rasterization, and searchable-PDF generation.
Questions or corrections about this codebase specifically (not the article) are welcome via GitHub issues.

