ALWAYS load before editing ANY .py file that contains @app.cell or marimo.App — 'edit this notebook', 'add a cell', 'fix the notebook', 'why is this cell not updating', 'my notebook won't run', 'convert this ipynb to marimo', 'turn my Jupyter notebook into marimo', 'start a notebook', 'export the notebook to HTML', 'run marimo', 'the cell says variable already defined', 'marimo edit'. Use even when the user just says 'the notebook' — hand-editing a marimo file breaks its cell signatures and DAG.
Installs into .claude/skills of the current project.
Are you the author of Marimo?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/edwinhu-marimo)
---
name: marimo
description: "ALWAYS load before editing ANY .py file that contains @app.cell or marimo.App — 'edit this notebook', 'add a cell', 'fix the notebook', 'why is this cell not updating', 'my notebook won't run', 'convert this ipynb to marimo', 'turn my Jupyter notebook into marimo', 'start a notebook', 'export the notebook to HTML', 'run marimo', 'the cell says variable already defined', 'marimo edit'. Use even when the user just says 'the notebook' — hand-editing a marimo file breaks its cell signatures and DAG."
user-invocable: false
---
**What this skill carries** — grep `references/` for any subject the names below miss:
!`d=${CLAUDE_SKILL_DIR}; command -v skill-toc >/dev/null 2>&1 && exec skill-toc "$d"; s=$HOME/.claude/skills/plugin-utils/bin/skill-toc; [ -x "$s" ] && exec "$s" "$d"; echo "(skill-toc unavailable: references and scripts are NOT listed here — install the plugin-utils plugin, or start a new session so its bin/ reaches PATH)"`
## Contents
- [Editing and Verification Enforcement](#editing-and-verification-enforcement)
- [Key Concepts](#key-concepts)
- [Cell Structure](#cell-structure)
- [Editing Rules](#editing-rules)
- [Core CLI Commands](#core-cli-commands)
- [Export Commands](#export-commands)
- [Live Session (marimo-pair)](#live-session-marimo-pair)
- [Data and Visualization](#data-and-visualization)
- [Debugging Workflow](#debugging-workflow)
- [Common Issues](#common-issues)
- [Additional Resources](#additional-resources)
# Marimo Reactive Notebooks
Marimo is a reactive Python notebook where cells form a DAG and auto-execute on dependency changes. Notebooks are stored as pure `.py` files.
## Editing and Verification Enforcement
### IRON LAW #1: NEVER MODIFY CELL DECORATORS OR SIGNATURES
Only edit code INSIDE `@app.cell` function bodies. This is not negotiable.
**NEVER modify:**
- Cell decorators (`@app.cell`)
- Function signatures (`def _(deps):`)
- Return statements structure (trailing commas required)
**ALWAYS verify:**
- All used variables are in function parameters
- All created variables are in return statement
- Trailing comma for single returns: `return var,`
### IRON LAW #2: NO EXECUTION CLAIM WITHOUT OUTPUT VERIFICATION
Before claiming ANY marimo notebook works:
1. **VALIDATE** syntax and structure: `marimo check notebook.py`
2. **EXECUTE** with outputs: `marimo export ipynb notebook.py -o __marimo__/notebook.ipynb --include-outputs`
3. **VERIFY** using notebook-debug skill's verification checklist
4. **CLAIM** success only after verification passes
This is not negotiable. Skipping execution and output inspection is NOT HELPFUL — the user gets a notebook that fails when they open it.
### Marimo Facts
- `marimo check` validates syntax and structure only — it never executes cells. Claiming a notebook works because check passed is an unverified claim presented as fact.
- Reactivity propagates every edit through the DAG: a one-line change re-executes all dependent cells. Verifying only the edited cell misses downstream breakage — counterproductive on its own terms.
- Wrong dependencies or missing returns break reactivity silently: no error at edit time, only a NameError when a dependent cell runs. Validate that all used variables are in params AND all created variables are in returns.
- A variable created but not returned raises NameError in every cell that depends on it.
- Python treats `return var` as returning the bare value, which breaks unpacking — single returns require the trailing comma (`return var,`).
- Marimo re-runs a cell when a **variable** in its dependency DAG changes. Regenerating a data file changes no variable — `t("table7")` is the same call on the same code — so the runtime correctly re-runs nothing, and `--watch` watches the notebook `.py`, not the data directory. A data-only change is therefore completely invisible to a live session until you re-run the cells yourself. Worse, every check you can run still passes: the parquet on disk is correct, `t("table7")` called from the kernel re-reads disk and returns the NEW data, all cells report `status=idle` with no errors, and a fresh HTML export is correct. Only the rendered output already sitting in the user's browser is stale — so "I queried the kernel and it's correct" is not evidence the user can see it, and you can verify a change thoroughly and report it truthfully while the person watching the screen sees the old numbers.
### Red Flags — STOP If About To:
- Edit a `@app.cell` decorator or `def _(...)` signature → STOP. Marimo manages these; edit only the function body.
- Claim done after only `marimo check` → STOP. Execution with `--include-outputs` is required.
- Claim the notebook works from reading the code → STOP. Reactive correctness shows only at runtime.
- Define a variable that another cell already defines → STOP. One variable = one cell.
- Report a regenerated data file as something the user can see, without having re-run the cells → STOP. Nothing re-ran; their browser still shows the old numbers.
### Editing Checklist
Before every marimo edit:
**Structure Validation:**
- [ ] Only edit code INSIDE `@app.cell` function bodies
- [ ] Do NOT modify decorators or signatures
- [ ] Verify all used variables are in function parameters
- [ ] Verify all created variables are in return statement
- [ ] Ensure trailing comma used for single returns
- [ ] Ensure no variable redefinitions across cells
**Syntax Validation:**
- [ ] Execute `marimo check notebook.py`
- [ ] Verify no syntax errors reported
- [ ] Verify no undefined variable warnings
- [ ] Verify no redefinition warnings
**Runtime Verification:**
- [ ] Execute with `marimo export ipynb notebook.py -o __marimo__/notebook.ipynb --include-outputs`
- [ ] Verify export succeeded (exit code 0)
- [ ] Verify output ipynb exists and is non-empty
- [ ] Apply notebook-debug verification checklist
- [ ] Verify no tracebacks in any cell
- [ ] Verify all cells executed (execution_count not null)
- [ ] Verify outputs match expectations
**Only after ALL checks pass:**
- [ ] Claim "notebook works"
### Gate Function: Marimo Verification
Follow this sequence for EVERY marimo task:
```
1. EDIT → Modify code inside @app.cell function bodies only
2. CHECK → marimo check notebook.py
3. EXECUTE → marimo export ipynb notebook.py -o __marimo__/notebook.ipynb --include-outputs
4. INSPECT → Use notebook-debug verification
5. VERIFY → Outputs match expectations
6. CLAIM → "Notebook works" only after all gates passed
```
**NEVER skip verification gates.** Marimo's reactivity means changes propagate unpredictably.
## Key Concepts
- **Reactive execution**: Cells auto-update when dependencies change
- **No hidden state**: Each variable defined in exactly one cell
- **Pure Python**: `.py` files, version control friendly
- **Cell structure**: `@app.cell` decorator pattern
## Cell Structure
```python
import marimo
app = marimo.App()
@app.cell
def _(pl): # Dependencies as parameters
df = pl.read_csv("data.csv")
return df, # Trailing comma required for single return
@app.cell
def _(df, pl):
summary = df.describe()
filtered = df.filter(pl.col("value") > 0)
return summary, filtered # Multiple returns
```
## Editing Rules
- Edit code INSIDE `@app.cell` functions only
- Never modify cell decorators or function signatures
- Variables cannot be redefined across cells
- All used variables must be returned from their defining cell
- **Markdown cells: Always wrap `$` in backticks** - `mo.md("Cost: `$50`")` not `mo.md("Cost: $50")`
- **Markdown cells: never hard-code a number, and never hard-code the QUANTIFIER either.**
Every figure is an f-string off the data (`mo.md(f"{_n:,} advisers")`), so a rebuild
cannot leave prose asserting last month's count. The subtler half: words like "all",
"every", "none", "both" and "only" go stale even when the number beside them
interpolates. Measured: a sentence reading "**{_io} advisers use it, and all {_io_bd} of
them describe board seats**" survived a corpus rebuild that took it from 62 advisers to
4 — of which 2 had board seats. Both numbers were correct and the sentence was false.
Compute the quantifier (`{_io_bd} of the {_io}`) or phrase it so the count carries it.
## Core CLI Commands
| Command | Purpose |
|---------|---------|
| `marimo edit notebook.py` | marimo: Open notebook in browser editor for interactive development |
| `marimo run notebook.py` | marimo: Run notebook as executable app |
| `marimo check notebook.py` | marimo: Validate notebook structure and syntax without execution |
| `marimo convert notebook.ipynb` | marimo: Convert Jupyter notebook to marimo format |
## Export Commands
```bash
# marimo: Export to ipynb with code only
marimo export ipynb notebook.py -o __marimo__/notebook.ipynb
# marimo: Export to ipynb with outputs (runs notebook first)
marimo export ipynb notebook.py -o __marimo__/notebook.ipynb --include-outputs
# marimo: Export to HTML (runs notebook by default)
marimo export html notebook.py -o __marimo__/notebook.html
# marimo: Export to HTML with auto-refresh on changes (live preview)
marimo export html notebook.py -o __marimo__/notebook.html --watch
```
**Key difference:** HTML export runs the notebook by default. ipynb export does NOT - use `--include-outputs` to run and capture outputs.
**Tip:** Use `__marimo__/` folder for all exports (ipynb, html). The editor can auto-save there.
## Live Session (marimo-pair)
For working inside a **running** marimo notebook kernel — executing code, creating/editing cells, and building notebooks interactively — invoke `Skill(skill="marimo-pair:marimo-pair")`. It ships separately, so install it once if that skill is not found:
```bash
claude plugin marketplace add marimo-team/marimo-pair
claude plugin install marimo-pair@marimo-pair
```
That skill owns the live-session protocol and its own CLI; read its SKILL.md for the current command surface rather than any command remembered from here. This section carries only what marimo-pair does *not*: how we start servers, and what we do after a data-only change.
### Starting a Server
marimo-pair's `reference/finding-marimo.md` has the full binary-resolution decision tree. Quick start:
```bash
# pixi project (our standard)
pixi run marimo edit notebook.py --no-token --watch
# uv project
uv run marimo edit notebook.py --no-token --watch
# standalone / sandbox
uvx marimo@latest edit notebook.py --no-token --watch --sandbox
```
**Always use `--watch`** so the server detects file edits and reloads automatically. Without it, file changes are invisible to the browser and the user sees stale content.
**Always start as a background task** (`run_in_background`) so the server doesn't block the conversation. Do NOT use `--headless` unless asked — let marimo open the browser.
### Remote Box? Bind to the Tailnet, Don't Ask for SSH Forwarding
`marimo edit` binds **127.0.0.1** by default. When the notebook runs on a remote host and the user
is on SSH, that is unreachable — they get nothing, and the obvious next move (tell them to set up
`LocalForward`) costs them a config edit *and* a reconnect before they can look at anything.
Bind to the machine's Tailscale address instead. It works immediately, from any tailnet device
including a phone, with no client-side change:
```bash
TS_IP=$(tailscale ip -4 2>/dev/null | head -1)
setsid nohup marimo edit notebook.py --no-token --watch --headless \
--host "$TS_IP" --port 2718 > /tmp/marimo.log 2>&1 < /dev/null & disown
# confirm it is actually reachable — a bind is not a connection
ss -ltn | grep 2718
curl -s -o /dev/null -w '%{http_code}\n' "http://$TS_IP:2718" # want 200
```
Then hand the user `http://$TS_IP:2718`. `--headless` is correct here (the opposite of the
local-box default above): a remote host has no browser to open.
**Tear it down when the review closes.** A `--no-token` server left running is an open notebook
kernel on the tailnet, and the next session's server discovery finds a stale one bound to a
notebook nobody is reviewing.
```bash
# record the pid at launch — this is the safe handle
echo $! > /tmp/marimo.pid
kill "$(cat /tmp/marimo.pid)"
```
### IRON LAW #3: `import marimo` GOES IN THE FIRST ~400 BYTES
**Never put a module docstring, licence header, or comment block in front of
`import marimo`. This is not negotiable.**
marimo decides whether a `.py` file is a notebook by scanning only the head of
the file. Push the signature past that window and the file is still a perfectly
valid notebook that runs, checks, and exports — it simply **stops appearing in
the workspace listing**, and there is no error anywhere to explain why.
Measured on 0.23.4 by bisection, identical files differing only in a leading
docstring:
| `import marimo` at byte | workspace listing |
|---|---|
| 0, 167, 246, 325, 404 | **listed** |
| 483, 562, 641 | **hidden** |
Put explanatory prose in an `mo.md` intro cell instead — a module docstring is
invisible in the rendered notebook anyway, so the "documentation" it buys costs
the file its discoverability and shows the reader nothing.
**If a notebook you just wrote is missing from the workspace, check the byte
offset of `import marimo` before anything else:**
```bash
python3 -c "print(open('nb.py').read().index('import marimo'))" # want < 400
```
Diagnose the listing directly rather than guessing at the server — the API
answers precisely, including the root it is scanning:
```bash
TOK=$(curl -s "http://$HOST:$PORT" | grep -oP '(?<=data-token=")[^"]+' | head -1)
curl -s -X POST -H 'Content-Type: application/json' -H "Marimo-Server-Token: $TOK" \
-d '{}' "http://$HOST:$PORT/api/home/workspace_files"
```
### Server Lifecycle Facts
- marimo's workspace file browser roots at the process **cwd**, not at the path argument. Running
`marimo edit notebooks/` from the repo root serves that directory but browses the root, so the
workspace lists nothing while "recent notebooks" still shows whatever was opened before — which
reads as a marimo bug rather than a launch mistake. `cd` into the directory first.
- A notebook whose `import marimo` sits past ~400 bytes is invisible in the workspace listing while
remaining fully valid — it runs, `marimo check` passes, `export` works. Iron Law #3 above.
- `marimo edit` binds `127.0.0.1` unless told otherwise. On a remote host that is invisible to an
SSH-connected user, and answering "set up a LocalForward" spends their reconnect to buy what
`--host <tailnet-ip>` gives for free. Verified: `LISTEN 127.0.0.1:2718` before, `HTTP 200` on the
tailnet address after.
- Bind to the **specific tailnet IP**, never `0.0.0.0`. With `--no-token` there is no auth at all,
so the bind address *is* the access control — `0.0.0.0` exposes an executing kernel to every
interface the box has.
- `pkill -f 'marimo edit notebook.py'` **matches the shell running it**, because `-f` sees the full
command line including your own. Measured: it killed the launching shell and took the new server
with it, leaving nothing listening and an empty log that reads like a startup failure. Record the
PID at launch, or use `pkill -x marimo` / a `[m]arimo` character class.
- **`marimo export` rewrites the source `.py` it exports.** Measured with the server idle: one
`export html` moved the notebook's mtime by 39 seconds without touching content. So exporting two
formats leaves the first one older than the source — any "is the export newer than the source?"
freshness check fails for whichever ran first. Export the format you will actually gate on
**last**. Discovering this at the gate, after the work is done, is the expensive way to learn it.
### Refreshing After a Data-Only Change
When you regenerate data a notebook reads, re-run **every** cell, not the ones you judge
affected. Guessing which cells a data change touches is exactly what lets a stale render
through — the dependency DAG cannot tell you, because no variable changed. For a thin-reader
notebook (cells that just `pl.read_parquet(...)`) a full re-run is cheap.
Run this in the live kernel (via marimo-pair — see the pointer at the top of this section):
```python
import marimo._code_mode as cm
async with cm.get_context() as ctx:
for c in ctx.cells:
ctx.run_cell(c.id)
```
Better still, wrap regenerate + refresh + export in one project script so the refresh cannot
be lost by forgetting it — this project does, at `scripts/repro/refresh.sh`.
`ctx.screenshot()` is not a shortcut for confirming what the user sees: it is a **coroutine**
(needs `await`, unlike every other `ctx.*` method) *and* it requires Playwright installed in
the environment. Re-run the cells instead. Note `cm.get_context()` likewise needs
`async with`, not `with`.
### While a Session Is Live
**NEVER write to the `.py` file directly while a session is running** — the kernel owns it. Make
cell changes through marimo-pair. Everything else about scratchpad execution, cell mutation and
package installation is marimo-pair's to document; read its SKILL.md and `reference/` files
(`finding-marimo.md`, `gotchas.md`, `rich-representations.md`, `notebook-improvements.md`) rather
than a copy here.
## Data and Visualization
- Prefer polars over pandas for performance
- Use `mo.ui` for interactive widgets
- SQL cells: `mo.sql(df, "SELECT * FROM df")`
- Display markdown: `mo.md("# Heading")`
## Debugging Workflow
**1. Pre-execution validation:**
```bash
# scripts: Validate notebook syntax and cell structure
scripts/check_notebook.sh notebook.py
```
Runs syntax check, marimo validation, and cell structure overview in one command.
**2. Runtime errors:** Export with outputs, then use `notebook-debug` skill:
```bash
# marimo: Export to ipynb with outputs for inspection
marimo export ipynb notebook.py -o __marimo__/notebook.ipynb --include-outputs
```
## Common Issues
| Issue | Fix |
|-------|-----|
| Variable redefinition | Rename one variable or merge cells |
| Circular dependency | Break cycle by merging or restructuring |
| Missing return | Add `return var,` with trailing comma |
| Import not available | Ensure import cell returns the module |
## Additional Resources
### Reference Files
For detailed patterns and advanced techniques, consult:
- **`references/reactivity.md`** - DAG execution, variable rules, dependency detection patterns
- **`references/debugging.md`** - Error patterns, runtime debugging, environment-specific issues
- **`references/widgets.md`** - Interactive UI components and mo.ui patterns
- **`references/sql.md`** - SQL cells and database integration techniques
Live-session references (finding the marimo binary, cached-module gotchas, rich representations,
notebook improvements) ship with the `marimo-pair` plugin — invoke `Skill(skill="marimo-pair:marimo-pair")`.
### Examples
Working examples available in `examples/`:
- **`examples/basic_notebook.py`** - Minimal marimo notebook structure
- **`examples/data_analysis.py`** - Data loading, filtering, and visualization patterns
- **`examples/interactive_widgets.py`** - Interactive UI component usage
### Scripts
Validation and live-session utilities:
- **`scripts/check_notebook.sh`** - Primary validation: syntax check, marimo validation, cell structure overview
- **`scripts/get_cell_map.py`** - Extract cell metadata (invoked by check_notebook.sh)
### Related Skills
- **`notebook-debug`** - Debugging executed ipynb files with tracebacks and output inspection
- **`marimo-pair:marimo-pair`** - Full live-kernel protocol: server discovery, scratchpad execution, cell mutation