Recursive AI
ResearchCase study

Building onepdf-csharp with a team of AI agents

Abstract

onepdf-csharp is a PDF library for .NET 10, written from first principles. It depends only on the .NET base class library and has no unsafe code. It opens and repairs real-world files, decrypts them, extracts text in reading order, and turns each page into drawing calls for a renderer. By 1.3.0 it also edits, saves, merges, splits, fills forms and draws new content. It is published on NuGet as RecursiveAI.OnePdf, with substitute fonts in RecursiveAI.OnePdf.Fonts.

Kurtis asked his first question about it at 13:16 on 22 October 2026, the day oneimage-csharp shipped. v1.0.0 was tagged 17 hours 27 minutes later, at 06:43 the next morning. It was faster than PdfPig, the established managed library, on all 17 benchmark rows, with lower peak memory on each, and had under half PdfPig’s text-extraction error on real-world files. Three feature releases followed in the next 5 hours 38 minutes. By v1.3.0 the read-only library could write. This report stops at the v1.3.0 tag.

Agents on Alera did all of the planning, code, measurement, documentation and releases: 51 agent runs and 3,904 model calls, plus a project manager agent shared with other work. Claude Sonnet 5.5 wrote the code; Claude Opus 5.5 planned, managed, verified, merged and released. At list prices the tokens would have cost about $300. Kurtis sent 11 messages about the library, none of them after v1.0.0. The main findings:

  1. One question became a running build in 25 minutes. A project manager agent answered the planner’s 33 design questions for Kurtis in eight minutes, and the first commit landed 17 minutes after he said go.
  2. After 1.0, the library kept shipping without him. The project manager cut the team to two engineers when Kurtis moved to his next project, and the roadmap from the design interview carried on to v1.3.0.
  3. Another agent team was the first customer. oneimage-csharp’s agents, building a PDF renderer on top, asked for an API change at 09:30. It shipped in v1.2.0 at 09:56.

1. What was built

ReleaseWhat it addedTagged (US Central)
v1.0.0Reading: cross-reference tables and streams, object streams, incremental files, and a documented repair chain for damaged ones. Standard-handler decryption, revisions 2 to 6 (RC4, AES-128, AES-256). A document model for pages, metadata, outlines, links, annotations, attachments and form values. Every PDF font type and the Unicode chain. Text as glyphs, words, lines and blocks in reading order, including right-to-left and vertical text. A content interpreter that drives a device interface, and a recorded display list. Limits on by default, fuzzed parsers. The Fonts package.23 Oct, 06:43
v1.0.1Absolute links in the package README, so they work on nuget.org.23 Oct, 07:29
v1.1.0Appearance generation for form fields and simple markup annotations that have none.23 Oct, 08:06
v1.2.0Writing: editable documents, full and incremental save, AES encryption on write. Knockout data for renderers. Correct outlines for the MingLiU family of CJK fonts.23 Oct, 09:56
v1.3.0Page operations, merge and split. Form filling and flattening. Content creation: text, paths, images, stamps and watermarks, with TrueType and OpenType fonts embedded as subsets. A PDFsharp migration guide.23 Oct, 12:20

Text from every page, in reading order, in parallel:

using PdfDocument doc = PdfDocument.Open("in.pdf");
IReadOnlyList<PdfPageText> pages = doc.ExtractText(new PdfTextOptions { Mode = PdfTextMode.ReadingOrder });
Console.WriteLine(pages[0].Text);

At v1.0.0 the library was 40,024 lines of C#, with 13,780 lines of tests. At v1.3.0 it was 55,396 and 19,882. The library projects have no package references. The repository had 115 commits at v1.0.0 and 149 at v1.3.0. Agents wrote all of them, under Kurtis’s name as the repository’s author identity.

2. The problem

The library started as a question to the project manager agent that was running oneimage-csharp, two hours after its v1.0.0:

as we build this out, would supporting something like turning PDF pages into images make sense?

The manager said yes: PDF previews are a common server job, and .NET has no good free answer. The choices are native PDFium wrappers, Magick.NET with Ghostscript (AGPL or commercial), or commercial libraries. PdfPig is pure managed and MIT-licensed, but it parses and extracts; it does not render. Three minutes later Kurtis set the shape:

would it make sense for us to have a separate “OnePDF” library that does the PDF parsing, and references OneImage for rendering? Or maybe you do it the opposite way. We build a OnePDF library that does all the PDF things, and then OneImage.PDF has OnePDF as a dependency?

The manager chose the second way. onepdf-csharp owns everything PDF and hands a renderer finished drawing calls: paths, glyphs, images and shadings. A thin package in oneimage-csharp turns those calls into pixels. Neither core library depends on the other. He asked for a name check, then: “nah, that works. Go ahead and kick off a planning session, and get to work”.

The bar for v1 was set in the design interview, on his behalf: faster than PdfPig with lower peak memory on every benchmark row; at or below its text-extraction error, overall and in every category; open every file PdfPig opens; no crash or hang on any file in the test corpora. PDFium and MuPDF’s mutool were to be measured as information, not as a gate.

3. How the work was organized

3.1 From one question to a plan

The project manager started a planner agent at 13:25 and answered its questions as they came, in nine batches. There were 33 questions, covering scope, spec coverage, the public API, the device interface, fonts, image streams, text extraction and its measurement, security limits, corpora, packaging and the post-1.0 roadmap. The manager accepted batch 1 at 13:26 and closed the interview at 13:34. Kurtis sent one message during it, telling the manager not to hand off to a successor halfway: “don’t hard stop at 400k - keep going until you fully finish this Q&A session”.

As with oneimage-csharp, the decision record tags every entry [Owner], [PM] or [Agent default]. At the planning commit, 13:35, the manager’s tag appears 28 times and the agents’ defaults 12. Two rules were binding from the start. The first: dependencies stop at the base class library. The second is a clean-room rule: competitors are studied only through public documentation, the PDF specifications and their output, never their source code or their test files. Every merge was checked against it.

3.2 The team

The planner had two agent-builder runs create an orchestrator and an engineer in about two and a half minutes. The orchestrator started at 13:39 and the first commit landed at 13:41.

RoleModelRunsDid
Project managerClaude Opus 5.51Answered the interview for the owner, made the decisions above the build, gave the go for each tag. Also ran oneimage-csharp in the same hours
PlannerClaude Opus 5.51Ran the interview, wrote the decision record and plan, started the team
Build orchestratorClaude Opus 5.53Sliced milestones into units, briefed engineers, verified and merged, ran the gate, released. Two handoffs to a successor at about 400k tokens of context
EngineerClaude Sonnet 5.543One unit each: implement, test, fuzz, benchmark, commit on a branch
Builders, updater, browserClaude Opus 5.54Created the two agent definitions; one updater run stopped and reverted when the manager made the change centrally; set up NuGet publishing in Kurtis’s signed-in browser

3.3 Units in parallel

The repository skeleton, filters and test corpora came first, and the reader merged at 14:16. The fan-out followed: decryption and the document model, fonts, functions, the interpreter, color and images, text layout. The milestones closed in this order (orchestrator reports): reader 14:21, document model 14:42, fonts 17:45, substitute-font package 17:51, interpreter 18:38, text extraction 18:56. Each unit worked in its own git worktree. Before merging, the orchestrator checked each one: the build, the full test suite, a NativeAOT publish, scans for unsafe code and package references, and the clean-room scan.

At most four engineers ran at once, against nine for oneimage-csharp. Builds and tests on the shared workstation ran under a memory cap that the orchestrator split between parallel engineers. From the first evening, at Kurtis’s request, heavy runs and all benchmarks went to a second machine through a remote-run tool, one job at a time under a lock. PLAN.md lists 31 merged units before v1.0.0.

3.4 After 1.0

Kurtis named his next project four minutes after the v1.0.0 tag. The project manager kept onepdf-csharp going at a lower pace, at most two engineers, under the roadmap set in the interview. Appearance generation went first, because oneimage-csharp’s PDF renderer could not ship without it. Then came the writer, page operations, forms and content creation. Each release followed the same rules: CI green on all four operating systems on a check branch, the manager’s go, then the commit and tag pushed together.

The renderer team became a customer. At 09:30 its orchestrator asked, through the manager, for two additions: the text-knockout flag and a single call for paths that are filled and stroked together. The unit merged at 09:47, and v1.2.0, which carried it, was tagged at 09:56. onepdf-csharp’s engineers also found four bugs in the TrueType instruction interpreter that the two libraries keep as copies. oneimage-csharp fixed them too, two of them differently, and v1.3.0 adopted its versions so the copies match.

4. What the human did

Kurtis wrote no code, and none of his messages was about code. From his first question to the v1.0.0 tag he sent 11 messages about the library, all to the project manager. Three of them (the second machine, the agent definitions and a status question) also covered his other libraries. He sent none about onepdf-csharp between v1.0.0 and v1.3.0. He never spoke to the orchestrators or engineers.

4.1 Setting direction

Four messages in eight minutes: the question about PDF pages as images, the split between the two libraries, a name check, and the go. Everything else in the design was settled by the agents and recorded as theirs.

4.2 Asking

Two questions at 18:20 and 18:22. The first, about the benchmark PdfPig had set for text extraction, was “what does this mean?” The second was “how close are we to 1.0 right now? What’s missing?” The manager answered from the plan: five of eight milestones done, with the text-extraction quality the one real unknown. At 06:33 he asked for the feature set and roadmap of this library and of oneimage-csharp.

4.3 Providing a machine

At 18:27 he told the manager about a second machine he had set up for heavy work. A minute later he asked for the change to reach every library: “we should change the agent definitions at add that tool as well, with instructions on how and when to use it. We should also update our processes across our libs”. The manager rewrote the build agents of four libraries to use it within 15 minutes. The release benchmarks ran there.

4.4 Decisions only he could make

Making the repository public and tagging 1.0.0 were his alone, by a rule set in the interview. The orchestrator reported v1 complete at 02:30, and the manager sent him an alert. He answered at 06:29, in two lines: “Make OnePDF public” and “Sign into chrome and publish nuget”. A browser agent added the nuget.org Trusted Publishing policy in his signed-in browser within two minutes. The orchestrator made the repository public, got CI green on four operating systems, and tagged v1.0.0 fourteen minutes after his message.

5. Verification

5.1 Text extraction quality

Two test sets were built for this. A generated set of 62 files with exact truth covered columns, tables, right-to-left text, CJK, rotation, ligatures and Type 3 fonts. A real-world set of 92 committed, permissively licensed files covered 11 categories. Every scored page of the real-world set was checked against its page image. Errors are character error on content (order ignored) and reading-order error, in percent; lower is better. PdfPig’s figure is the better of its default text and its documented layout pipeline, best of 8 runs.

SetOnePDF content / orderPdfPig 0.1.16 content / order
Generated, 62 files0.3 / 1.25.6 / 10.7 (layout pipeline)
Real-world, 92 files5.6 / 15.913.5 / 36.5

No category was lost, by the orchestrator’s report. The reference text is in logical order, so an extractor that emits right-to-left text in visual order, as PdfPig does, scores worse by design. The published page says so.

5.2 Robustness

The corpus was GovDocs1 (5,767 files), a 5,000-file sample of the SafeDocs crawl and the veraPDF corpus (2,906 files), fetched by script and not committed. On it, PdfPig opened no file that OnePDF failed to open, and OnePDF opened 20 that PdfPig did not. A sweep that opens each file, extracts all text and interprets every page found 0 crashes and 0 hangs. Its slowest page took 0.40 s and its highest peak memory was 889 MiB. Thirteen fuzz targets ran 600 s each under libFuzzer with no findings. These are the agents’ published results; this report did not re-run them.

5.3 The release gate

The gate is BenchmarkDotNet for time, with peak memory measured as one cold process per row. Load is traced every 30 seconds, and other projects’ jobs are locked out of the machine. The first full run, on the first evening, missed three time rows (section 6.1). After the fix, one run at 01:31 was discarded because the load spiked to 12. The run that counts took 01:54 to 02:08 on 23 October, with the load between 1.06 and 2.83.

For 1.2.0 and 1.3.0 the orchestrator re-ran OnePDF’s own gate rows against the previous release. All rows were within noise except two at 1.3.0 (section 6.3).

5.4 Four operating systems

Before going public, the agents checked macOS ARM64 on a Mac and Linux ARM64 under emulation, with NativeAOT on both. CI on Windows x64, Linux x64, Linux ARM64 and macOS ARM64 first ran once the repository went public, and needed three fixes before it was green, all in test setup or CI configuration. The library source didn’t change between the last pre-release commit and the release commit. Every later release was tagged on a commit with CI green on all four.

Settle the measurement method before the build starts. The quality sets, the PdfPig bar and the gate rows were fixed in the interview, so every later number had a place to go.

6. What went wrong

6.1 Three gate rows missed on the first run

The first full gate run beat PdfPig on every row except three time rows:

  • 300-page text, one thread: 251.2 ms against 237.3 ms;
  • the same file encrypted with AES-256: 252.0 against 247.8 ms;
  • metadata of a 1.7 KB file: 27.6 against 22.7 µs.

A performance unit worked on the duplicate-glyph search, sorting and the small-file open path, with output kept byte-identical. On the clean run the rows were 211, 217 and 20.5. The margins stayed small: 1.13×, 1.15× and 1.10×.

6.2 Defects found before the tag

  • A race in the shared font cache. The last gate-row work found that two threads could parse the same substitute font at once. Each program is now parsed once, under a lock (agents’ account).
  • A full cache stopped caching. The macOS check found that the shared font cache stopped working once many system fonts had been looked up. It was fixed before the tag.
  • Memory with glyphs kept. With every page’s glyph list kept, nine long documents reached 1.1 to 2.0 GiB (agents’ figures). Whole-document extraction now drops them by default and re-reads a page when asked. The worst corpus file is 889 MiB.

6.3 After 1.0

  • Package page links. The README’s relative links didn’t work on nuget.org. v1.0.1, 46 minutes after v1.0.0, made them absolute.
  • A small open regression shipped in 1.3.0. Opening a 1,000-page file went from 2.53 to 2.63 ms (+4.2%), and a small file from 17.2 to 17.8 µs (+3.5%), against 1.2.0. Both stayed well ahead of PdfPig, and the manager accepted it as backlog. The cause, a table built on every open by the page operations, was fixed after the tag.
  • A lock left behind. Before 1.3.0 a benchmark job kept the shared machine’s lock for its follow-up job. Another project’s work waited 27 minutes, and one contaminated run was discarded.

6.4 Where it trails

PDFium and mutool use less peak memory than OnePDF. On the 300-page text row, PDFium peaks at 43 MiB and mutool at 45 MiB, against OnePDF’s 104 MiB on one thread. On the scan and CJK files the gap is smaller. OnePDF’s figure includes the .NET runtime. The README states the gap in bold.

7. Results

7.1 Timeline

When (US Central)What
22 Oct, 13:16Kurtis’s first question
13:24The go
13:26 – 13:34The project manager answers 33 design questions
13:39 – 13:41Orchestrator started; first commit
14:21 – 18:56Reader, document model, fonts, interpreter and text extraction close
About 21:40First full gate run: three time rows missed
23 Oct, 01:54 – 02:08The clean gate run
02:31 – 06:29Ready; waiting for the go to publish
06:43v1.0.0 tagged and published to NuGet
07:29 – 09:56v1.0.1, v1.1.0 and v1.2.0
12:20v1.3.0 tagged

Dates come from git tags, the agents’ messages and the model-call records. Leaving out the overnight wait for the owner, agents worked for about 13½ of the 17½ hours to v1.0.0.

7.2 Benchmarks

Every number below comes from the README at the v1.0.0 tag, unchanged at v1.3.0. Times are BenchmarkDotNet means. Peak is the process’s maximum resident memory, including the .NET runtime, in one cold process per row. They were measured on an AMD Ryzen 9 7950X3D (16 cores, 32 threads) with .NET 10.0.13. “Default degree” is four pages in parallel. PdfPig has no parallel mode, so each of its workers opens its own copy of the file.

TaskDegreeOnePDFPdfPig 0.1.16FasterPeak MiB, ours / PdfPig
Open, small file (2 pages)117.4 µs27.7 µs1.59×36 / 79
Open, broken file (needs repair)14.41 ms17.5 ms3.96×44 / 87
Open, AES-25611.31 ms6.77 ms5.15×48 / 103
Metadata, small file120.5 µs22.6 µs1.10×42 / 79
Metadata, 300-page article10.48 ms4.06 ms8.50×40 / 88
Full text, 300 pages1211 ms238 ms1.13×104 / 124
Full text, 300 pagesdefault68.9 ms123 ms1.79×112 / 135
Full text, 40-page scan with OCR layerdefault5.11 ms23.3 ms4.57×67 / 230
Full text, CJK, 120 pages157.5 ms229 ms3.99×66 / 120
Words with positions, one dense page14.64 ms13.8 ms2.97×67 / 101
AES-256, full text, 300 pages1217 ms249 ms1.15×112 / 135

That is 11 of the 17 rows. OnePDF is faster with lower peak memory on all 17, and allocates less on every row: 62 MB against 562 MB on the 300-page text row. The smallest margins are single-thread text (1.13×) and metadata of a small file (1.10×, about 2 µs).

PDFium (through Docnet.Core 2.6.0) and mutool 1.23.10 were measured as information. On 300-page text OnePDF took 211 ms, against PDFium’s 626 ms and mutool’s 261 ms (mutool’s time includes starting its process). Their lower peak memory is covered in section 6.4.

Publish the rows you lose against the libraries you don’t gate on. The PDFium and mutool memory figures sit next to the wins, in bold.

7.3 Cost

Every model ran through a subscription, so the recorded spend is $0. The figures below are what the same tokens would have cost at each model’s list price. They come from the platform’s run and call records, from the first question to the v1.3.0 tag.

RoleRuns, to v1.3.0Model calls, to v1.3.0At list prices, to v1.0.0To v1.3.0
Engineers (Claude Sonnet 5.5)433,320~$143~$190
Build orchestrators3512~$77~$104
Planner, agent builders, updater, browser572~$6.50~$6.50
Total513,904~$227~$300

That is about 21.5 million input tokens, 5.0 million output tokens and 632 million cache reads. The 41 engineer runs that had finished by the tag averaged 80 turns each.

The project manager is not in the total. In the same hours it also ran oneimage-csharp, started the next project and set up the second machine. Its 272 calls from the first question to the v1.3.0 tag would add about $154; to v1.0.0, 183 calls and about $92.

7.4 Against oneimage-csharp

The same counting for both libraries, to each v1.0.0 tag, without the project manager:

To v1.0.0oneimage-csharponepdf-csharp
First owner message to tag22 h 56 min17 h 27 min
Owner messages1411
Agent runs4839
Model calls4,1662,957
At list prices~$324~$227
Most engineers at once94
Library / test lines49,507 / 22,20340,024 / 13,780

oneimage-csharp’s time includes a 5 hour 25 minute host restart and a 4½-hour overnight wait for the owner. onepdf-csharp’s includes a 4-hour wait. The libraries differ in kind, so these are two runs, not a controlled comparison.

8. Recommendations

  1. Let a manager agent run the design interview, and tag every decision with who made it. Thirty-three answers took eight minutes, and the owner’s own decisions stay distinguishable.
  2. Reserve only the irreversible steps for the owner. Going public and tagging 1.0.0 waited four hours for him; nothing else did.
  3. Measure on a locked, quiet machine, and discard any run that wasn’t. One gate run was thrown away for load, another for a lock left behind.
  4. Aim the gate for a margin, not a win. The unit that fixed the three missed rows was told to aim for 10% on the text rows so noise couldn’t flip them. They ended at 1.13× and 1.15×.
  5. Slow down after 1.0 rather than stop. Two engineers and the roadmap from the interview produced three feature releases while the owner was on the next project.
  6. Let sibling libraries file API requests through the manager. The renderer’s request went from message to release in 26 minutes.

Limits of this report

Run counts, models, tokens and cost come from the platform’s run and call records, cut off at the v1.3.0 tag. Two engineers were still running at the cut, and their later calls are excluded. The project manager’s calls are shown separately and not apportioned (section 7.3). Dates, line counts and commit counts come from git. Line counts include comments and blank lines.

The owner-message count is the same method as the cross-build count: his messages are separated from agent-sent ones by elimination, then read by hand. A message at 06:42, one minute before the tag, asked whether to pause either library and was mostly about oneimage-csharp’s performance. It is counted with oneimage-csharp; counting it here would make 12.

The benchmark, quality, robustness and fuzzing figures come from the published README and the agents’ reports and were not re-run for this report. They were measured on the v1.0 code. For 1.1 to 1.3 there are only the agents’ before-and-after runs of OnePDF’s own gate rows. The writing, page and form features of 1.2 and 1.3 were not benchmarked against PDFsharp or any other library. The fuzz harness at v1.0.0 has a fourteenth target, for image streams, which the published list of 13 doesn’t name.

What isn’t shown yet:

  • Rendering to pixels. That is oneimage-csharp’s PDF package, built on this library and outside this report.
  • Signatures, PDF/A and redaction. Signing and verification were merged after v1.3.0 but not released at the cut.
  • Shaping for new text. Complex scripts such as Arabic are not shaped when drawn.
  • ARM64 timings. ARM64 is checked for correctness only.

Links