Bring a freshly cloned catalog to a running marimo kernel, then hand off to composition.
Steps
-
Read the catalog manifest. Read
catalog.tomlat the repo root forfirst_notebook,auth(any required env var), and the catalog name. If there is nocatalog.toml, fall back to the lowest-numbered notebook innotebooks/and checkREADME.mdfor auth. -
Check the runtime. Confirm
uvis installed (uv --version); if missing, install it:curl -LsSf https://astral.sh/uv/install.sh | sh -
Install the marimo-pair skill for the user's agent (it is how you drive a live kernel):
npx skills add marimo-team/marimo-pair -y
The skills CLI auto-detects common agents; pass --agent <agent> only when the project needs an explicit target.
Skip the add if marimo-pair is already installed - check the agent's skill stores first (e.g. ~/.claude/skills/marimo-pair or the repo's .claude/skills/).
-
Check auth if the manifest declares it. Auth is required when
[auth].env_varor[auth].indirect_env_varis non-empty; if both are empty (or there is no[auth]), the catalog is public - skip this step. Confirm the token is obtainable, not merely that some variable is set. Passing either probe satisfies auth: probeenv_varfirst, and fall back toindirect_env_var.- Direct token (
env_var, sayCATALOG_TOKEN): passes if$CATALOG_TOKENis non-empty in your shell, or if a.envat the repo root contains the key - notebooks commonly load.envin-process (dotenv), so a working token may never appear in the shell environment. A value that lives only in a direnv.envrcis invisible to a non-interactive shell - probe it underdirenv exec .instead. - Indirect reference (
indirect_env_var, sayCATALOG_OP_REF): the named var holds a secret-manager reference the catalog resolves at runtime (e.g. anop://item read via the 1Password CLI). Resolve the var's value - the reference string, which may itself arrive via a direnv.envrcor shell profile - once end-to-end:op read "$CATALOG_OP_REF" >/dev/null && echo ok. If the var comes from a direnv.envrc, run the probe asdirenv exec . op read ...(non-interactive shells never load direnv) and obtain the value to thread in step 5 the same way. Traps here: sandboxed agent shells block the IPC secret-manager CLIs use to reach their desktop app - run the probe unsandboxed from the start, and do not infer the cause from the error or its latency (a sandboxed failure can look identical to a locked app, e.g.op's own ~60sauthorization timeout). Resolution may pop an interactive unlock/biometric prompt on the user's screen - warn them one may appear; the CLI bounds the wait itself (op: ~60s per attempt). If timeouts persist across unsandboxed retries, the desktop app itself may be wedged (stuck post-auto-update, or a stale secret-manager daemon predating the running app) - have the user restart the app, then re-probe.
If no probe passes, stop: tell the user how to unblock (unlock the secret manager, or where to get a token - point to the catalog's README) and hand them the exact probe command so they can verify it themselves before you retry. Do not proceed on an unproven token.
- Direct token (
-
Launch the first notebook in a sandbox, in the background, and remember the port. If step 4 required auth, the launch must carry the var you probed - direnv only hooks interactive shells, so a var exported by
.envrcis invisible to the non-interactive shell an agent launches from; do not assume inheritance. Which var, and how:- Indirect auth: thread the reference var into the
envprefix (CATALOG_OP_REF="$CATALOG_OP_REF") - the notebook resolves it in-process, and the reference string is not the secret, so inlining it is safe. Do NOT resolve the secret yourself and thread the token. - Direct token in the shell:
exportit and launch in the same shell invocation - env does not persist across an agent's separate shell calls, so an export in one call is silently gone by the next. Never inline the raw secret on the command line - it leaks into logs and the process table. - Token only in
.env: thread nothing; the notebook loads.envfrom the repo root at runtime.
PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1])") MARIMO_LOG="${TMPDIR:-/tmp}/marimo-$PORT.log" echo "marimo port: $PORT" echo "marimo log: $MARIMO_LOG" ulimit -n "$(ulimit -Hn)" 2>/dev/null || true # see note below: avoids os error 24 on shared Linux hosts # auth catalogs: extend the env prefix per above, e.g. env -u PYTHONPATH CATALOG_OP_REF="$CATALOG_OP_REF" uvx ... env -u PYTHONPATH uvx marimo edit --sandbox --no-token --headless --port "$PORT" notebooks/<first_notebook> > "$MARIMO_LOG" 2>&1 & MARIMO_PID=$! echo "marimo pid: $MARIMO_PID" for _ in $(seq 1 60); do curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break sleep 1 done curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null--sandboxis required so the notebook's PEP 723 dependencies are provisioned.env -u PYTHONPATHavoids a Nix-shell websockets shim that crashes startup. Theulimitline raises the open-file soft limit to the hard limit before provisioning: on a busy shared Linux host uv's parallel bytecode-compile can exceed a low soft limit (1024 on stock Ubuntu) and die withToo many open files (os error 24)(astral-sh/uv#16999). Clamping to$(ulimit -Hn)never exceeds the hard cap, so it is a no-op where the limit is already high (macOS) and harmless everywhere. The trailing&is load-bearing: the marimo server must keep running while the setup continues. Keep$PORT- the next step needs it. Shell state does not survive between an agent's separate shell calls - that applies to$PORT,$MARIMO_PID, and later$SESSION_IDjust as much as to exported tokens; the snippet echoes them so you can re-inline the literal values in later calls. The launch line also assumes the repo root as cwd (notebooks/<first_notebook>).Whether to pass
--headlessdepends on who is composing:- A human is pairing and a browser is available. Remove
--headless. marimo auto-opens the browser; that frontend connection both gives the user a live notebook to look at and registers the kernel session that step 6 needs. Skip to step 6. - You are an agent, or there is no browser (a remote/SSH host, a CI box, an agent-driven
run). Keep
--headlessand register the session yourself in the next step - nothing connects to the websocket on its own, so the server comes up with no session and the firstexecute-code.shcall would fail withNo active sessions on the server. Make sure a notebook is open in the browser.
- Indirect auth: thread the reference var into the
-
Ensure a kernel session exists (headless launch only - skip if a browser already opened). marimo creates a kernel session only when a frontend connects to its websocket. With no browser, register one yourself:
-
A browser exists on the host but did not auto-open (you launched
--headlesson a desktop): open the URL for the user and let their browser register the session -open "http://localhost:$PORT"on macOS,xdg-open "http://localhost:$PORT"on Linux. -
Truly headless (no browser at all): run the bundled stand-in, which connects to the websocket the way the frontend does, waits for the kernel to report ready, and prints the session id:
SESSION_ID=$(<vignette-catalog-setup-skill-dir>/scripts/register-session.py --port $PORT) echo "registered marimo session: $SESSION_ID"
In
marimo editmode the session persists after the helper exits, so this one-shot call is enough - keep$SESSION_IDand target it withexecute-code.sh --url "http://127.0.0.1:$PORT"(or--session $SESSION_ID). The helper takes--token/MARIMO_TOKENfor token-protected servers, and--holdfor the rare server that drops the session on disconnect (RUN mode or--session-ttl); plainmarimo editneeds neither. Confirm withcurl -s http://127.0.0.1:$PORT/api/sessionsthat a session is now listed. -
-
Run every cell, then confirm the kernel is populated. marimo only auto-runs cells when a browser frontend connects, so a freshly launched kernel can sit with all cells stale and every notebook variable undefined. Do not rely on the browser - run the cells explicitly through the marimo-pair
execute-code.shscript you installed in step 3, targeting--url "http://127.0.0.1:$PORT":bash <marimo-pair-skill-dir>/scripts/execute-code.sh --url "http://127.0.0.1:$PORT" <<'EOF' import marimo._code_mode as cm async with cm.get_context() as ctx: for cell in ctx.cells: ctx.run_cell(cell.id) EOFThis uses marimo-pair's documented code-mode path inside the running kernel; it is not local Python. A failed cell surfaces as a per-cell error in the script's output, not as a nonzero exit - read the output, don't trust the exit code. The loop is idempotent: if a cell errored transiently (auth still settling, a slow first fetch), re-run the same block. Then spot-check that the kernel is genuinely usable before handing off - prefer one cheap call that exercises the full chain (an authenticated request through a catalog helper, or the shape of the notebook's main table) over merely printing a variable. An empty, unexecuted kernel and a half-proven auth path are the two most common "it didn't work"; this is the only step that proves the whole chain end-to-end.
-
Hand off. Tell the user the kernel is running on
$PORT(and, if you registered one, that a session is live - the notebook is open in their browser when a browser was used) and that they can now ask a question - thevignette-catalog-compose-notebookskill takes it from here. This kernel runs the first notebook in its own--sandboxand is bound to that notebook. Hand off the port, but do not promise it serves every notebook: composing a new notebook gets its own--sandboxkernel (a relaunch of step 5 on that notebook), whichvignette-catalog-compose-notebookhandles.
Stopping a kernel
When you need to tear down a kernel you launched (end of an agent run, or before relaunching on
another notebook), stop the one you started - by its $PORT, never with a broad pkill.
uvx marimo edit --sandbox is a 4-process tree (uvx -> python -> uv run -> python), so killing
the recorded $MARIMO_PID alone orphans the uv run grandchild that actually serves the socket;
and on a shared host pkill -f marimo kills other users' kernels and your own shell wrapper. Match
the listener on the port and kill its whole process group:
PGID=$(ps -o pgid= -p "$(lsof -ti tcp:$PORT -s tcp:LISTEN | head -1)" 2>/dev/null | tr -d ' ')
[ -n "$PGID" ] && kill -TERM -"$PGID"
sleep 3 # TERM is async - an immediate health check races it and reads "still up" spuriously
curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && echo "still up - retry with kill -9 -$PGID" || echo "kernel on $PORT stopped"
This is why step 5 records $MARIMO_PID and $PORT: keep them for the run so teardown targets
exactly your kernel and nothing else.
Notes
- Public catalogs often need no auth; private or authenticated catalogs declare their required env var in
catalog.toml. - On harnesses that sandbox shell commands, the step-4 secret probe is not the only step needing an unsandboxed run: the launch (binds and serves localhost, backgrounds a server),
register-session.py(websocket),execute-code.sh, and thelsofteardown all need real localhost networking and process control. If any of them fails oddly under a sandbox, rerun unsandboxed before debugging anything else. - Do not improvise alternative launch commands; the
--sandboxflag is what makes per-notebook dependencies work. - Do not improvise the headless session bootstrap either - the
scripts/register-session.pystand-in is the supported way to register a kernel session without a browser. It exists so each agent does not re-derive a websocket client from scratch. Run it as an executable (the bare path in step 6) - its shebang provisionswebsocketsviauv run. Invoking it aspython3 register-session.pyskips that and fails withModuleNotFoundError: websockets; if a checkout dropped the executable bit,chmod +xit rather than reaching forpython3. execute-code.shalso accepts--port "$PORT", which goes through registry discovery - but discovery is occasionally flaky (especially with several servers up) and then reportsNo running marimo instances foundwhile the server is healthy.--url "http://127.0.0.1:$PORT"(as used in step 7) skips discovery entirely; it is the default here because it is the reliable path for an agent.- If the repo gitignores its skill stores (tracks only
skills-lock.json) and the catalog skills look missing or stale, restore them - but not withnpx skills update.updateonly refreshes agent stores that already exist on disk and has no--agentflag (only-g/-p/-y): on a fresh clone it materializes the Universal store (.agents/) but not an agent-specific one like Claude Code's.claude/skills/, so the skills can look installed while the agent sees nothing. The only command that targets a specific store isnpx skills add <source> --agent <agent> -y(e.g.--agent claude-code; comma-separate or*for several). Once that store exists,updatewill maintain it. Either way this is a refresh once you can run a command in the repo - it is not the clone-time bootstrap: a cloner who has no catalog skills on disk cannot reach this skill to run that step, so the post-clone restore lives in the repo's trackedAGENTS.md/README.md, not here, since a skill cannot bootstrap its own install.