The LLM Drafts; The Plugin Acts
TL;DR: Most AI writing tools save you ten minutes of writing and cost you forty minutes of manual plumbing. The way out is not a better chat model. It is to make the model draft into a clean JSON contract inside the CMS, then let deterministic plugin code translate that draft into WordPress-specific state changes on commit — post creation, metadata writes, taxonomy assignment, attachment tagging, citation generation, and footnote normalization.
Before the importer existed, the workflow was fully manual.
A new Issue Brief lands as a PDF in the inbox. The editor opens it, creates a new post in WordPress, and starts moving information over by hand. She selects “Issue Brief” from the publication-type dropdown. She squints at the PDF header to find the publication number — IB#25-21 — and types it into the ACF field. She types the canonical citation in the format the editorial style guide requires, name by name. She uploads the PDF to the media library, then opens the media settings to tag it as download-pdf so the theme’s download widget will find it on the public post. She pastes in the body, fixes formatting drift, and manually translates footnotes into endnotes.
One could imagine an initial foray into using AI to simplify that task: the editor opens the PDF in one tab, ChatGPT in another, and the WordPress admin in a third. She copies the abstract into the chat window, asks for a clean summary or draft, copies the result back into Gutenberg, and still does the publication-type selection, publication number entry, citation formatting, PDF upload, media tagging, and footnote/endnote cleanup by hand.
That workflow is actually a straw man stronger than the reality. But it is useful because it reveals the product problem with most AI writing tools. The AI might save ten minutes of writing. It still leaves forty minutes of plumbing.
That ratio is the actual product problem with most AI writing tools today, and the way out is not a better chat model. The way out is to move the AI inside the CMS, hand it a structured contract instead of a text box, and let the CMS itself do the plumbing on commit. This piece walks through a shipped example — a WordPress importer plugin built for the Center for Retirement Research at Boston College (CRR) — and stakes a claim from it. The claim: deterministic, idempotent, auditable commits to a CMS require code that lives in the CMS. Not a chat tool sitting outside it. Not an agent re-deriving the right action each time. Code that runs the same way for their post every other Tuesday, on every input, hooked into the host system’s lifecycle.
The contract is the product
The first thing the importer does, before it ever talks to a language model, is build the request. That request is not “here is a PDF, give me a draft.” It is closer to a job description for the LLM, with the host system’s structural constraints baked in.
The plugin sends along:
supported_post_types— every post type registered on this site. The LLM cannot returnnews_releaseif the site does not have anews_releasepost type; the contract rejects the response on receipt.existing_post_type_hints— site-wide frequency of post types over recent posts, so the LLM grounds its choice in what the site actually publishes rather than what merely sounds plausible.potential_authors— a list of actual WP user records (id, user_login, nicename, display_name, recent post types they have authored). The LLM picks an author the system can resolve to a real user record, not a free-form name string.site_context— site name and URL, so the model knows what publication it is writing for.
What comes back is not text. It is a structured draft blueprint that looks roughly like this:
{
"draft": {
"title": "Is Low Fertility in High-Income Countries Here to Stay?",
"post_type": "post",
"slug": "low-fertility-high-income-countries",
"authors": [{ "user_login": "amunnell" }],
"endnotes": [
{ "content": "U.S. Census Bureau (2024).", "id": "fn-1" }
],
"metadata_assignments": {
"publication_type": "Issue Brief",
"publication_date": "2025-10",
"topics": "demographics, retirement"
},
"content": "<!-- wp:paragraph --><p>...</p><!-- /wp:paragraph -->"
}
}That shape — not the prose inside content — is what makes the importer work. By insisting the LLM return WP-mappable identifiers, structured content, and metadata fields rather than free-form text, the plugin turns AI output into something the host system can validate and act on without a human translator in the middle. The contract is the product. The draft text is one of its outputs.
That is the boundary line: the LLM drafts into JSON; the plugin applies the JSON to WordPress. The model is responsible for producing structured content and identifiers the system can validate. The plugin is responsible for the side effects — creating posts, assigning taxonomies, writing meta fields, tagging attachments, and reshaping content into the exact forms WordPress expects.
The contract is not static, either. Extensions can shape it on both sides — they enrich the request (e.g. the modern-footnotes extension injects the endnotes schema into expected_draft_shape.draft so the LLM knows to emit endnotes as a structured array) and they act on the response when it lands. The same extension model that does the deterministic plumbing also defines what the LLM is asked to produce in the first place.
What the LLM is actually responsible for
For each publication type, the plugin holds a strict-behavior document the LLM is given as part of its instructions. The one for Issue Briefs at CRR reads more like a working editor’s brief than an AI prompt. Here is what it actually tells the model to do.
Translation-first body. Preserve the source PDF’s wording verbatim in the body. Do not paraphrase. Do not summarize. Do not “improve” the prose. This is the opposite of how most chat-AI workflows work, and it is the right rule for a research publication where the language is the work product. The LLM’s job is to faithfully transcribe the paper into Gutenberg block markup, not to rewrite it.
Front-matter exclusions. Do not put the issue-and-date line, the repeated title, or the boilerplate author-disclaimer footnote into draft.content. Those go into metadata_assignments as structured fields, where the theme can render them in the slots designed for them.
Author byline → draft.authors. When a byline exists, return it as one or more entries in the authors array, using user_login values drawn from the potential_authors list the plugin handed in. If no byline exists, return the field empty. Never fabricate a user_login.
Endnotes as structured data. Every numbered citation in the source PDF gets extracted into draft.endnotes as { content, id } objects. In the body, normalize superscript reference markers to plain bracket form: [1], [2], [3]. The plugin will handle the rest.
This is the soft work — the part only a language model does well at speed. Reading a multi-page research PDF, separating front matter from body, mapping byline names to user-account identifiers, identifying endnote markers in OCR text that may or may not be clean. No deterministic parser is going to do any of this well across the format variation that shows up in real briefs over a decade. The model is doing what only the model can do.
What the plugin does the moment that draft lands
When the structured draft comes back from the model, the core importer fires three lifecycle hooks: gnowbot_importer_precreate_blueprint, gnowbot_importer_apply_metadata_assignments, and gnowbot_importer_postprocess_created_post. Publisher-specific extensions register on those hooks. The CRR extension is one PHP file of narrow, well-scoped rules. For an Issue Brief, here is what it does — every time, identically, with no model in the loop:
Sets publication_type = "brief" as both a post meta value and a term on the publication_type taxonomy. The theme uses one; the archive page filter uses the other. Both writes happen on every import.
Extracts the publication number. CRR uses an IB#YY-N convention — IB#25-21 means Issue Brief number 21 of the 2025 series. The extension walks a candidate list in order: the metadata_assignments block the LLM returned (under three possible key spellings), the post title, and the source PDF filename. Two regexes do the matching — one for the IB#YY-N form, one for the bare YY-N form — and the first hit wins. This is exactly the kind of small-but-relentless cleanup no editor wants to do by hand for the four-hundredth time, and no LLM should be doing because the rule is exact. A model that gets this 99% right is a model that introduces a wrong publication number into the archive once a quarter.
Writes the value to two meta keys: publication-number and publication_number. The current theme reads one; an older ACF field that several legacy archive pages still rely on reads the other. The dual-write is invisible to the editor, but it is exactly the institutional knowledge embedded systems can encode. The same pattern shows up elsewhere in the extension — pdf-version and pdf_version are both written for the same reason, and publication_type lands as both a post meta value and a term on the publication_type taxonomy because the theme reads one and the archive page filter reads the other. No chat-window AI would know to do this. No off-the-shelf importer would either.
Builds the canonical citation deterministically and writes it to the citation post meta. From draft.authors, draft.title, the extracted publication number, and a fixed organizational suffix, the extension assembles:
Munnell, Alicia H., Jean-Pierre Aubry, and Anqi Chen. 2026. “Is Low Fertility in High-Income Countries Here to Stay?” Issue in Brief 25-21. Chestnut Hill, MA: Center for Retirement Research at Boston College.
The LLM never writes that string. The plugin writes it from structured fields. Same inputs, same output, every time. More generally: the LLM never performs the WordPress update itself; it returns structured data the plugin can use to perform the update deterministically. (The year is currently the runtime year, not the publication year — a known quirk worth fixing if the citation needs to reflect when the brief was published rather than when the post was imported.)
Maps topics into the topic taxonomy, normalizing the comma-separated string or array form the LLM might return.
Writes the pdf-version post meta linking the source PDF attachment to the post, so the theme’s download widget can find it.
Tags the PDF attachment with download-pdf on whichever taxonomy the site uses (the extension tries media-tags and then media_tag), so it shows up in the site-wide download list as well as on its own post.
None of this is AI work. All of it is the kind of work that breaks editorial trust the moment it gets done inconsistently. The reason it can be automated reliably is that the LLM gave the plugin enough structured handles to act on.
Footnotes: where soft and hard meet cleanly
The footnotes extension is a small piece of code that makes the soft/hard split unusually visible. The LLM’s job is to return endnotes as JSON in draft.endnotes and leave plain [1], [2] markers inline in the body. That is the entire AI surface area for footnotes.
The plugin’s job, on commit, is to convert each [N] marker in the body into a <sup data-fn="uuid"> with a generated UUID, write a footnotes post meta carrying the matching JSON the WordPress core footnotes feature expects, and append an Endnotes heading and a native Gutenberg <!-- wp:footnotes /--> block as the final section of the post content. After import, the editor opens the post in Gutenberg and the footnotes are there — fully editable, with the same affordances any other native footnote block would have. No bespoke shortcode to learn, no proprietary footnote plugin to depend on, no custom rendering layer to maintain.
The extension is generous about input, too. If the LLM forgets to populate draft.endnotes and instead leaves an inline <ol> under an Endnotes heading in the body, the pre-create hook lifts that list out and turns it into the structured endnotes array. The contract is the preferred path; the recovery path is there so a missed schema doesn’t fail the import.
A chat-window AI cannot do any of that conversion. The most it can do is paste a string into the editor and let the human re-do the work block by block.
When the contract breaks
The piece would not earn the developer audience without being honest about failure. Real importers fail in interesting ways, and the architecture only works because the failure modes are designed.
The LLM returns a post_type not in supported_post_types. The contract is explicit: post_type must be one of supported_post_types, otherwise the fallback is post. The importer does not silently coerce to a wrong custom post type or invent a new one. Silent coercion is how confidently wrong commits enter a publishing pipeline.
potential_authors does not resolve. The LLM returns user_login: "munnell" and there is no munnell user record on the site. The extension does not create a new WordPress user — it never creates user records, only looks them up. When a user_login doesn’t match, the extension falls back to the byline name (as a plain string) for citation construction and leaves the structured author field unresolved. The hard rule: do not fabricate identifiers that downstream systems might trust. An AI-fabricated user record is the kind of thing that ends up cited as a real person somewhere two years later.
The publication number regex misses every candidate. Maybe the PDF filename is unusually named, the title omits the number, and the metadata block didn’t include it. The extension logs the candidates it tried, leaves publication-number empty, and lets the post create. An empty field is recoverable; a wrong number is not. Worth noting honestly: the empty field has to get noticed by the editor downstream — there is no automatic flag yet, which is a fair next step.
Source PDF format drift. Sometimes the publisher redesigns the brief and the byline moves to a different page or the Key Findings section gets a new label. The strict-behavior rules degrade gracefully because they are scoped to the publication type — the editor sees a draft with weaker structure and a clear flag, not a confidently mis-mapped post. The extension is a thin layer of rules; updating it when the publisher’s format changes is a one-file edit.
Gutenberg block format change. This is the real long-tail risk and worth naming explicitly. The wp:footnotes block schema is owned by WordPress core, not the importer. Future changes to that schema require a contract update on the extension side. The cost of this architecture is that it has a hard dependency on the host CMS’s evolving block model. The benefit is that when the host changes, you change one extension, not your entire publishing tool.
The pattern across all of these: structured failure beats unstructured failure. A flagged post the editor reviews is recoverable. A confidently wrong publish is not. Designing the failure modes is part of designing the contract.
The contract is what survives the model
There is a one-line version of this whole argument: the model writes the brief, the plugin publishes it. The publishing half is where the editor’s day actually goes, and it is the half that earns trust over months and years of operation.
Three threads to pull together.
The architecture is portable. The CRR extension is one short PHP file of narrow, well-scoped rules. Other publishers, other CMSes, other publication types each get their own extension hooked into the same lifecycle. The pattern generalizes whenever you can articulate per-publication-type rules clearly enough to write them down. For content with looser editorial conventions — a culture blog with no consistent post-type vocabulary, a personal site with no per-category metadata — the deterministic side gets thinner. That is honest scope, not a flaw. The pattern is sharpest in editorial operations that already have house style.
The contract is durable. The model layer is the most volatile part of any AI tool. Pricing changes. New entrants appear. Capabilities shift in unpredictable directions. Architecture that depends on a specific model loses. Architecture that depends on a structured contract the model fills wins — and this benefit becomes clearest about six months in. The cheapest, fastest, smartest model is going to change every six months for the foreseeable future. The contract outlasts the model. Today the importer is pointed at one foundation model; a year from now it will be pointed at something cheaper or local, and the per-publisher extension code will not change at all. The contract is the part that has to be designed well. The model is the part that gets swapped.
The trust equation. Embedded AI tooling that does not have hands on the host system is a chat tool with a fancier paste button. Embedded AI tooling that does have hands — through a structured contract and a clean extension model — is editorial infrastructure.

