Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Notebook ops: nbformat + nbclient + artifacts + papermill

Notebook ops: nbformat + nbclient + artifacts + papermill

Notebooks are just JSON documents. That means you can:

  • generate them (nbformat)
  • execute them programmatically (nbclient)
  • collect outputs and errors
  • parameterize runs (papermill)

Why include this in a LangChain/LangGraph tutorial? Because “agents that write and run notebooks” is a surprisingly practical workflow for data work. We’ll keep the demos safe: everything writes under tmp_runs/.

from pathlib import Path

import nbformat
import nbclient

run_dir = Path("tmp_runs").resolve()
run_dir.mkdir(parents=True, exist_ok=True)

nb = nbformat.v4.new_notebook()
nb.cells = [
    nbformat.v4.new_markdown_cell("# nbclient smoke test"),
    nbformat.v4.new_code_cell("x = 2 + 3\nprint(x)"),
    nbformat.v4.new_code_cell("import math\nprint(math.sqrt(81))"),
]
nbformat.validate(nb)

in_path = run_dir / "smoke_in.ipynb"
out_path = run_dir / "smoke_out.ipynb"
nbformat.write(nb, str(in_path))

nb2 = nbformat.read(str(in_path), as_version=4)
client = nbclient.NotebookClient(
    nb2, resources={"metadata": {"path": str(run_dir)}}, timeout=60
)
client.execute()
nbformat.write(nb2, str(out_path))

str(out_path)

Write a notebook via a tool (from a spec)

We’ll build a tiny notebook in memory (a title + a code cell), then write it to disk.

This is the first building block for “notebook automation” — generating a notebook artifact from a structured spec.

# write_notebook is defined in langchain.API_utils.
spec = {
    "cells": [
        {"type": "markdown", "source": "# Tool-written notebook"},
        {"type": "code", "source": "print('ok')"},
    ]
}
ut.write_notebook.invoke({"spec": spec, "out_rel": "demo/tool_hello.ipynb"})

Notebook ops as tools + secure injected workspace (ToolNode)

Here we treat notebook operations as tools inside a LangGraph workflow.

The important idea:

  • tools can be powerful (file access, execution)
  • so we often want a controlled workspace root

You’ll see us use an injected workspace directory so the graph can safely read/write only where we intend.

from pathlib import Path

import langchain_core.messages
import langgraph.graph
import langgraph.prebuilt

# Create workspace directory for notebook tool operations.
workspace = Path("tmp_runs/ipynb_tools_workspace").resolve()
workspace.mkdir(parents=True, exist_ok=True)

# Assemble ToolNode with all notebook operation tools from utils.
tool_node = langgraph.prebuilt.ToolNode(
    [
        ut.nb_write,
        ut.nb_run,
        ut.nb_extract_errors,
        ut.nb_extract_artifacts,
        ut.nb_list_files,
    ]
)

# Build a simple StateGraph with one tool execution node.
g = langgraph.graph.StateGraph(ut.ToolGraphState)
g.add_node("tools", tool_node)
g.add_edge(langgraph.graph.START, "tools")
g.add_edge("tools", langgraph.graph.END)
graph = g.compile()

print(f"Graph compiled with workspace: {workspace}")
# Define the notebook spec: a simple markdown cell and code cell.
spec = {
    "cells": [
        {"type": "markdown", "source": "# Tool-made notebook"},
        {"type": "code", "source": "print('hello')"},
    ]
}

# Invoke the tool to write the notebook to disk.
out1 = graph.invoke(
    {
        "workspace_dir": str(workspace),
        "messages": [
            langchain_core.messages.AIMessage(
                content="",
                tool_calls=[
                    {
                        "name": "nb_write",
                        "args": {"spec": spec, "out_rel": "demo/in.ipynb"},
                        "id": "t1",
                        "type": "tool_call",
                    },
                ],
            )
        ],
    }
)

print("Notebook written:")
print(out1["messages"][-1].content)
# Execute the written notebook and capture the executed version.
out2 = graph.invoke(
    {
        "workspace_dir": str(workspace),
        "messages": [
            AIMessage(
                content="",
                tool_calls=[
                    {
                        "name": "nb_run",
                        "args": {
                            "in_rel": "demo/in.ipynb",
                            "out_rel": "demo/out.executed.ipynb",
                            "timeout_s": 60,
                        },
                        "id": "t2",
                        "type": "tool_call",
                    },
                ],
            )
        ],
    }
)

print("Notebook executed:")
print(out2["messages"][-1].content)
# List all files created in the workspace to show what the tools produced.
out3 = graph.invoke(
    {
        "workspace_dir": str(workspace),
        "messages": [
            AIMessage(
                content="",
                tool_calls=[
                    {
                        "name": "nb_list_files",
                        "args": {},
                        "id": "t3",
                        "type": "tool_call",
                    },
                ],
            )
        ],
    }
)

# Display summary of tool results.
print("Files in workspace:")
print(out3["messages"][-1].content[:400])

Execute notebooks + collect errors

We’ll execute a notebook programmatically and capture:

  • stdout
  • execution errors (if any)

This is a friendly way to build “run this notebook and report back” pipelines.

from pathlib import Path

import nbformat

# Create directory for notebook execution test.
run_dir = Path("tmp_runs/execute").resolve()
run_dir.mkdir(parents=True, exist_ok=True)

# Build a notebook that intentionally errors to demonstrate error handling.
nb_err = nbformat.v4.new_notebook()
nb_err.cells = [
    nbformat.v4.new_markdown_cell("# Intentional error"),
    nbformat.v4.new_code_cell("print('before')"),
    nbformat.v4.new_code_cell("1/0"),
    nbformat.v4.new_code_cell("print('after')"),
]
nbformat.validate(nb_err)

# Write notebook to disk.
in_path = run_dir / "error_in.ipynb"
nbformat.write(nb_err, str(in_path))

print(f"Notebook with error saved to {in_path}")
import nbclient

# Execute the error notebook with error tolerance enabled.
out_path = run_dir / "error_out.executed.ipynb"
nb = nbformat.read(str(in_path), as_version=4)
client = nbclient.NotebookClient(
    nb,
    timeout=60,
    allow_errors=True,
    resources={"metadata": {"path": str(run_dir)}},
)
client.execute()
nbformat.write(nb, str(out_path))

print(f"Executed notebook saved to {out_path}")

# Extract and display errors from the executed notebook.
errors = ut.extract_errors(nb)
print(f"\nExtracted {len(errors)} errors:")
for err in errors:
    print(f"  - {err}")

Extract artifacts from executed notebooks (stdout + inline images)

Executed notebooks can contain rich outputs (plots, tables, HTML).

We’ll show a simple approach to pull a couple useful artifacts out of the executed notebook:

  • printed output
  • embedded images
from pathlib import Path

import nbformat

# Create notebook with code that produces stdout and inline plot output.
run_dir = Path("tmp_runs/artifacts").resolve()
run_dir.mkdir(parents=True, exist_ok=True)

nb = nbformat.v4.new_notebook()
nb.cells = [
    nbformat.v4.new_markdown_cell("# Artifact notebook"),
    nbformat.v4.new_code_cell("print('hello from stdout')"),
    nbformat.v4.new_code_cell(
        "import matplotlib.pyplot as plt\n"
        "plt.plot([0,1,2],[0,1,4])\n"
        "plt.title('inline')\n"
        "plt.show()\n"
    ),
]

# Write and execute the notebook to generate outputs.
in_nb = run_dir / "artifacts_in.ipynb"
executed_nb = run_dir / "artifacts.executed.ipynb"
nbformat.write(nb, str(in_nb))

import nbclient

nb2 = nbformat.read(str(in_nb), as_version=4)
nbclient.NotebookClient(
    nb2, timeout=120, resources={"metadata": {"path": str(run_dir)}}
).execute()
nbformat.write(nb2, str(executed_nb))

print(f"Executed notebook: {executed_nb}")
import base64
import json

# Extract all output artifacts (stdout, images, text results) from executed notebook.
out_dir = run_dir / "out"
out_dir.mkdir(parents=True, exist_ok=True)
manifest = []

# Iterate over code cells and their outputs to extract artifacts.
for i, cell in enumerate(nb2.cells):
    if cell.get("cell_type") != "code":
        continue
    for j, out in enumerate(cell.get("outputs", [])):
        # Extract stdout/stderr streams.
        if out.get("output_type") == "stream":
            txt = out.get("text", "")
            p = out_dir / f"cell_{i}_stream_{j}.txt"
            p.write_text(txt if isinstance(txt, str) else "".join(txt))
            manifest.append({"cell": i, "kind": "stream", "path": str(p)})
        # Extract display and execution result data (text, images, etc).
        if out.get("output_type") in ("display_data", "execute_result"):
            data = out.get("data", {})
            # Extract plain text representation.
            if "text/plain" in data:
                t = data["text/plain"]
                p = out_dir / f"cell_{i}_text_{j}.txt"
                p.write_text(t if isinstance(t, str) else "".join(t))
                manifest.append(
                    {"cell": i, "kind": "text/plain", "path": str(p)}
                )
            # Extract embedded PNG images.
            if "image/png" in data:
                b64 = data["image/png"]
                b = base64.b64decode(
                    b64 if isinstance(b64, str) else "".join(b64)
                )
                p = out_dir / f"cell_{i}_img_{j}.png"
                p.write_bytes(b)
                manifest.append({"cell": i, "kind": "image/png", "path": str(p)})

print(f"Extracted {len(manifest)} artifacts from {len(nb2.cells)} cells")
# Write manifest JSON file cataloging all extracted artifacts.
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))

# Display summary of artifact extraction results.
result = {
    "executed_nb": str(executed_nb),
    "n_artifacts": len(manifest),
    "manifest": str(out_dir / "manifest.json"),
}
print(f"Artifact summary: {len(manifest)} items extracted")
print(f"Manifest written to: {out_dir / 'manifest.json'}")

Filesystem artifacts (notebooks that write files)

Sometimes notebooks produce real files (CSVs, images, model outputs).

In the next cell we execute a notebook that writes files into a run directory, then list what it produced. Everything stays under tmp_runs/.

# Create a notebook that generates file outputs (CSV and PNG).
run_dir = Path("tmp_runs/writes_files").resolve()
run_dir.mkdir(parents=True, exist_ok=True)

nb = nbformat.v4.new_notebook()
nb.cells = [
    nbformat.v4.new_markdown_cell("# Writes files"),
    nbformat.v4.new_code_cell(
        "import csv\n"
        "import matplotlib.pyplot as plt\n"
        "\n"
        "rows = [(i, i*i) for i in range(5)]\n"
        "with open('table.csv', 'w', newline='') as f:\n"
        "    w = csv.writer(f)\n"
        "    w.writerow(['x','y'])\n"
        "    w.writerows(rows)\n"
        "\n"
        "xs = [r[0] for r in rows]\n"
        "ys = [r[1] for r in rows]\n"
        "plt.plot(xs, ys)\n"
        "plt.title('y=x^2')\n"
        "plt.savefig('plot.png', dpi=120)\n"
        "print('wrote table.csv and plot.png')\n"
    ),
]

in_nb = run_dir / "writes_files.ipynb"
nbformat.write(nb, str(in_nb))

print(f"Notebook created at {in_nb}")
# Execute the notebook to generate file outputs.
out_nb = run_dir / "writes_files.executed.ipynb"
nb2 = nbformat.read(str(in_nb), as_version=4)
NotebookClient(
    nb2, timeout=120, resources={"metadata": {"path": str(run_dir)}}
).execute()
nbformat.write(nb2, str(out_nb))

# List all files produced by the notebook execution.
files = sorted([p.name for p in run_dir.iterdir() if p.is_file()])
print(f"Executed notebook, generated files: {files}")

Parameterized runs (Papermill)

Papermill is a simple way to run the same notebook with different parameters.

This is useful for:

  • experiments
  • scheduled reports
  • batch runs over multiple inputs

We’ll do a tiny demo so you can see the mechanics.

# Create a notebook with a parameters cell for Papermill execution.
run_dir = Path("tmp_runs/papermill").resolve()
run_dir.mkdir(parents=True, exist_ok=True)

nb = nbformat.v4.new_notebook()
nb.cells = [
    nbformat.v4.new_markdown_cell("# Papermill demo"),
    # Tag this cell as "parameters" so Papermill can inject values.
    nbformat.v4.new_code_cell(
        "# Parameters\nx = 1\ny = 2", metadata={"tags": ["parameters"]}
    ),
    nbformat.v4.new_code_cell("print({'x': x, 'y': y, 'x_plus_y': x + y})"),
]
nb.metadata["kernelspec"] = {
    "name": "python3",
    "display_name": "Python 3",
    "language": "python",
}

in_nb = run_dir / "pm_in.ipynb"
nbformat.write(nb, str(in_nb))

print(f"Parametrized notebook created at {in_nb}")
import papermill as pm

# Execute the notebook with parameter injection: x=10, y=32.
out_nb = run_dir / "pm_out.ipynb"
pm.execute_notebook(
    str(in_nb),
    str(out_nb),
    parameters={"x": 10, "y": 32},
    cwd=str(run_dir),
    kernel_name="python3",
)

print(f"Papermill execution complete: {out_nb}")