PDF Forms skill for Codex
Fill any AcroForm so every viewer shows it. Codex inspects the form first, printing every field name, type and checkbox state, then maps data to exact field names instead of guessing. It regenerates appearances so values show outside Acrobat, flattens when the recipient must not edit, batch-fills from CSV without leaking values between rows, and can build a new fillable form in ReportLab.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pdf-forms Installs to ~/.agents/skills/pdf-forms/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pdf-forms The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pdf-forms Installs to .agents/skills/pdf-forms/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pdf-forms.zipunzip -o pdf-forms.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pdf-forms PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $pdf-forms, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Fills, reads, flattens and builds fillable PDF forms (AcroForm) with pypdf, qpdf and ReportLab - maps data to field names, sets checkbox and radio export values, regenerates appearances so values show in every viewer, and detects XFA forms that need a different route. Use when the user wants to populate a government or vendor PDF form from data, batch-fill forms from a CSV, read submitted form values, flatten a filled form, or create a new fillable PDF.
Use it when
- A PDF has form fields that must be populated from data
- Filled values are invisible in some viewers
- You need to read back values from submitted PDF forms
- A new fillable PDF form has to be created in code
Use something else when
- You need structured fields from scanned or flat invoices and receipts: use form-data-extraction
- The task is plain text or table extraction from a PDF: use pdf-processing
How to check the result
Re-open the output with PdfReader.get_fields() and assert each field's value, then render page images to confirm checkboxes and text are visible.
The mistake it prevents
Setting a checkbox to "Yes" or True when the form's on-state is /1 or /On leaves the box unticked with no error.
Done well means: every intended field holds the right value, the value is visible in Acrobat, Preview, Chrome and mobile viewers, checkboxes and radios use the form’s own export values, and the output is flattened when the recipient must not edit it.
Inspect the form before mapping anything
Needs pypdf (plus reportlab to build forms, qpdf to flatten on the CLI). Use the
package manager named in AGENTS.md; installs need network, so ask for approval if the
sandbox blocks them.
from pypdf import PdfReader
r = PdfReader("form.pdf")
root = r.trailer["/Root"]
acro = root.get("/AcroForm")
print("AcroForm:", acro is not None, "XFA:", bool(acro and "/XFA" in acro))
for name, f in (r.get_fields() or {}).items():
print(
f"{name!r:45} type={f.get('/FT')} value={f.get('/V')!r}",
f"states={f.get('/_States_', '')}",
)
| What you see | Meaning | Route |
|---|---|---|
Fields listed, no /XFA | Standard AcroForm | Fill with pypdf (below) |
/XFA present and fields listed | Hybrid XFA + AcroForm | Fill the AcroForm, then delete /XFA so viewers use it |
/XFA present, get_fields() empty | Dynamic XFA (LiveCycle) | Cannot be filled reliably with open tools; ask for a static version or print-to-PDF and overlay text |
| No fields at all | Flat PDF that looks like a form | Overlay text at coordinates (last section) or build a fillable version |
Field types: /Tx text, /Btn checkbox or radio, /Ch choice (list or combo), /Sig
signature. Names are fully qualified (applicant.address.city); use the exact string
get_fields() returns.
Produce a field map before writing code. Render the page and label fields with their
names when names are cryptic (Text12): r.get_pages_showing_field(field_obj) tells you
which page each field sits on, and the widget’s /Rect gives its position.
Fill with pypdf
from pypdf import PdfReader, PdfWriter
from pypdf.generic import NameObject
reader = PdfReader("w9.pdf")
writer = PdfWriter(clone_from=reader)
values = {
"topmostSubform[0].Page1[0].f1_1[0]": "Ada Lovelace",
"topmostSubform[0].Page1[0].f1_2[0]": "Analytical Engines LLC",
"topmostSubform[0].Page1[0].c1_1[0]": "/1", # checkbox: use its own "on" state
}
# page=None applies the values to every page that carries the fields
writer.update_page_form_field_values(None, values, auto_regenerate=False)
writer.set_need_appearances_writer(True) # viewers rebuild appearances if needed
# Hybrid XFA forms: drop the XFA packet so viewers show the AcroForm values
acro = writer._root_object["/AcroForm"]
if "/XFA" in acro:
del acro[NameObject("/XFA")]
with open("w9-filled.pdf", "wb") as fh:
writer.write(fh)
Rules for values:
- Text: plain strings. Respect
/MaxLen(comb fields) and the field’s date format; the form will not reformat for you. - Checkbox: the “on” value is form-specific (
/Yes,/On,/1,/Married). Read it from/_States_and use exactly that name; anything else leaves the box unchecked. Unchecked is/Off. - Radio group: set the parent field to the chosen kid’s state name, slash included
(
"/annual", not"annual"); pypdf then switches each kid’s/ASto match. - Choice: the value must be one of
/Optentries (or its export value when/Optholds pairs).
Flatten
Flatten when the recipient should not edit values, or when a viewer still shows blanks.
writer.update_page_form_field_values(None, values, auto_regenerate=False, flatten=True)
Or on the CLI, which also builds missing appearance streams first:
qpdf --generate-appearances --flatten-annotations=all filled.pdf flat.pdf
After flattening, get_fields() on the output should be empty or have no widgets, and
pdftotext flat.pdf - should contain the values.
Batch fill from CSV
import csv
from pathlib import Path
from pypdf import PdfReader, PdfWriter
FIELD_MAP = {"name": "applicant_name", "dob": "applicant_dob", "agree": "consent_box"}
ON_STATE = "/Yes" # read from /_States_ during inspection
template = PdfReader("application.pdf")
out = Path("out"); out.mkdir(exist_ok=True)
with open("applicants.csv", newline="", encoding="utf-8-sig") as fh:
for i, row in enumerate(csv.DictReader(fh), start=1):
w = PdfWriter(clone_from=template)
vals = {FIELD_MAP[k]: v for k, v in row.items() if k in FIELD_MAP}
vals["consent_box"] = ON_STATE if row.get("agree") == "yes" else "/Off"
w.update_page_form_field_values(None, vals, auto_regenerate=False)
w.set_need_appearances_writer(True)
w.write(out / f"{i:04d}-{row['name'].replace(' ', '_')}.pdf")
Clone from the reader per row; reusing one writer accumulates every row’s values. Sanitise names used in filenames. To combine the results into one PDF, flatten each first: identical field names across merged files share a single value.
Build a fillable form with ReportLab
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
c = canvas.Canvas("signup.pdf", pagesize=A4)
form = c.acroForm
c.setFont("Helvetica", 11)
c.drawString(50, 760, "Full name")
form.textfield(name="full_name", x=150, y=750, width=300, height=20,
tooltip="Full name", borderStyle="underlined", fieldFlags="required")
c.drawString(50, 720, "Plan")
form.choice(name="plan", options=["Free", "Team", "Enterprise"], value="Free",
x=150, y=712, width=150, height=20)
c.drawString(50, 680, "Subscribe to updates")
form.checkbox(name="updates", x=210, y=676, size=14, fieldFlags="") # default is required
c.drawString(50, 640, "Billing")
for i, choice in enumerate(["monthly", "annual"]):
form.radio(name="billing", value=choice, selected=(i == 0), x=150 + i * 90, y=636, size=14)
c.drawString(170 + i * 90, 640, choice.title())
c.save()
Give every field a stable, meaningful name and a tooltip (screen readers announce it).
ReportLab’s checkbox defaults to fieldFlags="required"; pass "" for optional boxes.
Flat PDFs: overlay text at coordinates
When there are no fields, draw values on a transparent overlay and merge it:
from io import BytesIO
from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas
base = PdfReader("flat-form.pdf")
page = base.pages[0]
w_pt, h_pt = float(page.mediabox.width), float(page.mediabox.height)
buf = BytesIO()
c = canvas.Canvas(buf, pagesize=(w_pt, h_pt))
c.setFont("Helvetica", 10)
c.drawString(132, h_pt - 188, "Ada Lovelace") # PDF origin is bottom-left
c.save()
buf.seek(0)
w = PdfWriter(clone_from=base)
w.pages[0].merge_page(PdfReader(buf).pages[0])
w.write("flat-filled.pdf")
Find coordinates with pdfplumber (page.extract_words() gives the label positions; note
pdfplumber’s top is measured from the top edge, so convert with h_pt - top).
Gotchas
- Values present but invisible: missing appearance streams. Use
set_need_appearances_writer(True)and, for recipients on viewers that ignore it, flatten with qpdf--generate-appearances. auto_regenerate=True(the default) only sets the NeedAppearances flag; it does not draw anything itself, so it does not replace flattening for strict viewers.- Fonts: text fields render with the form’s
/DAfont. Non-Latin values in a field whose font is Helvetica will show as blanks or boxes; flatten using a viewer or regenerate with a font that has the glyphs. - Never fill signature (
/Sig) fields with text, and never place an image of someone’s signature without their explicit consent. - Forms with personal data (tax, medical, ID) stay local: do not upload them to online converters or include their contents in logs.
Verification
qpdf --check filled.pdf
pdftotext -layout filled.pdf - | grep -n "Ada Lovelace"
pdftoppm -f 1 -l 1 -r 90 -png filled.pdf check
- Re-read with
PdfReader("filled.pdf").get_fields()and assert each mapped field’s/V. - Open the rendered PNG: checkboxes ticked, text inside the boxes, nothing truncated.
- For batches, check the count of output files equals CSV rows and spot-check first, last and one row with the longest values.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "PDF Forms"
short_description: "Fill any AcroForm so every viewer shows it"
default_prompt: "$pdf-forms Fill this W-9 PDF with the vendor details from vendors.csv, one file per row" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.