Filesystem-backed Python notebooks. Each cell is a .py file. Output lives in .output sidecar files. One long-running kernel holds shared state.
Install
Requires Python 3.10+.
git clone https://github.com/BartlomiejLewandowski/looseleaf cd looseleaf pip install .
Usage
looseleaf serve [dir] # default dir is current directory
looseleaf serve my-experiment
looseleaf serve ./analysis --port 9000Point it at a directory. If it doesn't exist, it gets created. Open http://localhost:8765.
Only one kernel can run per directory at a time. A .looseleaf.lock file is written on startup and removed on exit. Starting a second kernel in the same directory prints an error and exits.
CLI commands (requires a running kernel)
looseleaf cells # list cells and whether each has output looseleaf run 02_load.py # run one cell, print output, exit 0/1 looseleaf run-all # run all cells in filename order looseleaf reset # clear the shared namespace looseleaf interrupt # stop the currently running cell
All commands accept --port PORT.
Development
./dev [dir] # restarts the kernel automatically when kernel.py or index.html changeHow it works
The kernel is a single Python process that:
- Maintains a shared namespace — variables persist across cells, just like Jupyter
- Executes
.pyfiles on demand viaexec()in that namespace - Streams output over WebSocket as it happens (stdout, stderr, matplotlib plots)
- Writes
.outputJSON sidecar files alongside each.pycell - Watches the filesystem for external edits (edit cells in your editor, changes appear in the browser)
- Serves the frontend on the same port
Cells are just Python files. They sort alphabetically, so use numbered prefixes:
my-experiment/
├── 01_setup.py
├── 01_setup.output
├── 02_load_data.py
├── 02_load_data.output
├── 03_train.py
└── 03_train.output
Keyboard shortcuts
| Key | Mode | Action |
|---|---|---|
Enter |
command | Enter edit mode |
Esc |
edit | Back to command mode |
Cmd+Enter |
both | Run cell |
Shift+Enter |
both | Run cell, select next |
Up/Down |
command | Navigate cells |
j/k |
command | Navigate cells (vim-style) |
Up at line 1 |
edit | Select previous cell |
Down at last line |
edit | Select next cell |
Two modes, like Jupyter:
- Command mode (blue left bar) — cell is selected, keyboard navigates between cells
- Edit mode (green left bar) — cursor is in the editor, keyboard types code
Features
- Streaming output — print statements and plots appear in real time
- Shared state — cell 2 can use variables from cell 1
- Monaco editor — syntax highlighting, autocomplete, the same editor as VS Code
- Filesystem is the source of truth — cells are real
.pyfiles, work with git/linters/editors .outputfiles — JSON with stdout, stderr, error tracebacks, base64 images, timing- Live stats — memory usage, variable count, uptime in the toolbar
- File watcher — edit cells externally, browser updates automatically
- Multi-client — multiple browser tabs see the same kernel state
.output format
{
"stdout": "Hello world\n",
"stderr": "",
"images": ["base64-encoded-png..."],
"error": null,
"elapsed": 0.003,
"execution_number": 3
}Add *.output to .gitignore. They're regenerated on run.
Limitations
- One kernel, one namespace, one directory. That's the point.
- No cell reordering in the UI — rename files to reorder (they sort alphabetically).
- No markdown cells — write comments in Python instead.
- Matplotlib only for plot capture — uses the
Aggbackend, patchesplt.show(). - Editing conflicts with multiple users — last save wins.
- One kernel per directory — starting a second kernel in the same directory is an error.
AI-friendly by design
Because cells are plain files, AI coding agents (Claude Code, Copilot, Cursor, etc.) can work with looseleaf notebooks naturally:
- Edit one cell at a time — each cell is its own
.pyfile. An agent rewrites03_train.pywithout touching anything else. No monolithic JSON to parse or accidentally corrupt. - Read output without running anything —
.outputsidecar files are plain JSON. An agent can inspect stdout, stderr, error tracebacks, and timing from the last run before deciding what to change. - Drop in new cells — create a new
.pyfile in the directory and the file watcher picks it up automatically. No API, no restart. - Standard Python — no special syntax, no cell markers, no notebook format. Any tool that understands
.pyfiles already understands a looseleaf notebook.
Further reading
- docs/ARCH.md — frontend ↔ backend communication and WebSocket protocol
- docs/KERNEL_LOCK.md — kernel lock and execution model internals
Related projects
- marimo — Reactive Python notebook stored as pure
.pyfiles, git-friendly, Monaco editor, streaming output. Much heavier and more opinionated (reruns dependent cells automatically), but the closest in spirit. No one-file-per-cell model. - srush/streambook — Tiny (~50 lines): watches a
.pyfile and renders a live Streamlit view. Same minimal philosophy, but uses# %%markers in a single file rather than separate files per cell. - netgusto/nodebook — Multi-lang web REPL where a notebook is a directory containing a single
main.py. Same "directory = notebook" concept, but no multi-cell shared-state model. - jupytext — Syncs
.ipynbnotebooks to plain.pyfiles using# %%markers. Git-friendly, edit in any editor — but depends on Jupyter as the execution backend. - nteract/hydrogen — Atom plugin for running Jupyter kernels inline in text files. Same "edit in real editor" idea; now unmaintained since Atom was sunsetted.