Coding exercises that run in the browser with Pyodide
Building agentic AI? I co-run a 6-week cohort where you ship a production-ready agent, not another API wrapper.
I've built coding-exercise platforms before (Python, Rust). AWS API Gateway + Lambda, Docker, etc. It works great, but that's a lot of infrastructure to teach someone a four-line function.
For our new Agentic AI cohort I wanted a free warm-up: ten short Python exercises that introduce the AI vendor SDK patterns (in this case Anthropic). The hard constraint was that visitors should be able to click "Run" without signing up, without bringing an API key, and without complex third party infrastructure. As this site is built on Cloudflare Pages, that meant an in-browser Python runtime. Enter Pyodide ...
Unlike toy Python interpreters, Pyodide runs real CPython compiled to WebAssembly (listen to my interview Elmer Bulthuis why Wasm is cool), which enables broad compatibility with the Python ecosystem, including native extension packages.
Getting it working was easy with some Claude Code prototyping; the interesting part was the last 20%. Some of the challenges I faced and how I worked around them.
Mocked tests + a stubbed SDK
Every exercise has a solution.py and a test_exercise.py. The tests look like this:
from unittest.mock import MagicMock, patch
from solution import get_completion
def test_returns_text():
mock_client = MagicMock()
mock_client.messages.create.return_value.content = [MagicMock(text="Hello, Pythonista!")]
with patch("solution.anthropic.Anthropic", return_value=mock_client):
assert get_completion("Say hello") == "Hello, Pythonista!"
patch("solution.anthropic.Anthropic") replaces the class with a mock for the duration of the with block. The original Anthropic class is never instantiated. Which means the only thing the real SDK contributes is the name anthropic.Anthropic existing somewhere on the Python path.
So I don't install it. I write a tiny stub package straight to Pyodide's in-browser filesystem:
const ANTHROPIC_INIT = `
class Anthropic:
def __init__(self, *args, **kwargs):
pass
`;
const ANTHROPIC_TYPES = `
class TextBlock: ...
class MessageParam: ...
class ToolParam: ...
class ToolUseBlock: ...
`;
await pyodide.loadPackage(["pytest", "pydantic"]);
pyodide.FS.mkdirTree("/home/pyodide/anthropic");
pyodide.FS.writeFile("/home/pyodide/anthropic/__init__.py", ANTHROPIC_INIT);
pyodide.FS.writeFile("/home/pyodide/anthropic/types.py", ANTHROPIC_TYPES);
It's a package, not a single file, because some exercises also do from anthropic.types import TextBlock, which I needed to fix ty type errors. Both modules exist only so the imports resolve. The bodies never execute under test thanks to the mocking.
# Inside Pyodide, before running pytest:
sys.path.insert(0, "/home/pyodide")
# `import anthropic` finds the stub. `patch` replaces it. Tests run.
That one decision cuts ~3 seconds and several megabytes off the boot. The real anthropic package pulls in pydantic-core, httpx, httpcore, anyio, sniffio, idna, distro, certifi, typing-extensions. Every byte irrelevant to learning the pattern, because the test never lets the SDK run anyway.
If you've read build the data layer before you touch the LLM, this is the same strategy: cut the AI piece down to its smallest shape so the rest of the engineering is more flexible.
Lazy-loading the runtime
Pyodide is 5MB+ over the network. I don't want this to load on the homepage, not even on the exercise index page. Even on an exercise page, visitors might skim and leave. So the pyodide.js script tag isn't in the HTML. The page ships a ~250-line runner.js and that script injects Pyodide on demand:
// Module-level constants, defined once at the top of runner.js:
const PYODIDE_VERSION = "0.27.7";
const PYODIDE_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
const PYODIDE_JS_SRI = "sha384-90so5tCKvl0xs9agU29IMKlAVzhfzFX7QO//YxQkRhJG58bBZrFN+2ZTRB026X5X";
async function ensurePyodide() {
if (pyodide) return pyodide;
if (bootPromise) return bootPromise;
bootPromise = (async () => {
if (typeof loadPyodide !== "function") {
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = PYODIDE_URL + "pyodide.js";
s.integrity = PYODIDE_JS_SRI;
s.crossOrigin = "anonymous";
s.onload = resolve;
s.onerror = () => reject(new Error("Failed to load pyodide.js"));
document.head.appendChild(s);
});
}
pyodide = await loadPyodide({ indexURL: PYODIDE_URL });
await pyodide.loadPackage(["pytest", "pydantic"]);
// write the anthropic stub here…
return pyodide;
})();
return bootPromise;
}
Two triggers prewarm the runtime before the user clicks Run:
cm.on("focus", prewarm);
runBtn.addEventListener("mouseenter", prewarm, { once: true });
The moment they tab into the editor or hover the button, the 3-second cold start starts ticking. By the time they're done typing, the runtime is usually ready. The cached bootPromise deduplicates: focus and hover both await the same in-flight promise, never two parallel boots.
Tracking progress without a backend
No users, no database, no sessions, but I still want:
- ✓ badges on completed exercises in the list view
- A progress bar across all ten
- Draft code that survives a tab close
- A next step that only appears once all ten are green
One localStorage key holds the whole state:
const STORAGE_KEY = "pyai_progress_v1";
// { "first-api-call": { passed: true, code: "...", lastRun: 1736... } }
Three operations carry the state: saveCode(slug, code) runs on every CodeMirror change, markPassed(slug) runs when pytest returns 0, and get(slug) reads on page load to restore drafts and badges.
In a similar vein, the Solution tab stays locked until the tests pass. The point of an exercise is the struggle, not the answer.
Once markPassed(slug) writes to localStorage, it also fires a pyai:passed event, and a separate tabs.js listener flips the solution from <div data-solution-locked> to <div data-solution-revealed> and lazy-fetches solution.py for a side-by-side compare. No reload. One key, three consumers (runner, list page, solution tab).
And the key is versioned: pyai_progress_v1. The day I want to change the shape, I can bump it to _v2 and old state cleanly stops loading. No migration code, no schema check.
The list page reads the same store on render and walks the DOM:
document.querySelectorAll(".exercises-list-item").forEach((item) => {
const slug = item.dataset.exerciseSlug;
const { passed } = window.PyAIProgress.get(slug);
if (passed) item.classList.add("is-passed");
});
When passedCount() >= total, a hidden next-step block flips visible. That's the whole mechanism: ten green checks reveal one element, all computed in the browser from that one localStorage key.
All static, all local
The whole thing is a static site. Cloudflare serves the HTML, JS, and the synced exercise files. The browser does the rest. Zero extra cost. It scales for free because the load is on the client, not a server.
For development, uv runs the end-to-end check with a single command:
uv run scripts/e2e_test.py
It walks every exercise in headless Chromium, pastes the reference solution, clicks Run, asserts the test suite passes. Ten exercises in ~22 seconds. Anytime the upstream content changes I know in under half a minute whether all ten warm-ups still pass end-to-end. I will save the details of this Playwright end-to-end testing for another article.
Starter code
The site this runs on is standalone so I put together a single-file Pyodide starter gist of a mini coding platform experience: code in the browser, click "Run tests", pytest runs against your code, all in the browser. Lazy boot and the Solution/Tests tabs are wired up. The SDK stub and localStorage progress I left out for simplicity, but the core Pyodide integration is there. You can download and build on it if you want to try your hand at a browser-based Python coding experience.
Try it out
Back to the 10 exercises, you can try them out here. They cover the basics that show up in the typical production Agentic AI app: a first API call, structured outputs with Pydantic, system prompts, multi-turn state, tool use, then the architectural patterns (Protocol, Repository, Service layer, HITL, the agent loop).
Keep reading
- How an AI expense agent is actually structured
- Build the data layer before you touch the LLM
- Modern Python tooling: uv, ruff, ty
One bigger lesson I'm taking away from this: every time I've built a thing server-side over the years, I was usually paying a complexity tax for flexibility I didn't need. Sometimes the right architecture is to push the work to the client, especially where modern browsers and Wasm can handle this performantly and securely.
Most AI tutorials end at "call the API." This cohort ends with a deployed agent: function calling, structured outputs, three interfaces, Docker, 95%+ test coverage. Six weeks of real engineering, not notebooks. Join the next Agentic AI cohort →