name: marimo description: Essential guidance for working with marimo notebooks — reading, viewing, editing, refactoring, running, and capturing outputs. Covers marimo's reactive model, namespace management patterns, cell execution via run_cell.py, and output capture. Use this skill for ANY task involving marimo notebooks, including just reading or viewing them. Trigger on: user mentions "notebook" or "marimo", you see marimo.App() in a file, the task involves a .py file that is a notebook, or you need to invoke marimo from the command line.
Required: Fetch Official Recipes
Immediately after reading this skill, fetch the official marimo recipes documentation:
https://raw.githubusercontent.com/marimo-team/marimo/main/docs/recipes.md
This document contains essential patterns for UI elements, caching, control flow, and buttons that complement the project-specific guidance below. Do not skip this step.
Fundamentals
- Marimo notebooks are defined in pure python as a series of
@app.celldecorated functions - All cells in marimo share a global namespace
- The first cell should handle all imports, and should always include
import marimo as mo
Cells
@app.cell
def _():
<cell body>
display_me
return
- Cells are referenced by position:
cell_1is the first@app.cell,cell_2is the next, etc. The setup cell is not numbered. - Cells receive inputs and emit outputs by reading from and writing to the global namespace
- Cells should be idempotent
- Always end cells with a bare
returnstatement. The marimo autoformatter will automatically add the appropriate return tuple (e.g.,return (my_var,)) based on which variables the cell exports. Claude should never manually write return tuples. - The result of the last expression evaluated in the cell is displayed in the UI. To display multiple values, make the last expression be a call to
mo.vstackormo.hstack - Visualization objects and dataframes are displayed automatically as images and tables
The Reactive Model
- Cells in marimo form a directed acyclic graph (DAG), defined by creating an edge from cell A to cell B when B uses a global variable defined by A
- When the cell that declares a variable runs (or changes and is then ran automatically), all cells that use the variable automatically re-run
- Marimo does not track mutation. Never mutate a variable in the global namespace
- Cycles are illegal
Global Namespace Management
Always encapsulate the bodies of cells in a def _() function to prevent cell-local variables from leaking into the global namespace. Every cell that has cell-local variables should use this pattern, without exception. The only variables that should ever enter the global state are variables that we expressly want to be there. Here's a full example of how to use this pattern:
@app.cell
def _(df, pd):
def _():
grouped = df.groupby("category") # does not enter the global namespace, cell-local
means = grouped["value"].mean()
stds = grouped["value"].std()
return pd.DataFrame({"mean": means, "std": stds})
summary = _() # call cell-local function
summary # display the dataframe
return (summary,) # enter dataframe into global namespace; this return is auto-generated by marimo
Autoformat and Returns
When the user has a marimo notebook open with --watch, marimo will automatically format the notebook on save. If Claude is having issues with a third party editing the notebook, this is likely why. It is expected. Work with the formatter, not against it.
How Returns Work
Returns export variables to the global namespace. A variable is only accessible to other cells if it appears in the return tuple. This is why Claude should always write bare return and let the fixer handle it.
What marimo check --fix Does
The fixer performs cross-cell dependency analysis to automatically manage returns and parameters:
Adds returns for variables other cells need - If cell B references
fooand cell A definesfoo, the fixer addsreturn (foo,)to cell A.Adds function parameters for variables cells read - If cell B uses
foofrom the global namespace, the fixer addsfooto cell B's function signature:def _(foo):.Ignores underscore-prefixed variables - Variables starting with
_are never returned, even if referenced elsewhere. This is why thedef _()pattern works for hiding locals.Removes unnecessary returns - If you manually write
return (foo,)but no other cell usesfoo, the fixer strips it to barereturn.Fixes malformed returns - Invalid returns (wrong variables, non-tuples like
return x) are replaced with barereturn.
Why Claude Should Trust Bare Returns
The fixer is smart. Claude should:
- Always write bare
return- The fixer determines what needs to be exported - Never manually write return tuples - The fixer handles this based on actual cross-cell usage
- Leave existing return tuples alone - They were added by the fixer for a reason
The fixer runs automatically via --watch mode or manually via uvx marimo check --fix notebook.py.
Common Pitfalls
- Can't access UI value in same cell where it's defined - Define UI elements in one cell, access
.valuein a later cell - Matplotlib: Use
plt.gca()as the last expression to display a plot, notplt.show(). Callplt.tight_layout()to prevent clipping - Circular dependencies: If two cells reference each other's variables, marimo will error. Reorganize to break the cycle
- Closure capture in loops: When creating
on_changehandlers in a loop, bind the loop variable explicitly:lambda v, i=i: handle(i)notlambda v: handle(i)
Creating a Notebook
All notebooks should start with the following:
#!/usr/bin/env -S uvx marimo edit --sandbox --no-token --no-skew-protection --watch --port 3005
# /// script
# requires-python = ">=3.11"
# dependencies = ["marimo", "pandas", "seaborn"]
#
# [tool.marimo.runtime]
# auto_reload = "lazy"
# auto_instantiate = false
# ///
import marimo
__generated_with = "0.18.4"
app = marimo.App()
with app.setup:
import marimo as mo
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
# from helpers import my_helper
<first cell body goes here>
sns.set_theme()
@app.cell
def _():
<second cell body goes here>
return
if __name__ == "__main__":
app.run()
- Shebang so the user doesn't have to memorize an incantation to launch the notebook. Pick a random port for the kernel to run on
- The shebang includes
--watch, which makes the notebook automatically pick up changes from the notebook file and dependencies. Cells still have to be re-run manually, but you do NOT have to restart the kernel - Pandas for data manipulation, seaborn for visualization. This is our standard stack
with app.setupcreates the setup cell. This will be the first cell in the notebook, and is treated specially by marimoauto_reload = "lazy"configures marimo to mark cells that depend on functions defined outside the notebook as stale when those functions changeauto_instantiate = falseprevents marimo from automatically running the setup cell on notebook startup- After creating the notebook, run
chmod +x notebook.pyand notify the user that they can now open it with./notebook.py
uvx Tricks
uvx marimo check --fix notebook.py # Lint and autofix
uvx marimo development openapi # View the OpenAPI spec for the marimo kernel API
uvx marimo export html notebook.py --sandbox -o notebook.html # Run notebook and export to HTML. Not machine readable unfortunately
Working Agentically
Running Notebooks
When collaborating with a user, let the user run the notebook themselves. But when Claude is working agentically, Claude can run the notebook. Run it as a background bash process. Start it with the shebang, simply ./notebook.py. This will open the notebook in the browser-based marimo editor, but it will not automatically run the notebook cells.
Because we're not running cells when we open the notebook, opening the notebook is nearly instantaneous. It is natural to want to sleep after opening the notebook and before running the first run_cell command. NEVER, under ANY circumstances, sleep for longer than 2 seconds.
After the notebook is open, Claude should run the first cell in the reactive sequence. This is not the setup cell, which is handled specially by marimo. This cell should be the first cell that the user runs, which is cell_1. The setup cell runs automatically as a dependency.
IMPORTANT: All notebooks are ran with the --watch option (it's in the shebang). This means that marimo will automatically pick up changes to the notebook, including any changes to the notebook's dependencies. IMPORTANT: After changing a cell or notebook dependency, Claude should NOT re-run the notebook. Claude should re-run cells instead. Trust --watch!
Running Cells
Use .claude/skills/marimo/scripts/run_cell.py to run a cell from the command line while a notebook is open in the browser:
uv run .claude/skills/marimo/scripts/run_cell.py <cell_id> <port> [output_dir]
This connects to the running marimo server, triggers the specified cell, and waits for it (and all dependent cells) to complete.
Cells are identified by cell_n where n is the 1-based position among @app.cell functions. The setup cell (with app.setup) is not numbered and runs automatically as a dependency when needed.
The script outputs:
- Which cells actually ran (e.g.,
Ran [cell_2, cell_3, cell_4]) - Any print/stdout output from each cell
- Errors with full stack traces (exits with code 1 on error)
Capturing Outputs
If output_dir is provided to run_cell.py, cell outputs are automatically captured to disk:
uv run .claude/skills/marimo/scripts/run_cell.py cell_1 3005 ./outputs
Output:
Ran [cell_1, cell_2]
cell_1 output:
Processing data...
Captured outputs:
cell_1 [dataframe]: ./outputs/cell_1_dataframe.json
cell_2 [figure]: ./outputs/cell_2_figure.png
Supported output types:
- DataFrames: Saved as JSON (
cell_N_dataframe.json) - Figures: Saved as PNG (
cell_N_figure.png) - Markdown: Saved as HTML (
cell_N_markdown.html) - Simple values: Saved as text (
cell_N_text.txt)
The user will expect you to use this to look at what they're seeing when they run their notebook in the marimo editor. If they're asking you for your opinion on an output, you must go find it and view it. You should run the cell that produces the output using run_cell.py. Typically, this cell is cheap analysis. Do NOT re-run the expensive data generation. Never claim or imply the claim that you've seen an output that you in fact did not view.
Code Reuse
Local Packages
Reference local packages via [tool.uv.sources] in the notebook's inline metadata:
# /// script
# requires-python = ">=3.11"
# dependencies = ["pandas", "seaborn", "build_utils"]
#
# [tool.uv.sources]
# build_utils = { path = "../build_utils", editable = true }
# ///
With editable = true, changes to the package reflect immediately.
Helper Files
For shared utilities, create a regular Python file (e.g., helpers.py) in the notebook's directory and import from it. Regular functions can use marimo constructs like mo.status.spinner or mo.md() without any special decorators.
# helpers.py - a regular Python file
import marimo as mo
import time
def run_with_spinner(task_name: str):
"""Regular functions can use marimo constructs just fine."""
with mo.status.spinner(title=f"Running {task_name}..."):
time.sleep(1)
return f"{task_name} completed"
Then import in your notebook's setup cell:
with app.setup:
import marimo as mo
from helpers import run_with_spinner
Further Reading
The marimo documentation is excellent. When you encounter marimo-specific issues or unknowns (API signatures, interactivity options, etc), use search! The docs are available on the docs.marimo.io domain.