Install
$ agentstack add skill-adrunkhuman-marimo-opencode-skill-marimo-notebook ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Marimo Notebook Skill
Running & Editing
# Edit interactively in browser
uv run marimo edit
# Run as web app (code hidden)
uv run marimo run
# Run as plain script (non-interactive, for testing/CI)
uv run
# Lint for common mistakes — always run before handing back to user
uvx marimo check
uvx marimo check --fix
When to use this skill
- Creating .py notebook files for interactive data exploration
- Building reactive dashboards/apps with marimo
- Converting Jupyter notebooks (.ipynb) to marimo
Ask clarifying questions if:
- User mentions "Cell.run()", "reusable cells", or "testing cells" (API use case, different patterns)
- User wants to "pass variables between cells" with parameters/returns (wrong mental model)
File Format
Marimo notebooks are .py files. Each cell is a function decorated with @app.cell. Marimo resolves dependencies via static analysis of the cell body — it reads what variables you assign and reference, then builds a DAG automatically.
When marimo saves, it auto-generates function parameters and return tuples as serialization metadata. You don't need to manage these yourself. Write def _(): for all cells. Marimo handles the rest.
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "marimo",
# "pandas",
# "numpy",
# ]
# ///
import marimo
__generated_with = "0.19.7"
app = marimo.App(width="medium")
@app.cell
def _():
import marimo as mo
import pandas as pd
import numpy as np
@app.cell
def _():
# df is automatically available to all other cells
df = pd.read_parquet("data/input.parquet")
@app.cell
def _():
# Reference df directly — marimo knows the dependency
filtered = df[df["value"] > threshold.value]
mo.md(f"**{len(filtered)} rows** after filtering")
if __name__ == "__main__":
app.run()
PEP 723 inline dependencies (# /// script block) are added automatically by marimo edit --sandbox but should always be present. They enable --sandbox mode for isolated execution.
Core Mental Model
Variables Are Automatically Global
Assign a variable in any cell and it's available everywhere. Marimo's static analysis handles dependency resolution. No explicit exports needed.
@app.cell
def _():
df = pd.read_csv("data.csv") # df is available to all cells
df.head() # Bare expression = displayed in UI
@app.cell
def _():
summary = df.describe() # Just reference df directly
summary
Use _prefix (e.g., _tmp, _i) for cell-local variables that shouldn't leak:
@app.cell
def _():
_tmp = []
for _i in range(10):
_tmp.append(_i * 2)
result = sum(_tmp) # Only result is global
Don't use _prefix to dodge "variable already defined" errors — refactor to meaningful unique names instead.
Two Output Channels Per Cell
Every cell has two independent channels:
- Display — the last bare expression (rendered in the UI)
- Sharing — any variable assigned in the cell body is automatically global
A cell can do both, either, or neither.
# Display only — shows controls
@app.cell
def _():
mo.hstack([months_slider, outlier_input], justify="start")
# Share only — computes, no visual output
@app.cell
def _():
filtered = filter_outliers(predictions)
outlier_count = len(predictions) - len(filtered)
# Both — creates a UI element AND displays it
@app.cell
def _():
period_selector = mo.ui.dropdown(options=options, value=default)
mo.hstack([mo.md("### Analysis"), period_selector])
# Neither — defines helpers
@app.cell
def _():
def calculate_rmse(preds, actuals):
return np.sqrt(np.mean((preds - actuals) ** 2))
Only the final expression renders. Indented or conditional expressions won't:
# BAD — indented expression won't render
@app.cell
def _():
if condition:
mo.md("This won't show!")
# GOOD — final expression renders
@app.cell
def _():
output = mo.md("Shown!") if condition else mo.md("Also shown!")
output
Cells Don't Take Parameters
Never use function parameters for cell-to-cell data flow. Reference variables directly.
# WRONG
@app.cell
def analyze(df): # Don't declare parameters for sharing
df.describe()
# RIGHT
@app.cell
def _():
df.describe() # Direct reference — marimo resolves the dependency
About Return Tuples in .py Files
When marimo serializes a notebook, it adds return (var1, var2) lines. These are marimo's bookkeeping — don't add or maintain them yourself. If they appear in a file you're editing, leave them alone; marimo will update them on next save.
Rules
NO_PRINT: Use marimo Output, Not print()
print() works in edit mode but fails silently in run/app mode.
# WRONG
print(f"Accuracy: {accuracy}")
# GOOD
mo.md(f"**Accuracy:** {accuracy:.2%}")
mo.stat(value=f"{accuracy:.2%}", label="Accuracy")
f"Processed {len(df)} rows" # Bare expression
UI_GLOBAL: UI Elements Must Be Global
Assign UI elements to global variables (no _ prefix) for reactivity tracking.
# GOOD
@app.cell
def _():
slider = mo.ui.slider(1, 10)
slider # Display it
# WRONG — local, breaks reactivity
@app.cell
def _():
_slider = mo.ui.slider(1, 10)
_slider
UIVALUEACCESS: Read .value in a Different Cell
Define UI in one cell, read .value in another. Reading .value in the same cell as definition breaks reactivity — the defining cell doesn't re-run on interaction, only dependent cells do.
# Cell 1 — define and display
@app.cell
def _():
slider = mo.ui.slider(1, 10)
slider
# Cell 2 — read value (re-runs when slider changes)
@app.cell
def _():
mo.md(f"Value: {slider.value}")
Exception: SQL cells return DataFrames directly (no .value).
NOONCHANGE: Don't Use on_change Handlers
Use marimo's built-in reactive execution instead. Cells that reference a UI element's .value re-run automatically.
# WRONG
@app.cell
def _():
slider = mo.ui.slider(on_change=lambda v: update(v))
# RIGHT — let reactivity handle it
@app.cell
def _():
slider = mo.ui.slider(1, 10)
slider
@app.cell
def _():
filtered = df[df["x"] > slider.value] # Auto re-runs
Exception: on_change is legitimate with mo.state() for accumulated callback state (see State Management below).
NOMUTATIONACROSS_CELLS: Mutate Where Defined
Marimo doesn't track object mutations. Mutate in the same cell that creates the object, or create new variables.
# BAD
@app.cell
def _():
items = [1, 2, 3]
@app.cell
def _():
items.append(4) # Marimo won't know this happened
# GOOD — create new
@app.cell
def _():
extended = items + [4]
# GOOD — mutate where defined
@app.cell
def _():
items = [1, 2, 3]
items.append(4)
STOPNOTEXCEPTION: Use mo.stop for Graceful Halting
# GOOD
@app.cell
def _():
mo.stop(not data_loaded, mo.callout(f"Error: {error_msg}", kind="error"))
mo.md("## Dashboard Ready")
# WRONG — ugly red error UI
@app.cell
def _():
if not data_loaded:
raise ValueError("Data not found")
FORM_GATE: Check form.value for None
form.value is None until submit is clicked.
@app.cell
def _():
mo.stop(form.value is None, "Submit to process")
result = analyze(form.value)
IDEMPOTENT_CELLS: Same Inputs → Same Outputs
Cells re-run when dependencies change. Non-idempotent cells cause unpredictable state.
# BAD — accumulates on every re-run
@app.cell
def _():
results.append(compute())
# GOOD — fresh every time
@app.cell
def _():
results = [compute()]
DON'TGUARDUNNECESSARILY
Marimo's reactivity means cells only run when dependencies are ready.
# BAD — unnecessary guard
@app.cell
def _():
if training_results:
fig, ax = plt.subplots()
ax.plot(training_results["losses"])
fig
# GOOD — cell won't run until training_results exists
@app.cell
def _():
fig, ax = plt.subplots()
ax.plot(training_results["losses"])
fig
DON'TTRYEXCEPTFORCONTROL_FLOW
Let errors surface. Only use try/except for specific, expected exceptions with meaningful recovery (e.g., FileNotFoundError when loading optional data).
Script Mode Detection
Use mo.app_meta().mode to detect CLI vs interactive execution:
@app.cell
def _():
is_script_mode = mo.app_meta().mode == "script"
Show all UI elements always — only change the data source in script mode:
@app.cell
def _():
slider = mo.ui.slider(start=0.001, stop=0.1, value=0.01)
slider
@app.cell
def _():
if is_script_mode:
results = run_training(X, y, lr=slider.value) # Auto-run
else:
mo.stop(not train_button.value, "Click to train")
results = run_training(X, y, lr=slider.value)
SQL
Under the hood, SQL cells call mo.sql(). By default marimo uses DuckDB in-memory and can reference any DataFrame variable in scope.
@app.cell
def _():
grouped = mo.sql(
f"""
SELECT category, MEAN(value) as mean
FROM df
GROUP BY category
ORDER BY mean
""",
output=False # Don't auto-display, just return the DataFrame
)
grouped is a Polars DataFrame (configurable to Pandas). No .value needed.
Signature: mo.sql(query: str, *, output: bool = True, engine: Optional[DBAPIConnection] = None)
External engines
# SQLAlchemy
import sqlalchemy
engine = sqlalchemy.create_engine("sqlite:///:memory:")
result = mo.sql("SELECT * FROM users", engine=engine)
# DuckDB file
import duckdb
conn = duckdb.connect("file.db", read_only=True)
result = mo.sql("SELECT * FROM sales", engine=conn)
State Management
Regular variables between cells ARE your state. Widget .value works the same way. No store, no session_state, no hooks needed for 99% of cases.
When you DO need mo.state()
Use it for accumulated state from callbacks or bidirectional UI sync.
@app.cell
def _():
get_items, set_items = mo.state([])
@app.cell
def _():
task = mo.ui.text(label="New task")
add = mo.ui.button(
label="Add",
on_click=lambda _: set_items(lambda d: d + [task.value])
)
mo.hstack([task, add])
@app.cell
def _():
mo.md("\n".join(f"- {t}" for t in get_items()))
Bidirectional sync (slider ↔ number input):
@app.cell
def _():
get_n, set_n = mo.state(50)
@app.cell
def _():
slider = mo.ui.slider(0, 100, value=get_n(), on_change=set_n)
number = mo.ui.number(0, 100, value=get_n(), on_change=set_n)
mo.hstack([slider, number])
Warnings:
- Don't store
mo.uielements inside state - Don't use
on_changewhen you can just read.valuefrom another cell - The cell calling the setter does NOT re-run (unless
allow_self_loops=True)
UI Components Quick Reference
Core inputs: mo.ui.slider, mo.ui.number, mo.ui.text, mo.ui.text_area, mo.ui.dropdown, mo.ui.radio, mo.ui.checkbox, mo.ui.date, mo.ui.file, mo.ui.button, mo.ui.run_button
Data: mo.ui.table, mo.ui.dataframe, mo.ui.data_explorer
Charts: mo.ui.altair_chart, mo.ui.plotly
Layout: mo.ui.tabs, mo.ui.array
Composition: mo.ui.form, .batch().form()
Forms with .batch().form()
Compose multiple UI elements into a single form with a submit button:
@app.cell
def _():
form = (
mo.md("""
**Choice** {choice}
**Text** {text}
**Flag** {flag}
""")
.batch(
choice=mo.ui.dropdown(options=["A", "B", "C"]),
text=mo.ui.text(),
flag=mo.ui.checkbox(),
)
.form(submit_button_label="Submit", show_clear_button=True)
)
form
@app.cell
def _():
mo.stop(form.value is None, "Submit the form first")
# form.value is a dict: {"choice": ..., "text": ..., "flag": ...}
Add validation to block submission:
mo.ui.dropdown(options=columns, allow_select_none=True, value=None).form(
submit_button_label="Apply",
validate=lambda v: "Select a column" if v is None else None,
)
Checking API docs at runtime
uv --with marimo run python -c "import marimo as mo; help(mo.ui.form)"
Top-Level Imports: @app.function and @app.class_definition
Expose functions/classes for import by other Python scripts or notebooks.
Requirements:
- Cell defines exactly one function or class
- Can only reference symbols from
app.setupor other top-level symbols
import marimo
app = marimo.App()
with app.setup:
import numpy as np
@app.function
def calculate_statistics(data):
return {
"mean": np.mean(data),
"std": np.std(data),
}
if __name__ == "__main__":
app.run()
Then from another file:
from my_notebook import calculate_statistics
stats = calculate_statistics([1, 2, 3, 4, 5])
Module Splitting
When a notebook gets too long, extract logic into helper .py modules and import them. Enable marimo's built-in module autoreloading so edits propagate without restarting.
Export
uvx marimo export html notebook.py -o out.html
uvx marimo export pdf notebook.py -o out.pdf
uvx marimo export script notebook.py -o flat.py # Flattened topological script
uvx marimo export md notebook.py -o out.md
uvx marimo export ipynb notebook.py -o out.ipynb
uvx marimo export html-wasm notebook.py -o out.html # WASM-powered, runs in browser
PDF export needs nbformat, nbconvert, and Chromium (playwright install chromium).
Useful PDF flags: --no-include-inputs (hide code), --as=slides (reveal.js deck), --raster-scale 4.0 (sharpness).
Common flags across exports: -o (output path), --watch (re-export on change), --sandbox (isolated uv env), -f (overwrite).
Deployment
# Single notebook as web app
uvx marimo run --sandbox notebook.py
# Folder of notebooks
uvx marimo run --sandbox
# Generate thumbnails for multi-notebook deployment
uvx marimo export thumbnail notebook.py
Add metadata for overview pages:
# /// script
# requires-python = ">=3.12"
# dependencies = ["marimo", "polars"]
# [tool.marimo.opengraph]
# title = "My Dashboard"
# description = "Portfolio tracking over time"
# ///
Validation Checklist
- [ ] All cells use
def _():— no manual parameters - [ ] Variables referenced by direct name across cells
- [ ] Bare expression as last line for display output
- [ ] UI elements assigned to globals (no
_prefix) - [ ]
.valueread in DIFFERENT cell from UI definition - [ ] No
on_changecallbacks (unlessmo.state()accumulation) - [ ] No
print()— usemo.md()/mo.stat()/ bare expressions - [ ] No mutation across cells
- [ ] Cell-local temps use
_prefix - [ ]
mo.stop()for conditional halts (not exceptions) - [ ] Cells are idempotent
- [ ] PEP 723 dependencies present
- [ ]
marimo checkpasses
Common Jupyter Idioms That Break
| Jupyter / Naive Python | Marimo | |------------------------|--------| | def analyze(df): cell params | def _(): + direct reference | | print() for output | mo.md(), mo.stat(), bare expression | | Read .value in same cell as UI def | Define UI in one cell, read in another | | on_change callbacks | Reactive execution | | Mutate objects across cells | Mutate where defined or create new | | raise for user errors | mo.stop(condition, message) | | if guard: before using variable | Cell won't run until dependency exists |
References
- https://docs.marimo.io/gettingstarted/keyconcepts/
- https://docs.marimo.io/guides/reactivity/
- https://docs.marimo.io/guides/interactivity/
- https://docs.marimo.io/guides/best_practices/
- https://docs.marimo.io/guides/lint_rules/
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: adrunkhuman
- Source: adrunkhuman/marimo-opencode-skill
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.