StencilPDF Form Filler
What it does for you
Fill a stack of PDF forms from one spreadsheet.
Map spreadsheet columns to a fillable PDF's fields and get one validated, well-named filled PDF per row, plus a ZIP and a batch report, in the browser or from a Cowork skill.

Who it's for
Small offices that fill out the same PDF forms over and over, like intake, enrollment, or onboarding packets. When a row can't be filled, it tells you why in plain words.
Screenshots

In more detail
Stencil takes a fillable PDF and a spreadsheet and makes one correctly filled, sensibly named PDF for every row. If a row can't be filled, it tells you in plain words why, instead of handing you a half-filled form. Think intake forms, enrollment forms, onboarding packets: the stuff small offices fill out over and over.
Honest limits
It assumes US conventions (month-first dates, US states). Bad rows are skipped, long text is cut with a warning. It can't draw names outside Latin-1 without an embedded font. The sample data is fictional, and nothing in the build records a run on a real office's forms. Google Fonts were blocked in the sandbox, so screenshots show the fallback font.
How I built it
Another one from the "one-shot all the Cowork projects" run. I set the spec (fuzzy mapping, type coercion, readable validation, filename templates, a tested ZIP writer, presets, flatten, samples, and a Cowork skill), and Claude Cowork built the core, the PDF adapter, a command-line tool, the UI, and 230 assertions in one pass, about an hour.
The key design choice: the mapping logic never touches a PDF. stencil.js turns a plain field model plus CSV rows into a batch plan (values, errors, filenames). A thin pdf-adapter.js, about 180 lines, translates between pdf-lib and that model. So 200-plus tests run with no PDFs at all, and one end-to-end test still proves the real thing: generate a fillable PDF, fill 3 rows, reopen every output and every copy inside the ZIP, and read each field back.
pdf-lib is vendored as the one dependency. Claude Cowork also checked its output with outside tools: poppler rendered the PDFs to images, qpdf checked structure, and Python's zipfile validates the ZIP inside verify.mjs. The sample form is a fictional business so nothing implies a real agreement.
What I learned
- pdf-lib's
flatten()deletes the widget objects but leaves their references in each page's/Annots. qpdf said the file was fine, poppler flagged every widget. A small prune pass fixed it, and a test now checks nothing is left. One reader accepting a file doesn't mean it's valid. - Plain Levenshtein counts the typo "emial" as two edits and missed the auto-map threshold. Optimal string alignment counts a transposition as one edit and fixed it without loosening anything else.
- A synonym table did more work than fuzzy math. "mobile" and "Phone," or "postal_code" and "ZIP," share almost no letters.
- The standard PDF font only covers WinAnsi, so smart quotes and dashes get mapped to plain ones and anything it can't draw gets dropped before filling.
- The vendored pdf-lib had literal dash characters that tripped our own no-em-dash rule. They were rewritten as equivalent escapes and the change was documented.
What carried forward
Same house pattern: DOM-free core, verify.mjs, zero-dependency serve.mjs. Stencil and #53 both needed a hand-rolled STORE zip writer with CRC-32, and both got checked by an independent reader rather than trusting their own output. That was a shared habit in this batch, not shared code. No later project imports Stencil.
If I took it further
I would add a field-location preview that highlights where each box sits on page 1, because real forms have cryptic field names like Text12. And an optional Unicode font for forms that need it.
#054 Built with Claude Cowork September 23, 2026 230 automated checks