diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80aa248..9d50cf5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- docs: add JupyterLite browser playground by @abetlen in #173
+
## [0.0.44]
- ci: add Pyodide wheel builds by @abetlen in #171
diff --git a/docs/hooks/jupyterlite.py b/docs/hooks/jupyterlite.py
new file mode 100644
index 0000000..53d74a5
--- /dev/null
+++ b/docs/hooks/jupyterlite.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import json
+import shutil
+import subprocess
+import tempfile
+import urllib.request
+from html.parser import HTMLParser
+from pathlib import Path
+
+GGML_PYTHON_WHEEL_INDEX = "https://abetlen.github.io/ggml-python/whl/cpu/ggml-python/"
+# The current Pyodide wheel is tagged for the 2026 ABI, while JupyterLite 0.7
+# defaults to a 2025 ABI Pyodide runtime.
+PYODIDE_INDEX_URL = "https://cdn.jsdelivr.net/pyodide/v314.0.0/full/"
+PYODIDE_CORE_URL = (
+ "https://github.com/pyodide/pyodide/releases/download/314.0.0/"
+ "pyodide-core-314.0.0.tar.bz2"
+)
+PYODIDE_WHEEL_SUFFIX = "-py3-none-pyemscripten_2026_0_wasm32.whl"
+
+
+class _LinkParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.hrefs: list[str] = []
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag != "a":
+ return
+
+ for name, value in attrs:
+ if name == "href" and value is not None:
+ self.hrefs.append(value)
+
+
+def _find_latest_pyodide_wheel_url() -> str:
+ with urllib.request.urlopen(GGML_PYTHON_WHEEL_INDEX, timeout=30) as response:
+ html = response.read().decode("utf-8")
+
+ parser = _LinkParser()
+ parser.feed(html)
+
+ for href in parser.hrefs:
+ if href.endswith(PYODIDE_WHEEL_SUFFIX):
+ return href
+
+ msg = f"could not find Pyodide wheel in {GGML_PYTHON_WHEEL_INDEX}"
+ raise RuntimeError(msg)
+
+
+def _download_pyodide_wheel(wheels_dir: Path) -> Path:
+ wheel_url = _find_latest_pyodide_wheel_url()
+ wheel_path = wheels_dir / wheel_url.rsplit("/", 1)[-1]
+ with urllib.request.urlopen(wheel_url, timeout=30) as response:
+ wheel_path.write_bytes(response.read())
+ return wheel_path
+
+
+def _configure_pyodide_runtime(output_dir: Path) -> None:
+ config_path = output_dir / "jupyter-lite.json"
+ config_data = json.loads(config_path.read_text())
+ lite_settings = config_data["jupyter-config-data"].setdefault(
+ "litePluginSettings", {}
+ )
+ kernel_settings = lite_settings.setdefault(
+ "@jupyterlite/pyodide-kernel-extension:kernel", {}
+ )
+ kernel_settings["pyodideUrl"] = "./static/pyodide/pyodide.mjs"
+ load_options = kernel_settings.setdefault("loadPyodideOptions", {})
+ load_options["indexURL"] = PYODIDE_INDEX_URL
+ config_path.write_text(json.dumps(config_data, indent=2) + "\n")
+
+
+def _patch_pyodide_kernel_extension(output_dir: Path) -> None:
+ extension_dir = (
+ output_dir
+ / "extensions"
+ / "@jupyterlite"
+ / "pyodide-kernel-extension"
+ / "static"
+ )
+ dynamic_import_stub = (
+ '476:e=>{function t(e){return Promise.resolve().then((()=>{var t=new '
+ "Error(\"Cannot find module '\"+e+\"'\");throw "
+ 't.code="MODULE_NOT_FOUND",t}))}t.keys=()=>[],t.resolve=t,t.id=476,'
+ "e.exports=t}"
+ )
+
+ for path in extension_dir.glob("*.js"):
+ text = path.read_text(errors="replace")
+ patched = text.replace("{type:void 0}", '{type:"module"}')
+ patched = patched.replace(
+ dynamic_import_stub,
+ "476:e=>{e.exports=e=>import(e)}",
+ )
+ patched = patched.replace(
+ '["sqlite3","ipykernel","comm","pyodide_kernel","jedi","ipython"]',
+ '["ipykernel","comm","pyodide_kernel","jedi","ipython"]',
+ )
+
+ if patched != text:
+ path.write_text(patched)
+
+
+def on_post_build(config, **kwargs) -> None:
+ docs_dir = Path(config["docs_dir"])
+ site_dir = Path(config["site_dir"])
+ contents_dir = docs_dir / "jupyterlite" / "contents"
+ output_dir = site_dir / "playground" / "lite-2026"
+
+ if not contents_dir.exists():
+ return
+
+ if output_dir.exists():
+ shutil.rmtree(output_dir)
+
+ with tempfile.TemporaryDirectory(prefix="ggml-python-jupyterlite-") as temp_dir:
+ wheels_dir = Path(temp_dir) / "wheels"
+ wheels_dir.mkdir()
+ pyodide_wheel = _download_pyodide_wheel(wheels_dir)
+
+ subprocess.run(
+ [
+ "jupyter",
+ "lite",
+ "build",
+ "--apps",
+ "lab",
+ "--contents",
+ str(contents_dir),
+ "--output-dir",
+ str(output_dir),
+ "--piplite-wheels",
+ str(pyodide_wheel),
+ "--pyodide",
+ PYODIDE_CORE_URL,
+ "--no-libarchive",
+ "--no-sourcemaps",
+ "--no-unused-shared-packages",
+ ],
+ cwd=temp_dir,
+ check=True,
+ )
+
+ _configure_pyodide_runtime(output_dir)
+ _patch_pyodide_kernel_extension(output_dir)
diff --git a/docs/jupyterlite/contents/ggml-python-playground.ipynb b/docs/jupyterlite/contents/ggml-python-playground.ipynb
new file mode 100644
index 0000000..fa95b51
--- /dev/null
+++ b/docs/jupyterlite/contents/ggml-python-playground.ipynb
@@ -0,0 +1,93 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# ggml-python in Pyodide\n",
+ "\n",
+ "This notebook installs the Pyodide wheel and runs a small ggml graph in the browser."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import micropip\n",
+ "import piplite\n",
+ "\n",
+ "await micropip.install([\"numpy\", \"typing_extensions\"])\n",
+ "await piplite.install(\n",
+ " \"ggml-python\",\n",
+ " deps=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import ggml\n",
+ "\n",
+ "params = ggml.ggml_init_params(mem_size=16 * 1024 * 1024, mem_buffer=None)\n",
+ "ctx = ggml.ggml_init(params)\n",
+ "assert ctx is not None\n",
+ "\n",
+ "x = ggml.ggml_new_tensor_1d(ctx, ggml.GGML_TYPE_F32, 1)\n",
+ "a = ggml.ggml_new_tensor_1d(ctx, ggml.GGML_TYPE_F32, 1)\n",
+ "b = ggml.ggml_new_tensor_1d(ctx, ggml.GGML_TYPE_F32, 1)\n",
+ "\n",
+ "x2 = ggml.ggml_mul(ctx, x, x)\n",
+ "f = ggml.ggml_add(ctx, ggml.ggml_mul(ctx, a, x2), b)\n",
+ "\n",
+ "gf = ggml.ggml_new_graph(ctx)\n",
+ "ggml.ggml_build_forward_expand(gf, f)\n",
+ "\n",
+ "ggml.ggml_set_f32(x, 2.0)\n",
+ "ggml.ggml_set_f32(a, 3.0)\n",
+ "ggml.ggml_set_f32(b, 4.0)\n",
+ "\n",
+ "ggml.ggml_graph_compute_with_ctx(ctx, gf, 1)\n",
+ "output = ggml.ggml_get_f32_1d(f, 0)\n",
+ "ggml.ggml_free(ctx)\n",
+ "\n",
+ "output"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "assert output == 16.0\n",
+ "ggml.ggml_version().decode()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python (Pyodide)",
+ "language": "python",
+ "name": "python"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "python",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.14"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/playground.md b/docs/playground.md
new file mode 100644
index 0000000..bafbda4
--- /dev/null
+++ b/docs/playground.md
@@ -0,0 +1,17 @@
+---
+title: Playground
+---
+
+# Playground
+
+This JupyterLite notebook runs entirely in your browser and installs the bundled Pyodide wheel from the local playground package index.
+
+
+
+Open the JupyterLite workspace in a full page if the embedded view is too small.
+
+
diff --git a/mkdocs.yml b/mkdocs.yml
index 23b8e34..719f9ad 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -48,12 +48,15 @@ plugins:
- search
- social
+hooks:
+ - docs/hooks/jupyterlite.py
+
markdown_extensions:
- tables
- attr_list
- pymdownx.emoji:
- emoji_index: !!python/name:materialx.emoji.twemoji
- emoji_generator: !!python/name:materialx.emoji.to_svg
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.tilde
- pymdownx.superfences
- pymdownx.inlinehilite
@@ -70,5 +73,6 @@ watch:
nav:
- "Getting Started": "index.md"
+ - "Playground": "playground.md"
- "API Reference": "api-reference.md"
- "Changelog": "changelog.md"
diff --git a/pyproject.toml b/pyproject.toml
index af5f681..a6bbbbf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,7 +53,17 @@ extend-exclude = ["ggml/contrib/onnx.py", "tests/test_ggml_onnx.py"]
[project.optional-dependencies]
test = ["pytest"]
-docs = ["mkdocs", "mkdocstrings[python]", "mkdocs-material", "pillow", "cairosvg"]
+docs = [
+ "mkdocs",
+ "mkdocstrings[python]",
+ "mkdocs-material",
+ "pillow",
+ "cairosvg",
+ "jupyter-server>=2",
+ "jupyterlab-server>=2",
+ "jupyterlite-core>=0.7.0,<0.8",
+ "jupyterlite-pyodide-kernel>=0.7.0,<0.8",
+]
publish = ["build"]
convert = [
"accelerate==0.30.1",