Blog

All posts
John Damask · 2026-06-01
devlogsecurityarchitecture

When somebody uploads a PDF to Now I Get It!, the interesting work is what Claude does with it -- read the paper, decide on a structure, write the HTML. But the bytes Claude returns are not the bytes that get published. There's a sophisticated post-processing pipeline that runs on every generation and it's one of the most important parts of the system. None of the pieces are large. All of them earned their place by fixing a real problem.

This post is about what's in that pipeline and why.

Step 1: extract the HTML

The model is asked to return structured data in ```html block, optionally preceded by a <metadata> block with title, authors, and date. The first thing post-processing does is parse those apart: pull the metadata into a dict, find the fenced block, fall back to an unclosed fence, and finally fall back to a raw <!doctype> start. If none of those match, the job fails. This sounds boring until you realize that "the model occasionally forgets to close a code fence" is a real failure mode at production scale.

Step 2: security scan, fail closed

This is the one that matters most. _check_suspicious_patterns() runs a set of diagnostics over the generated HTML looking for things that have no business being on a page Claude built from a scientific PDF:

For a long time this function was logging-only -- it would print [SECURITY WARNING] to CloudWatch and let the page through. The reasoning at the time was reasonable: the upstream input screen (an LLM classifier) and the system prompt hardening were the real defenses, and the diagnostics were a backstop. But the backstop didn't actually stop anything.

The function now returns a list of violation descriptions, and the three call sites that handle the model's three possible output shapes each raise ValueError when the list is non-empty. The Lambda already catches exceptions and writes the job to error state, so nothing upstream needed to change. Fail closed: if the diagnostic catches anything, the page never leaves the function.

This same diagnostic set is now reused by the in-page editor's save path. When a logged-in owner edits their published page and POSTs the new body, the Lambda runs _check_suspicious_patterns on the submitted bytes before writing to S3. One battle-tested check, two callers, no new governance surface.

Step 3: make the math render

A few weeks after the security work, the presentation-modes feature broke LaTeX rendering. Claude was generating math content with inconsistent approaches -- sometimes MathJax, sometimes KaTeX, sometimes raw LaTeX with no library at all -- and the page's Content-Security-Policy header was blocking whichever CDN Claude happened to choose that day.

The fix lives in post-processing: _ensure_katex() detects LaTeX-like patterns (\frac, \begin{, \(, \[, and friends) and, if it sees them but the page is missing the KaTeX assets, injects the KaTeX CSS, JS, and auto-render init script. CSP was updated to allow cdn.jsdelivr.net. The model can choose any approach it likes; the post-processor standardizes the output.

A sibling step, _wrap_math_sources(), wraps math regions with sentinel spans so the later edit-id pass treats them as atomic. That detail matters because of step 5.

Step 4: adding constants

Two small injections:

The architectural lesson here is that the post-processing step is the cheapest place to add cross-cutting behavior. Anything I'd otherwise have to teach Claude to emit -- favicon link, feedback widget, editor bootstrap, KaTeX wiring -- can instead be injected after the fact. Claude's job stays focused on the paper; the chrome is the pipeline's problem.

Step 5: instrument for editing

The last two steps wire up the in-page editor. _inject_edit_ids() assigns a data-nowigetit-edit-id attribute to every editable block so the editor can target specific paragraphs without having to invent stable selectors. Then _strip_protected_footer_edit_ids() immediately removes those ids from the footer and copyright region so users can't accidentally edit or delete them.

That order matters: tag everything, then untag the parts that aren't actually yours.

The shape of the pipeline

Stepping back, the whole thing reads like a short list:

  1. Extract the HTML from whatever envelope Claude returned.
  2. Refuse to publish anything that contains exfiltration or redirect patterns.
  3. Make sure the math libraries are wired up if there's math.
  4. Add favicon, feedback widget, editor bootstrap.
  5. Tag every editable block; untag the parts that should stay frozen.

Each step is a few dozen lines. What they have in common is that they all exist because something either broke in production or was about to. The KaTeX injector exists because a CSP change broke math rendering. The blocking version of the suspicious-pattern check exists because the logging-only version wasn't protective. The footer-edit-id stripper exists because the very first editor demo let me delete the copyright.

The architectural learning is to always place a post-processer between an LLM's output and users. Not a sanitizer that fights the model, not a parser that re-interprets it - just a short, ordered list of small functions that each fix one thing. This is deterministic and extensible.