Write your first reproduction
// GUIDE · WRITE A RECIPE
Author one Layer 1 recipe that runs in a browser tab.
Copy the nearest existing recipe, swap the slug and the reproduction script, and you have a Vivarium-compatible reproduction page running locally. From there, the choice is upstream PR or self-publish on a fork.
// 0 · WHAT THIS GUIDE COVERS
A minimal Layer 1 (in-browser WASM) recipe.
Layer 1 pulls the upstream code into the browser via Pyodide, Ruby.wasm, php-wasm, or the Rust wasm32-wasip1 target, runs the reproduction script the moment the page loads, and returns a verdict. No install, no server — so a Layer 1 recipe is the lowest-cost first contribution to Vivarium.
Layer 2 (Docker) has its own niche in architecture, but if you are writing your first recipe, start at Layer 1.
This guide ends at "I have a working recipe locally." Where to publish it is a separate two-way fork: Run on your own fork or a PR to the upstream repo.
// 1 · PICK A BUG
Slug shape: <project>-<issue>.
If the upstream bug has an issue tracker entry, the slug is <project>-<issue> (e.g. cpython-137205). Otherwise, a descriptive kebab-case string (e.g. bash-local-shadows-exit) works. The slug is the directory name and also the Manifest v1 slug field.
A Layer 1-friendly bug usually has these properties:
- No external I/O (no network, no filesystem, no subprocess).
- The verdict collapses to one boolean (e.g. "are these two values equal?").
- The required libraries ship inside the WASM build of the runtime (Pyodide / Ruby.wasm / php-wasm bundled package list).
// 2 · CLONE AND COPY A TEMPLATE
Copying the closest existing recipe is the shortest path.
For an upstream PR, clone aletheia-works/vivarium directly. To self-publish, follow the fork guide and clone your fork.
mise install at the repo root pulls bun and the rest. For Layer 1 specifically, also run bun install from src/layer1_wasm/.
Pick one in the same language: Python → src/layer1_wasm/cpython-137205/, Ruby → ruby-21709/, PHP → <php-slug>/, Rust → <rust-slug>/. Copy the whole directory: cp -r src/layer1_wasm/cpython-137205 src/layer1_wasm/<your-slug>.
// 3 · EDIT INDEX.HTML
Three swaps: title, heading, lede.
Keep the structure of the copied index.html; replace these three to match the bug:
<title>— tab title (e.g.Vivarium · Reproducing <project>#<issue>).<h1>— heading. Link to the upstream issue.<p class="lede">— one-to-two-sentence summary.
Do not touch: <meta name="vivarium-contract" content="v1">, the #verdict element, or the link to ../_shared/style.css. Those carry Contract v1, and removing any of them breaks machine-readable verdict capture.
The authoritative spec lives at Contract v1. The allowed values for
data-verdictand the schema for theVIVARIUM_RESULTenvelope come from there.
// 4 · EDIT REPRO.TS
Swap the reproduction logic; keep the helpers.
The copied repro.ts imports the verdict helpers from ../_shared/verdict.js and, for a Python recipe, startPyodideWorker from ../_shared/pyodide-worker-client.js (Ruby, PHP and Rust use ruby_loader.js, php_loader.js and rust_loader.js instead). Leave that frame alone and swap only the reproduction body.
Two rules make the page readable, and both matter:
- Write ordinary code and
printwhat happened. The output pane shows the script's real stdout, so a visitor can see why the answer is what it is. Do not end the script in a bare expression for the page to pick up — nothing reads it. - Leave the machine-readable values in a global named
result(orresultsfor a list of rows). That is what the verdict and the Contract v1 envelope are built from.
const REPRO_CODE = `
import pandas as pd
# ↑ minimum code that exercises the upstream bug
left = ... # value 1
right = ... # value 2
mismatch = left != right
result = {
"left": left,
"right": right,
"mismatch": mismatch,
}
print("left ".ljust(10) + str(left))
print("right".ljust(10) + str(right))
print()
if mismatch:
print("The two disagree.")
else:
print("The two agree.")
`.trim();
The driver deletes result before every run, so a visitor who edits the script in the browser and removes the assignment gets unreproduced rather than the previous run's answer.
The helpers handle the verdict wiring: if the comparison is true (bug reproduced), call setVerdict("reproduced", "..."); otherwise setVerdict("unreproduced", "..."). setResult({ contract: "v1", bug, runtime, result, timing }) writes the VIVARIUM_RESULT envelope.
Pyodide runs in a Web Worker shared by every Python recipe, so booting it never freezes the page — and there is no worker file to write.
startPyodideWorkerspawns it and returnsrun/evaluate/install.
// 5 · RUN IT LOCALLY
bun run build → static server → open in browser.
# from src/layer1_wasm/
bun install
bun run build # repro.ts → repro.js
# serve the recipe directory directly
python -m http.server -d src/layer1_wasm/<your-slug> 8765
# open http://localhost:8765/ in a browser
The page loads, the upstream library streams in from the CDN, and the verdict badge moves from pending to either reproduced or unreproduced. In DevTools, check that VIVARIUM_VERDICT and VIVARIUM_RESULT agree with the badge — that's the Contract v1 surface.
Pyodide / Ruby.wasm / php-wasm are all configured to avoid
SharedArrayBuffer, so COOP/COEP headers are not required. A plain static server (python -m http.server,bunx serve) is enough.
// 6 · NATIVE RE-VERIFICATION (OPTIONAL)
Show the same bug reproduces outside the browser.
A reproduction observed via Pyodide / Ruby.wasm is more convincing when it also reproduces on a native CPython / MRI Ruby. For Python, uv + PEP 723 inline metadata gives you a single-file companion:
# /// script
# requires-python = ">=3.14"
# dependencies = ["pandas==2.3.3"]
# ///
"""… what the bug is, and how to run this file …"""
# ← the body here is the same text as REPRO_CODE, character for character
if mismatch:
print("verdict=reproduced — …", file=sys.stderr)
sys.exit(0)
else:
print("verdict=unreproduced — …", file=sys.stderr)
sys.exit(1)
Keep the body identical to REPRO_CODE. Only the PEP 723 header, the docstring, and the verdict= line plus exit code are extra — that is what makes the native run a check on the browser one rather than a second script that merely looks similar. Run the repo's formatter over the native file and mirror whatever it does back into the string literal, or the next autofix pass will drift them apart.
mise exec uv -- uv run repro.py spins up an ephemeral venv and runs it. The other languages share the idea, not the mechanism:
- Ruby — a plain
repro.rbthe interpreter runs; there is no PEP 723 equivalent. Run it withmise run repro:native:rubyrather thanmise exec ruby, because the version pin lives on the task. Seeruby-21709. - Rust — no companion file at all. The crate that compiles to
repro.wasmis the native CLI, somise run repro:native:rustruns the samesrc/repro.rsthe page runs. See<rust-slug>.
mise run repro:native runs all of them. No php-wasm recipe has been authored yet, so there is no repro.php to copy — _shared/php_loader.ts is in place for whoever writes the first one.
// 7 · REGENERATE recipes.json
Get your recipe into the gallery.
recipes.json, which the gallery (/repro/) and the MCP server consume, is generated from src/layer*/ at build time. To regenerate locally:
mise run recipes:index
# or
cd docs && bun run generate
Check that docs/site/public/api/recipes.json contains your slug. With the docs site running locally, the new recipe shows up as a card on the gallery:
cd docs && bun run dev
# http://localhost:3000/vivarium/repro/// 8 · WHERE TO PUBLISH
Upstream PR, fork, or consumer integration.
Open a PR on aletheia-works/vivarium with a Conventional Commit such as feat(layer1): add <slug> reproduction. The CI gate is essentially Contract v1 conformance — anything that turns green is in.
Push the recipe to your own vivarium fork's main, enable deploy via the fork guide, and the recipe appears at <you>.github.io/vivarium/. Sharing is just sharing the URL.
If you only want to watch a recipe (no new authoring), you don't need this guide — see Integrate with your own repo, which uses Manifest v1 plus the reusable consumer-workflow.
If a step in this guide fails, that's a bug in this guide — file an issue with the step number. Where readers stall sets the priority for the next docs revision.