Build artifact-forge — an independent HTML artifact system

High todo
2026-06-23 agentics feature High effort

Ship a standalone artifact-forge plugin that forges self-contained, accessible HTML artifacts on demand from any source — conversation context, markdown, code files, or session logs — and prove it end-to-end by migrating one real skill off its hand-rolled HTML.

Implement Read and implement all steps in the plan at docs/plans/build-html-artifact-system.md — Build artifact-forge — an independent HTML artifact system. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/build-html-artifact-system.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
More ways to run this plan — goal & workflow prompts, file path
Pursue as goal — optimize for the outcome, in parallel
Achieve this goal: Build artifact-forge — an independent HTML artifact system. The plan at docs/plans/build-html-artifact-system.md describes one approach — use it as reference, but optimize for the outcome. Fan out across parallel subagents where that serves the outcome. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/build-html-artifact-system.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
Run as workflow — launch parallel subagents
Run a workflow to implement the plan at docs/plans/build-html-artifact-system.md — Build artifact-forge — an independent HTML artifact system. Brief subagents with the plan file at docs/plans/build-html-artifact-system.md. Reserve a final verification phase for the lead agent, not a subagent. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/build-html-artifact-system.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
File build-html-artifact-system.html
Path docs/plans/build-html-artifact-system.html
Spec docs/plans/build-html-artifact-system.md
Definition of done 0 / 11 done

Context

The story behind this plan — what prompted the work and why it matters now.

HTML generation is scattered across the marketplace with heavy duplication. Two mature patterns already exist — plan-agent 's inlined SKELETON.html (the model fills placeholders) and plan-interview:markdown-to-html 's spec-driven html-spec.md + build-assets.sh extraction — alongside social-media-tools , where 9+ card templates each re-inline the same ~1.5KB of CSS tokens and 8+ share-* skills each re-implement the identical “find free port → http.server → Playwright screenshot → kill” pipeline with no shared code.

The goal is one independent system that any skill can call on demand to turn a source — conversation context, markdown content, a code file, or a session log — into a single self-contained .html artifact (optionally screenshotted to PNG). Built and tested first, it later replaces the hand-rolled HTML across all skills. This plan builds and tests that system as a new standalone plugin and proves it with one live pilot migration; the remaining migrations are Next Steps.

Decided up front (Clarify): a new standalone plugin ( artifact-forge ); a greenfield engine (a deterministic Python core, not a model-filled skeleton — so it is genuinely unit-testable); scope is build + test + one pilot migration; and the engine owns a thin shared render-to-PNG wrapper. Greenfield reuses the rules already proven in html-spec.md (per-sink escaping, URL and theme allow-lists, a11y landmarks, reduced-motion and print styles) — it does not re-derive them.

Files that change

Every file this plan touches, and what happens to each one.

agentics/
  • .claude-plugin/marketplace.json modified register artifact-forge @ v0.1.0
  • kit/plugins/artifact-forge/.claude-plugin/plugin.json new name only, no version field
  • kit/plugins/artifact-forge/
    • README.md new overview, usage, delegation contract
    • CHANGELOG.md new 0.1.0 initial release
    • requirements.txt new pytest + playwright
    • pytest.ini new testpaths = tests
  • kit/plugins/artifact-forge/scripts/
    • forge.py new CLI entry: source → shape → html (+ png)
    • ir.py new IR schema + validate()
    • render.py new IR → self-contained HTML
    • screenshot.py new shared serve → shoot → kill wrapper
    • find_free_port.py new OS-assigned free port
  • kit/plugins/artifact-forge/scripts/adapters/
    • context.py new conversation/text → IR
    • markdown.py new markdown → IR sections
    • code.py new code file → IR with language
    • session.py new session JSONL → recap IR
  • kit/plugins/artifact-forge/scripts/shapes/
    • document.py new doc/explainer layout
    • card.py new fixed-width social card
    • plan.py new mirrors SKELETON.html
    • gallery.py new filterable index grid
  • kit/plugins/artifact-forge/scripts/assets/
    • tokens.css new single source of design tokens
    • base.css new layout + a11y + print
    • enhance.js new savePDF, scroll-spy, copy buttons
  • kit/plugins/artifact-forge/skills/forge-artifact/SKILL.md new model-invocable front door
  • kit/plugins/artifact-forge/tests/
    • conftest.py new shared tmp_path fixture
    • test_objective_smoke.py new hero: all 4 sources + pilot
    • test_adapters.py new unit: source → IR
    • test_render.py new unit: structure + escaping
    • test_self_contained.py new unit: no external refs
    • test_a11y.py new unit: landmarks + contrast per shape
    • test_forge_cli.py new integration: CLI per source/shape
    • test_screenshot.py new e2e: render → png → teardown
  • kit/plugins/artifact-forge/tests/fixtures/
    • sample.md new markdown adapter input
    • sample_code.py new code adapter input
    • sample-session.jsonl new session adapter input
    • sample_context.txt new context adapter input
  • kit/plugins/social-media-tools/skills/share-code/SKILL.md modified pilot: migrate to forge.py

Steps

The step-by-step work, in order — each step says what to do, why it matters, and how to check it worked.

1
todo Scaffold the artifact-forge plugin and register it
Why
Establishes the standalone-plugin boundary the whole system lives in, so every later file has a home and the marketplace can serve it.
Verify
Create kit/plugins/artifact-forge/.claude-plugin/plugin.json with the full standard manifest ( name , description , author , license , keywords , repository , and homepage: https://github.com/shawn-sandy/agentics/tree/main/kit/plugins/artifact-forge ) but no version field; add README.md , CHANGELOG.md , and the pytest infrastructure ( requirements.txt pinning pytest + playwright , pytest.ini with testpaths = tests , tests/conftest.py for the shared tmp_path ); register a relative git-subdir entry in .claude-plugin/marketplace.json at version: 0.1.0 , category: development , with specific searchable tags . Verify: python3 -c "import json; json.load(open('.claude-plugin/marketplace.json'))" parses, the new entry (with tags) appears, claude --plugin-dir kit/plugins/artifact-forge loads with no manifest error, and pytest --collect-only kit/plugins/artifact-forge/tests/ succeeds on a clean checkout.
2
todo Define the IR schema and the single design-token core
Why
One IR and one token file are the whole point — a single tokens.css kills the ~11× CSS duplication, and a versioned, fully-specified IR is the contract every adapter, shape, and downstream skill migration depends on; getting it wrong breaks every future caller at once (four reviewers flagged this as the linchpin).
Verify
Write scripts/ir.py defining the IR as a documented TypedDict — {schema_version, title, meta, theme, shape, sections[]} — with a per-section-type schema ( type ∈ prose|code|steps|criteria|cards plus type-specific keys: steps[].{action,why,verify} , criteria[].text , and cards[].{title,href,tags,date,status} for the gallery variant) and a validate(ir) guard. Write scripts/assets/tokens.css (one canonical token set + the four named themes ported from the existing palettes, with the status-badge gray darkened to meet 4.5:1 contrast). Verify: validate() rejects an IR missing title / sections , rejects an unknown schema_version or section type , rejects a theme outside the allow-list, and accepts a minimal valid one; grep confirms every token referenced by base.css is declared once in tokens.css .
3
todo Write the four input adapters
Why
These are exactly the “context, content, code files, sessions” inputs the objective names — each must normalize its source into one common IR so the renderer never sees source-specific shapes.
Verify
Add scripts/adapters/{context,markdown,code,session}.py , each a pure to_ir(raw, opts) -> ir function: context → prose sections, markdown → parsed sections, code → a code section with language, session JSONL → a recap (title + highlights); empty or malformed input yields a minimal valid IR rather than raising. Create the fixtures they consume: tests/fixtures/{sample.md,sample_code.py,sample-session.jsonl,sample_context.txt} . Verify: pytest tests/test_adapters.py — each adapter turns its fixture into an IR that passes validate() with the expected section types, and an empty input still validates.
4
todo Build the renderer and the self-contained guarantee
Why
Deterministic IR → HTML assembly is what makes the system testable and the output truly standalone — and since the engine becomes the single HTML sink for every skill, a missed escaping context has cross-skill blast radius, so escaping and allow-lists must live here where no caller can bypass them.
Verify
Write scripts/render.py render(ir) -> html : inline tokens.css + base.css + shape CSS + enhance.js ; escape user content at every sink per the html-spec.md rules — element text, attribute values, and CSS custom-property values — and enforce the URL and theme allow-lists in the renderer (not only in adapters), so a hand-supplied --source ir JSON cannot bypass sanitization. Emit <main id="main-content"> as the skip-link target per the spec. Add assets/base.css (skip-link, landmarks, focus, reduced-motion, print) and assets/enhance.js (savePDF, scroll-spy, copy buttons). Verify: pytest tests/test_render.py tests/test_self_contained.py — output has no <link> , no http(s):// asset ref, no @import ; and a title carrying <script> , an attribute payload " onclick=" , and a javascript: URL are each neutralized in the text, attribute, and href sinks.
5
todo Implement the four v1 shapes: document , card , plan , gallery
Why
document covers context/markdown/code/session artifacts and card covers the social-card pilot; plan reproduces the plan-agent SKELETON.html structure and gallery the index grid, so implementation-plan and the galleries can later adopt the engine without a second shape-building pass. Reproducing two already-shipped designs is the proof the engine can absorb the existing systems.
Verify
Add scripts/shapes/{document,card,plan,gallery}.py , each consuming the same IR: document (header + sticky nav + sections), card (a fixed-width card — define a canonical width, e.g. 800px, in tokens.css so PNG output is deterministic — wrapped in an <article> landmark with a single heading), plan (steps + acceptance criteria + completion checklist + meta tags, mirroring SKELETON.html ), gallery (filterable index grid mirroring plans-library/media-library; filter triggers are <button> s inside <div role="group" aria-label="Filter"> , the result region carries aria-live="polite" with a visible empty-state, filtering is keyboard-operable). Every shape emits the spec landmarks (skip-link, <main id="main-content"> , heading hierarchy). Verify: pytest tests/test_render.py renders all four shapes from IR fixtures and asserts shape-specific structure via concrete selectors — nav for document , a .card wrapper + fixed width for card , a step list + completion checklist for plan , and role="group" filter buttons + an aria-live result region for gallery .
6
todo Wire the forge.py CLI front door
Why
A single entry point is how every skill invokes the system on demand — adapter dispatch, render, write, optional screenshot, all behind one command.
Verify
Write scripts/forge.py accepting --source context|markdown|code|session|ir , --in <path|-> , --shape document|card|plan|gallery , --theme <name> , --out <path> , and optional --png <path> ; the ir source reads a ready-made IR JSON for skill delegation. Define an explicit error contract: a missing --in file, malformed input, empty stdin, or invalid IR exits non-zero with a clear stderr message; an unknown --theme degrades to default . Verify: pytest tests/test_forge_cli.py — each --source writes a valid .html for every --shape ; --in - reads stdin; a missing file and a malformed IR each exit non-zero with a message; an unknown --theme falls back to default .
7
todo Add the shared render-to-PNG wrapper
Why
This single copy replaces the find-port → serve → screenshot → kill flow duplicated across 8+ share-* skills. It must always tear the server down — a leaked http.server accumulates across invocations — and degrade gracefully when Playwright (a heavy, optional dependency) is absent rather than crashing the caller.
Verify
Write scripts/screenshot.py to_png(html_path, png_path) + scripts/find_free_port.py using Playwright-as-library (the Python package, documented as a prerequisite with playwright install chromium in the README): free port → background python3 -m http.server → navigate + full-page screenshot → a finally block that kills the whole process group ( os.killpg ) with a timeout then SIGKILL fallback. A soft preflight emits an advisory and skips the PNG (non-fatal) when Playwright is unavailable. Verify: forge.py … --png /tmp/out.png on a fixture writes a non-empty PNG and leaves no server alive ( pgrep -f http.server empty afterward) even when the screenshot step raises ; with Playwright absent, forge.py still emits HTML and exits zero.
8
todo Author the forge-artifact skill
Why
The skill is the on-demand, model-invocable front door — and its delegation contract is how other skills reach the engine instead of hand-writing HTML, so that contract must be decided and documented now, not deferred.
Verify
Write skills/forge-artifact/SKILL.md (three-part description; allowed-tools: Bash, Read, Write, AskUserQuestion, Glob, SendUserFile, ToolSearch ): gather the source, build or emit the IR, call forge.py , deliver the HTML (and PNG when asked). Make --source ir --in - (IR JSON on stdin) the primary, documented delegation contract in the README, with the Skill tool as the interactive fallback, and specify how a consuming plugin resolves forge.py across plugins (a documented ARTIFACT_FORGE_ROOT / $CLAUDE_PLUGIN_ROOT lookup) rather than hard-coding an absolute path. Verify: claude --plugin-dir kit/plugins/artifact-forge lists forge-artifact ; running it on a sample produces an artifact via forge.py (not hand-written HTML), and piping an IR JSON to forge.py --source ir --in - round-trips to valid HTML.
9
todo Migrate one pilot skill onto the engine
Why
A live migration is the only real proof the system can replace hand-rolled HTML — share-code exercises both the card shape and the PNG wrapper, the two riskiest new pieces, while retiring the most duplication.
Verify
Convert social-media-tools:share-code to build a card IR and call forge.py --shape card --png … (resolving forge.py via the documented cross-plugin lookup, not a hard-coded path), deleting its inlined <style> and private screenshot steps; leave all other skills untouched. Capture a reference of the current diff-card.html output first and gate the deletion behind a temporary toggle until the new path passes, so rollback is one revert. Verify: a structural/dimensional parity check (presence of .card , the copy panel, and matching card width) passes against the captured reference, test_self_contained.py passes on the migrated HTML, the PNG is non-empty, and grep shows no <style> or http.server remaining in the migrated path.

Tests

The tests that prove the change does what it promises.

Tier 1 — Code-touching plan
Objective Forge produces a self-contained HTML artifact from every source File: kit/plugins/artifact-forge/tests/test_objective_smoke.py Type: smoke test Asserts: for each of the four sources ( context , markdown , code , session ), forge.py writes a single .html with zero external refs (no <link> , no http(s):// asset, no CDN) whose title matches the fixture's expected title exactly , whose section count matches, and which parses as well-formed HTML; and the migrated pilot emits a valid card artifact through the engine. Directly proves &ldquo;an independent system generates self-contained HTML artifacts on demand from context, content, code, and sessions.&rdquo; Run: pytest kit/plugins/artifact-forge/tests/test_objective_smoke.py
Unit Adapters normalize each source to a valid IR File: kit/plugins/artifact-forge/tests/test_adapters.py Targets: adapters/{context,markdown,code,session}.py to_ir() Key cases: each fixture source produces a valid IR with the expected section types; empty input yields a minimal valid IR; escaping-sensitive content (a literal <script> ) survives into IR text unmangled, ready for the renderer to escape.
Unit Renderer emits correct structure and escapes content File: kit/plugins/artifact-forge/tests/test_render.py Targets: render.py render() + shapes/{document,card,plan,gallery}.py Key cases: document includes a nav landmark, card a .card wrapper at the canonical width, plan a step list + completion checklist, gallery role="group" filter buttons + an aria-live region; user content in the text ( <script> ), attribute ( " onclick=" ), and CSS-value sinks is each escaped; a javascript: URL is dropped; an unknown theme falls back to default .
Unit Output is fully self-contained (no external refs) File: kit/plugins/artifact-forge/tests/test_self_contained.py Targets: the rendered HTML invariant (a pure check over the output string) Key cases: rendered output contains no <link rel> , no src= or href= pointing at http(s):// , no @import url( , and no host from the pinned CDN-host constant — the guarantee that lets artifacts open offline from file:// .
Unit Every shape meets the accessibility baseline File: kit/plugins/artifact-forge/tests/test_a11y.py Targets: rendered output of all four shapes (axe-core / pa11y over the static HTML) Key cases: each shape has a skip-link, a <main id="main-content"> landmark, and correct heading order; gallery filter buttons are keyboard-reachable with an aria-live result count; token/badge contrast meets 4.5:1 — zero WCAG AA violations per shape.
Integration forge.py CLI runs end-to-end per source and shape File: kit/plugins/artifact-forge/tests/test_forge_cli.py Targets: forge.py argument dispatch → adapter → renderer → file write Key cases: each --source writes a valid .html to disk; --in - reads an IR from stdin (the delegation path); --shape selects the right layout; a bad --theme degrades to default without error.
E2E Render-to-PNG produces an image and tears down the server File: kit/plugins/artifact-forge/tests/test_screenshot.py Targets: screenshot.py to_png() via forge.py --png (skipped when Playwright is unavailable) Key cases: a fixture render produces a non-empty PNG; the temporary http.server is killed afterward (no lingering process, port released) even when the screenshot step raises.

Definition of done

The plan counts as done when every statement below is true — check each one off as you verify it.

Final check

One last pass to confirm the whole change works end to end.

Run pytest kit/plugins/artifact-forge/tests/ — every test passes and the objective-smoke test confirms all four sources plus the pilot. Then spot-check by hand: forge.py --source markdown --in README.md --shape document --out /tmp/d.html opens correctly from file:// with the network disabled (proving self-containment); forge.py --source code --in kit/plugins/artifact-forge/scripts/forge.py --shape card --png /tmp/c.png writes a non-empty PNG. Load the plugin and run forge-artifact on a recent session to confirm an artifact is produced through forge.py . Finally, diff the migrated share-code card against the previous diff-card.html output for visual parity, and run git diff --stat to confirm no skill other than the pilot changed.

Wrapping up

Three gates that must all pass before this plan is marked completed.

Required

Completion Report

No items to report — all requirements met.

Next steps

Follow-up ideas that came up along the way — none of them are required to finish this plan.

Migrate the remaining HTML-emitting skills onto forge.py

Paste this prompt into Claude to execute this follow-up:

Run a workflow to migrate every remaining HTML-emitting skill in kit/plugins/ onto the artifact-forge engine (forge.py). In scope: the social-media-tools share-* card skills (share-blog, share-react, share-github, share-selection, share-session, share-video, share-explanation) and the media-library/plans-library gallery builders. For each skill: build the appropriate IR, call forge.py with the right --shape (and --png for cards), delete the skill's inlined <style> and its private screenshot pipeline, and verify the output passes the self-contained test and matches the prior visual output. Brief each subagent with kit/plugins/artifact-forge/README.md and the forge-artifact delegation contract. Leave the implementation-plan plan shape for its own follow-up. Report a table of skill → shape → lines removed.
Adopt the plan and gallery shapes in implementation-plan and the galleries

Paste this prompt into Claude to execute this follow-up:

Run a workflow to migrate plan-agent:implementation-plan and the plans-library/media-library gallery builders onto the artifact-forge engine, using the plan and gallery shapes already shipped in v1. For implementation-plan: render through forge.py (--shape plan) instead of filling SKELETON.html, confirming feature parity (steps, acceptance criteria, completion checklist, implement/goal/workflow prompts, meta tags) against current output. For the galleries: render through forge.py (--shape gallery), confirming the filter chips and card grid match. Keep SKELETON.html and the current index.html as visual references. Report what changed and any visual diffs.
Retire the duplicated screenshot pipeline copies

Paste this prompt into Claude to execute this follow-up:

Once all share-* skills render through artifact-forge, remove the now-redundant screenshot infrastructure: the standalone kit/plugins/social-media-tools/scripts/find_free_port.py and the references/rendering-pipeline.md, replacing their usages with kit/plugins/artifact-forge/scripts/screenshot.py. Grep the whole repo for "http.server" and "find_free_port" to confirm no skill still hand-rolls the pipeline. Bump social-media-tools in marketplace.json (minor) and add CHANGELOG entries. Report every file touched.
Live-preview watch mode for forge.py Wish List

Speculative / blue-sky idea — not on the critical path. Paste into Claude when ready to explore:

Paste this prompt into Claude to execute this follow-up:

Add a "forge.py serve --watch <source>" mode that re-renders the artifact and reloads the browser whenever the source file changes, for fast iterative authoring. Use only the standard library (http.server + a simple mtime poll + an injected dev-only reload snippet that is stripped from the final artifact). Keep the production output path byte-identical to the non-watch path.
theme-from-image adapter Wish List

Speculative / blue-sky idea — not on the critical path. Paste into Claude when ready to explore:

Paste this prompt into Claude to execute this follow-up:

Add a fifth theme-generation path to artifact-forge that derives a tokens.css palette from an input image or brand color (reusing the design-token-extractor approach already in the marketplace), so artifacts can match a project's brand without hand-editing tokens. Validate contrast ratios stay WCAG AA before emitting the theme.