The primitive between everything and WordPress blocks.
Block Runner is the layer between generated content and WordPress. AI tools, agents, and
design tools spit out HTML, but the block editor only trusts blocks it recognizes, so it
freezes everything else into a single "Custom HTML" blob, or breaks the block outright with
"Attempt Block Recovery." Block Runner converts that output into real, nested, native
Gutenberg blocks (wp:cover > wp:columns > wp:buttons) and proves every result is
editor-valid. Built to sit in an agent loop, a content pipeline, or a CI gate, and
deliberately a primitive rather than a platform: the blocks it emits are plain, native
WordPress, editable in any editor with nothing proprietary to keep installed.
| Generated HTML reaches the editor as… | |
|---|---|
| Today ❌ | one frozen Custom HTML blob, or a broken block and "Attempt Block Recovery" |
| With Block Runner ✅ | wp:cover > wp:columns > wp:buttons: real, nested, editable, valid |
npm install block-runner # requires Node 20+Then just ask your coding agent (Claude Code, Codex):
Use block-runner to convert this hero into a native Gutenberg block.
Or run the CLI yourself:
# native blocks stream to stdout by default; pipe them anywhere
block-runner convert hero.html
# pipe in from an agent, a generator, or curl
generate-page | block-runner convert -
# or write straight to a file
block-runner convert hero.html --out hero.blocks.htmlEvery run is checked against headless Gutenberg, so what comes back is guaranteed editor-valid, or Block Runner tells you exactly what wasn't and points at the line.
Every conversion is scored from 0 to 100 against a fixed suite of design sections with a
known ideal block tree, by how faithfully it reproduces the intended wp:* structure and
content. The comparison is raw LLMs writing the block markup themselves versus Block Runner
pairing the same model (GPT-5.5, Opus) with its validity gate, across simple and complex layouts.
Two jobs: convert generated HTML into native blocks, and validate that what you ship is editor-valid. Use either half on its own: convert in your agent pipeline, or run the gate as a standalone validator in CI.
- Native blocks, never locked in. Real
wp:cover > wp:columns > wp:buttons, properly nested, with real media ids: plain core blocks anyone can edit in any WordPress, not a builder's proprietary block types you have to keep its plugin installed to touch. - Broad element coverage. Tables, quotes, code, separators, video/audio,
<details>, YouTube/Vimeo embeds, and image galleries all map to their native core blocks — not just the hero primitives. What genuinely has no native home (inline SVG icons, definition lists, arbitrary iframes) is preserved as Custom HTML with a warning pointed at the line, never dropped and never crashing the run. - Any model, any agent. Feed it whatever your LLM, agent, or design tool emits, from any vendor, and drop it into your own pipeline instead of adopting someone else's editor.
- Media resolution. Resolve images to real attachment ids via a map, WP-CLI, or the REST API.
- Styling fidelity, your call. Keep off-theme styles or map them to your theme, up to a ceiling you set.
- Extensible. Built-in rules out of the box; add your own, or hand the hardest layouts to an LLM (experimental).
- A seatbelt for generated blocks. Models and builders will cheerfully emit markup that corrupts the editor; every result is held to a gate wired to headless Gutenberg first, so valid means what the editor means, not what a generator hoped.
- Reproducible gate. Same markup, same verdict, every time. Safe to run on every request and in CI.
- Canonicalize. Rewrite near-miss markup into the exact shape the editor expects.
- Never fails silently. When something can't be expressed natively, it says so and points at the exact line.
Content pours out of AI and agents faster than anyone can hand-build it, but "a block the editor actually accepts" is a brutally exact bar. To land one valid block, every one of these has to be right:
- Markup is validated against what the block's
save()would output. Attribute order, class names, whitespace, a stray self-closing slash: one mismatch and the editor throws "This block contains unexpected or invalid content" and offers Attempt Block Recovery. - Attributes live in a typed HTML-comment schema (
<!-- wp:cover {"dimRatio":50,...} -->), order-sensitive, with defaults that must or must not appear depending on the block. - Nesting is enforced.
wp:columnsaccepts onlywp:column,wp:buttonsonlywp:button,wp:coverwraps a specific inner container. Put the wrong child inside and the block is invalid. - Each block expects its exact generated classes (
wp-block-cover,wp-element-button,has-background-dim,wp-image-1234). Miss one and it breaks or renders wrong. - Images need a real attachment ID, not just a URL, so you also have to resolve and import media into the library and thread the id through the markup.
- Colors, spacing, and fonts have to map to your theme presets (
var:preset|spacing|40,has-accent-color), not raw hex and pixels, or the result is off-brand or rejected outright. - Blocks carry deprecations. Markup that validated against last year's
save()may not validate against this year's. - Anything it can't place collapses into one frozen "Custom HTML" blob, and the structure, nesting, and editability are gone.
Get any of it wrong and you ship invalid blocks, broken layouts, or one giant uneditable blob. Block Runner gets all of it right: it turns whatever your agents and tools generate into real, nested, editable blocks with resolved media, then proves every result against headless Gutenberg before it reaches the editor.
Any content in. Real blocks out.
Three commands: convert (HTML to blocks), validate (check block markup), and fix
(canonicalize block markup).
block-runner convert hero.html # blocks to stdout
block-runner validate "content/**/*.html" --json
block-runner fix post-content.html --out post-content.fixed.htmlRead from stdin with -:
cat hero.html | block-runner convert -All commands:
| Flag | Description |
|---|---|
--config <path> |
Use a specific config file (otherwise auto-loaded from the working directory). |
--json |
Emit a machine-readable JSON report instead of text or markup. |
--strict |
Exit 1 on strict warnings (unresolved media, fallback blocks). |
--explain |
Include rule attribution and near-misses in the report. |
convert and fix also take --out <path> to write the result to a file instead of stdout.
convert adds media-resolution flags:
| Flag | Description |
|---|---|
--resolver <kind> |
Media resolver: noop, map, wpcli, rest. |
--wp-url <url> |
WordPress URL for wpcli or rest resolution. |
--wp-user <user> |
WordPress username for rest resolution. |
--wp-app-password-env <name> |
Env var holding a WordPress application password. |
0: clean1: problems found2: usage or I/O error3: headless Gutenberg boot failure
It's a Node CLI, so it drops into whatever you already use: your shell, a pre-commit hook, GitHub Actions, or any other CI (GitLab, CircleCI, and friends all run Node). And it's model-agnostic: it works on the output of any model, from any vendor.
pre-commit (add to .pre-commit-config.yaml):
- repo: https://github.com/humanmade/block-runner
rev: v0.1.0
hooks:
- id: block-runner
args: ['content/**/*.html'] # glob of files that contain block markupGitHub Actions (or any CI) validate blocks on every push:
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npx block-runner validate "content/**/*.html" --strictimport { canonicalize, convert, validate } from 'block-runner';
const validation = await validate(markup);
const fixed = await canonicalize(markup);
const converted = await convert(html, { resolver: 'noop' });A <img src="hero.jpg"> in generated HTML is just a URL, but WordPress image and cover blocks
want a real media-library attachment with an ID (wp-image-1234). Media resolution is how
Block Runner connects the two: matching or importing each image into the library and threading
the right id into the block. Choose how it does that:
noop: leave URLs as-is and warn when an ID is missing (good for a dry run).map: look up IDs and URLs from a JSON map you provide.wpcli: find or import media withwp media listandwp media import.rest: find or import via the WordPress REST API, with credentials supplied explicitly.
Remote sideloading is off by default. Under --strict, unresolved media (and fallback blocks)
cause exit code 1.
Block Runner auto-loads block-runner.config.{mjs,js,json} from the working
directory, so most runs need no flags; the config sets the media resolver, tokens,
and rules. Pass --config <path> only to point at a config elsewhere.
block-runner.config.mjs:
export default {
strict: false,
media: {
resolver: 'map',
mapFile: './media-map.json',
},
tokens: {
colors: {
dark: 'contrast',
light: 'base',
accent: 'accent',
},
fonts: {
heading: 'display',
body: 'body',
},
spacing: ['20', '30', '40', '50', '60'],
},
};Design HTML often carries custom CSS (and sometimes JavaScript) that doesn't match
the target theme. The styling level controls how much of it Block Runner keeps. The
levels run from safest (cleanest, most editable blocks) to most faithful (keeps the
original look, but less editable):
| Level | What it does |
|---|---|
strict |
Map to the theme only. Off-theme styles are dropped. Cleanest, fully on-brand, fully editable. |
relaxed |
Keep exact off-theme values on the block (custom color, size, spacing). Still native and fully editable. |
open |
Also keep CSS no block can express, by wrapping the element and shipping that CSS alongside. Look preserved, structure still editable. |
source |
Keep the original markup as a Custom HTML block. Exact, but not editable. Last resort. |
You set one ceiling. Per block, Block Runner uses the strictest level that still
captures the design, and never goes past your ceiling. It's configured in
block-runner.config.mjs:
export default { styling: 'relaxed' }; // default (a per-run --styling flag is planned)Custom JavaScript is never inlined. A behavior maps to a native interactive block, comes from a block plugin, or is dropped, and every drop or escalation is reported.
Status:
relaxedandopenare in progress. Today the converter behaves likestrict(off-theme styling is dropped) with asource(Custom HTML) fallback.
A conversion benchmark lives under benchmarks/: it measures how faithfully real generator
output (Impeccable, Codex, Claude, and more) converts to native blocks, across swappable
converters (the built-in rules, plus experimental LLM translators run via their CLIs).
npm run bench # score the suite; write benchmarks/presentation/review.html + benchmarks/presentation/scoreboard.html
npm run bench:record # also append a provenance-tagged run to benchmarks/results.jsonlRuns are recorded with engine / model / effort / suiteHash, so older engines stay
backtestable against the current suite (scripts/backtest.sh). See benchmarks/README.md
for adding producers and engines.
GPL-2.0-or-later.

