Kiran Gadhave51 downloadsRun marimo notebooks as live, reactive Python cells inside your notes. Python runs in-browser via Pyodide.
Obsidian plugin that renders marimo cells as live, reactive WASM islands inside notes. Python runs in-browser via Pyodide — no server, no local Python install.
Install "marimo notes" from Obsidian's community plugin directory: Settings → Community plugins → Browse → search "marimo notes", or open the listing.
To install a release by hand instead: download main.js, manifest.json,
and styles.css from the
latest release
into <vault>/.obsidian/plugins/marimo-notes/, then enable "marimo notes"
in Settings → Community plugins.
Write cells in a marimo fence:
```marimo
import marimo as mo
slider = mo.ui.slider(0, 10, value=3)
slider
```
```marimo
slider.value * 2
```
All marimo blocks in one note share a single reactive notebook: moving the
slider reruns the dependent cell below it.
marimo's own notebook-as-markdown format
is also supported — fences like ```python {.marimo} are upgraded to
islands, so a file produced by marimo export md notebook.py renders as a
runnable notebook when opened in Obsidian.
Both fence flavors render in reading view and in live preview. In live preview,
the island replaces the fence while the cursor is outside it. To edit the
source, move the cursor into the block or click the </> button on the
island.
Built on marimo islands, the same runtime behind marimo-anywhere (quarto-marimo, jupyter-book-marimo, mdx-marimo):
<marimo-island data-app-id="note-…" data-reactive="true">
<marimo-cell-output>…</marimo-cell-output>
<marimo-cell-code hidden><!-- URL-encoded Python --></marimo-cell-code>
</marimo-island>
@marimo-team/islands
runtime (pinned in src/main.ts, versioned in lockstep with marimo) is
dynamically imported from jsDelivr on first use, along with its stylesheet.initialize() scans the DOM, stitches all islands with the same
data-app-id into one notebook file, and executes it in a Pyodide web
worker. initialize() is idempotent, so it is re-called (debounced) as
Obsidian renders blocks.Two render paths feed step 1. Reading view uses a markdown post-processor. Live
preview uses a CodeMirror state field (src/live-preview.ts) that replaces each
fence with a block widget, because live preview draws fences as editor text and
the reading-view post-processor never runs there.
Unlike the static-site integrations, no build-time Python step is needed — the runtime executes the embedded code client-side; build-time output is only a hydration nicety.
./scripts/vendor-islands.sh # islands runtime (~29 MB) → vendor/islands
./scripts/vendor-pyodide.sh # Pyodide core + boot wheels (~20 MB) → vendor/pyodide
With both vendored, notebooks using marimo + the Python stdlib run fully
offline; the plugin falls back to jsDelivr when vendor/ is absent. The pyodide
script also patches the vendored worker chunk to honor
globalThis.__PYODIDE_BASE__ / __MARIMO_LOCK__, which the plugin injects
via its worker shim.
src/main.ts):
Electron's process in workers breaks Pyodide's environment detection
(worker shim), CSP blocks remote styles (inlined, titled "marimo-notes" so
shadow roots adopt it), live-preview/reading-view DOM duplication (only the
active note's islands join the live app), and recreated block DOM rebinding
without a kernel restart (data-mo-code + cell index map).light-dark() to the browser.
It rewrites its colors into a polyfill that reuses one variable name across
rules, so a color can read a value that belongs to an unrelated property.
This one is not solved yet.Notebooks reach the vault through the obsidian_marimo module. Import it with
from obsidian_marimo import vault and call it from any marimo cell. Every
call is async.
The vault API needs Obsidian 1.7.2 or later.
from obsidian_marimo import vault
notes = await vault.notes() # all notes sorted by path
notes = await vault.notes(folder="areas/") # prefilter by folder
notes = await vault.notes(tag="#book") # prefilter by tag
files = await vault.files() # all vault files
files = await vault.files(ext="csv") # filter by extension
me = await vault.self() # the note hosting this notebook
await vault.read("path/to/note.md") # fetch one file as text
await vault.read_bytes("image.png") # fetch one file as bytes
texts = await vault.read_many(paths) # fetch many files at once
Each Note object has read-only properties and methods. Properties are:
path: vault path, including the extensionname: file name without the extensionfolder: parent folder path, empty at the vault rootsize: file size in bytesctime, mtime: creation and modification time, in millisecondsfrontmatter: the frontmatter dict, empty when the note has nonetags: inline and frontmatter tags, merged by Obsidian's own rulesheadings: dicts with heading and levellinks: dicts with link, the text as written, and target, the resolved
vault path or Noneunresolved: link texts that point at no filetasks: dicts with done and line, one per checkboxblocks: block ids in the noteMethods on a Note are:
text = await note.read() # fetch the note's current text
await note.write(text) # write the note's full text
await note.set_frontmatter({...}) # merge frontmatter keys
Query the vault graph and all tasks:
await vault.links() # the whole forward link graph
await vault.backlinks("path/to/note.md") # notes that link to this one
await vault.frontmatter("path/to/note.md") # one note's frontmatter dict
await vault.tasks() # all checkboxes in the vault
await vault.tasks(done=True) # only finished checkboxes
await vault.tasks(done=False) # only open checkboxes
df = await vault.frame() # one row per note as DataFrame
frame() needs pandas, which Pyodide does not preload. The first call
installs it into the running kernel, which takes several seconds. A notebook
that never calls frame() never pays that cost. If the install fails, which
happens when you are offline, the call raises VaultError with code
missing_dependency.
To move the cost to notebook startup instead, put import pandas as pd in a
cell. marimo installs the packages it finds in the notebook source while the
kernel boots. It cannot find the import inside frame(), because this module
reaches the kernel as a string.
The DataFrame carries one column per built-in field and one column per
frontmatter key found anywhere in the vault. A frontmatter column is prefixed
with fm_, so a key named path or tags cannot collide with a built-in
column. A note without that key gets None in its row.
A refused write raises VaultError in the cell, with the code and the reason.
A write never fails silently.
await vault.write("path/to/file.md", "# Heading\n...") # create or replace
await vault.write("attachments/plot.png", data) # bytes write binary
await vault.append("out/log.md", "line 1\n") # creates when missing
await vault.set_frontmatter("path/to/note.md", { # merge frontmatter
"status": "done",
"due": vault.UNSET, # remove this key
"note": None, # set to YAML null
})
await vault.trash("path/to/file.md") # move to trash
await vault.exists("path/to/file.md") # test if file exists
The validator runs in the plugin, not in Python, because a notebook author controls the kernel and can post to the plugin directly. These paths are refused, with the code the error carries:
| Reason | Error code |
|---|---|
| Path is empty or not a string | invalid_path |
| Path contains a null byte | invalid_path |
Path is absolute (leading /, \, or drive letter) |
invalid_path |
Path walks out of the vault with .. |
invalid_path |
| Path names a folder | invalid_path |
| The Obsidian configuration directory, whatever its name | denied_config_dir |
| File extension not in the allowlist | denied_extension |
| Write through a symlinked folder, with the setting off | denied_symlink |
| The note that hosts this notebook | denied_self_write |
File missing, for trash or set_frontmatter |
not_found |
A write is limited to these file types: md, csv, json, txt, png,
jpg, jpeg, gif, webp, svg, parquet.
A symlinked folder inside the vault is a door out of it. The plugin resolves the nearest existing ancestor of the target and requires it to stay inside the vault.
The setting "Allow writes through symlinked folders" turns this check off. It ships disabled. Turn it on only when you put a symlink in your vault on purpose and you trust what it points at. The setting changes writes only. It never affects reading, and it never changes the symlink itself.
The check does nothing on mobile, where Node is absent. The exposure is small, because the iOS and Android app sandbox cannot hold a symlink.
Validation answers immediately. Only the filesystem write waits, for 500 milliseconds, in one window per path. A write to one note never waits behind a write to another.
Inside a window:
write() and set_frontmatter() coalesce. A newer whole-file value
replaces the older one, and two frontmatter edits merge.append() accumulates. Later text concatenates onto earlier text, because
dropping an append loses data.The promise resolves when the write lands, not when the call is accepted. An early resolve would let a kernel restart inside the window lose a write your notebook already treated as durable.
A text write whose content matches the file is skipped. No vault event fires, so the note does not re-render, and any write loop reaches a fixed point.
One notebook runs at a time, and the active note owns it. When you focus a different marimo note, the plugin stops the running notebook and starts the new one.
Here is what you see. The note you leave keeps its last output on screen. That output is a picture, not a running notebook. Its Python state is gone, and a slider in it no longer does anything. Focus it again and it runs from the start.
A note that is visible but not active stays static too. In a split view with two marimo notes, only the active pane runs.
The plugin batches vault changes and pushes them to the notebook every 500
milliseconds. Python drops its cached notes(), files(), and links()
results when a batch arrives, so the next call returns fresh data without a
kernel restart.
A cell does not re-run on its own when the vault changes. To follow the vault,
put mo.ui.refresh in one cell and read it in another. The second cell
depends on the first, so it re-runs on every tick.
refresh = mo.ui.refresh(default_interval="5s")
refresh
refresh
notes = await vault.notes()
mo.md(f"{len(notes)} notes")
Save a figure as bytes, write it to the vault, then write a note that embeds it.
import io
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 9])
buf = io.BytesIO()
fig.savefig(buf, format="png")
await vault.write("attachments/plot.png", buf.getvalue())
await vault.write("out/chart.md", "# Chart\n\n![[plot.png]]")
Obsidian renders the image when you open out/chart.md.
Markdown is executable in many vaults. A note that holds a Dataview JS query or a Templater trigger runs JavaScript in the renderer when you open it.
The validator cannot block this without blocking .md, and writing markdown
is the point of the API. So a hostile notebook can write a note that runs code
under the settings of those plugins.
Give a notebook from someone else the same trust you give a script from someone else. Read it before you run it.
pnpm install
pnpm dev # watch build
pnpm build # type-check + production build
pnpm vault # scaffold + open the demo vault (examples/demo-vault) in Obsidian
pnpm vault symlinks the plugin into the test vault and opens it via the
obsidian:// URL scheme; on first open, choose "Trust author and enable
plugins". For any other vault, symlink this folder into
<vault>/.obsidian/plugins/marimo-notes/ and enable "marimo notes" in
Community plugins.
The "Reinitialize notebooks in open notes" command forces a re-scan.
Release prep lands through a normal PR. The workflow never pushes to main.
node scripts/pin-islands.mjs — pin the islands runtime to the latest
marimo release.node scripts/version-bump.mjs <x.y.z> — set the version in
package.json, manifest.json, and versions.json.main
with the manifest version, and creates a draft GitHub release with
main.js, manifest.json, and styles.css.