You change a number near the top of a notebook, and then the chore starts. Find every cell below that used it, work out which of those feed each other, and run them in that order without missing one. Most of us just run everything from the top, which is fine until one cell takes four minutes.
The DAG Notebook layout does that part for you: it reads the notebook, works out which cells feed which, and when a cell finishes running it runs that cell's dependents, in dependency order. It shipped with Verso 1.2 as Verso.Showcase.DagNotebook, an ordinary package on NuGet.
What I like about it is where it lives. Reactive execution is normally a property of the notebook tool you picked. Here it is a layout extension sitting on the same public interfaces as everything else, which is what treating every feature as an extension buys you.
So let me build one in front of you: a week of forecast from a public API, an ipywidgets slider, one #!bind line, and two C# cells that follow the slider on their own.
Getting the layout in front of you
This one is an inline layout, so your cells stay the real, editable cells and the layout supplies the frame around them. Install the command line tool and open a notebook.
dotnet tool install -g Verso.Cli
verso serve weather.verso
A notebook can also ask for the extension itself. Two entries in its metadata block name the package and pin the layout, so the host installs it from NuGet and opens straight into it.
"activeLayout": { "extensionId": "com.verso.showcase.dag-notebook", "layoutId": "dag-notebook" },
"extensions": { "required": ["Verso.Showcase.DagNotebook"] }
Otherwise install it from the Extensions pane and pick DAG Notebook from the layout picker. Approving an extension is per notebook and pinned to the version you approved. Everything below works the same in VS Code and in the browser host verso serve starts.
The data
The forecast comes from the Open-Meteo forecast API, which needs no key. This is the request the weather briefing notebook in the Verso gallery makes.
using System.Net.Http;
using System.Text.Json;
var url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=47.6062&longitude=-122.3321"
+ "&daily=temperature_2m_max,temperature_2m_min"
+ "&temperature_unit=fahrenheit&timezone=America%2FLos_Angeles&forecast_days=7";
var json = await new HttpClient().GetStringAsync(url);
var daily = JsonDocument.Parse(json).RootElement.GetProperty("daily");
var days = daily.GetProperty("time").EnumerateArray()
.Select(d => DateTime.Parse(d.GetString()!).ToString("ddd")).ToArray();
var highs = daily.GetProperty("temperature_2m_max").EnumerateArray()
.Select(t => t.GetDouble()).ToArray();
var lows = daily.GetProperty("temperature_2m_min").EnumerateArray()
.Select(t => t.GetDouble()).ToArray();
Console.WriteLine($"{days.Length} days for Seattle, highs {highs.Min():F1} to {highs.Max():F1}°F.");
7 days for Seattle, highs 67.8 to 85.2°F.
A forecast is live data, so your run gives a different week. Every number here comes from one response, saved inside that gallery notebook from a call made on 2026-07-04, so the figures are checkable rather than asserted.
| Day | Date | High °F | Low °F |
|---|---|---|---|
| Sat | 2026-07-04 | 67.8 | 55.5 |
| Sun | 2026-07-05 | 71.9 | 52.5 |
| Mon | 2026-07-06 | 80.2 | 52.5 |
| Tue | 2026-07-07 | 85.2 | 51.9 |
| Wed | 2026-07-08 | 68.6 | 57.8 |
| Thu | 2026-07-09 | 75.3 | 54.1 |
| Fri | 2026-07-10 | 80.0 | 50.2 |
An HTTP cell would fetch the same thing in two lines of .http syntax, and I did not use one. The dependency scan reads C#, Python, and F# source, and a variable an HTTP cell publishes is not an assignment anywhere in that source, so the scan cannot see it. Fetching in C# keeps the whole notebook inside the graph.
The slider
Two Python cells. The first builds the slider, the second shares it.
#!pip ipywidgets
import ipywidgets as widgets
slider = widgets.IntSlider(value=70, min=40, max=100,
description="Threshold", continuous_update=False)
slider
The #!pip line is there for a notebook that has to run anywhere. In the editor you can leave it out, because the default install policy offers to install what a cell imports and cannot find. On the command line it does real work: verso run never installs on import, and --auto-install covers only the distributions Verso keeps a mapping for.
continuous_update=False holds the value until you let go of the handle. You do not need it, because even with continuous updates the cascade runs once per gesture.
The next cell is one line, and it is the line that turns the notebook on.
#!bind slider.value as threshold
'threshold' now follows slider.value. Any kernel can read it, and writing it moves the widget.
From here threshold is an ordinary entry in the shared variable store, which every kernel can read from and write to.
The object has to exist already
A magic command runs before the rest of its own cell, so #!bind has to name a widget an earlier cell built. Bind in the same cell that creates the widget and it finds nothing, and tells you so. That is why this is two cells and not one.
The trait is an Int, so other kernels see a long. A Float arrives as a double, a Unicode as a string, and a Dict as a dictionary keyed by string. A trait holding another widget is refused, and so is a value over the megabyte a projected value may occupy, because a projection crosses on every change and a dragged control changes many times a second.
The two cells that read it
Two C# cells, written the way you would write any cell that reads a variable. Neither knows a widget exists.
var cutoff = Variables.Get<long>("threshold");
var warmDays = days.Zip(highs, (d, h) => (Day: d, High: h))
.Where(x => x.High >= cutoff)
.ToArray();
Console.WriteLine($"{warmDays.Length} of {days.Length} days at or above {cutoff}°F.");
5 of 7 days at or above 70°F.
var atOrAbove = Variables.Get<long>("threshold");
var listed = warmDays.Length == 0 ? "none" : string.Join(", ", warmDays.Select(w => w.Day));
$"{atOrAbove}°F and above: {listed}."
70°F and above: Sun, Mon, Tue, Thu, Fri.
That is the whole notebook: five code cells, no prose cells in between, so the numbers the layout draws are the numbers you just saw.
What the layout drew
Every linked cell gets a row of chips above it. ↑ 1 days means this cell reads days from cell 1. ↓ 5 warmDays means cell 5 reads warmDays from this one. ↻ threshold marks a variable that follows a control instead of a computation. Chips are numbered by document position but tracked by cell identity, so they survive a reorder, and clicking one scrolls you to the other end of the link.
Put every chip together and you have the graph. Cell 1 feeds cell 4 twice, once for days and once for highs. Cell 2 feeds cell 3, because the bind directive names the object it binds. Cell 3 produces threshold, which both C# cells read. Cell 4 feeds cell 5. lows gets parsed and read by nobody, so it gets no chip.
Moving the dial
Press Run DAG once. That button lives in the layout's own header and runs every cell in dependency order rather than document order. The host's Run All is untouched and keeps its document-order behavior, and the two coexist on purpose.
Then drag the slider and let go. Cells 4 and 5 re-run on their own, in that order, with nothing clicked. Cell 4 prints its count, cell 5 names the days.
| Threshold | Warm days | Cell 5 |
|---|---|---|
| 60 | 7 | Sat, Sun, Mon, Tue, Wed, Thu, Fri |
| 70 | 5 | Sun, Mon, Tue, Thu, Fri |
| 80 | 3 | Mon, Tue, Fri |
| 85 | 1 | Tue |
| 90 | 0 | none |
Cell 1 never runs again. It does not depend on threshold, so no forecast is fetched a second time, which is the difference between this and Run All.
The trigger is a completed run, not a keystroke. When a cell finishes successfully, the layout runs that cell's transitive dependents one at a time, in topological order, through the same notebook operations you would drive by hand. Strictly sequential is the point: a dependent must not start before its producer has finished.
A drag is one run, not one per step. The cascade waits before it starts, and a change that arrives inside that window replaces the one before it. The sample's own constants are 300 milliseconds after a completed run and 450 after a moved control. The longer one is deliberate, because a dragged slider writes its variable many times a second and each write cancels the trigger the last one scheduled. That window also keeps a batch run honest: any cell starting inside it cancels the pending trigger, so a document-order Run All finishes on its own terms.
Turn Auto-run dependents off and drag again: cells 4 and 5 draw a dashed border and wait. The mark shows up the moment a producer starts running and clears when the cell catches up. The toggle round-trips through the layout's metadata, so a notebook saved with it off stays off.
How the graph is built
This is the part I would read twice, because it holds the honest limits.
On every render the layout scans each code cell for the variables it defines, meaning assignments, functions, and type declarations, and the names it references, including names inside interpolated strings. A variable with exactly one defining cell links that producer to every cell that reads it. The scan covers C#, Python, and F#. The sample's own README calls it a lightweight heuristic pass and a strong hint rather than a proof, which is the right way to hold it: it reads source text, it does not compile it.
Two of the ways a cell writes a variable are not assignments at all. #!bind shares a widget's trait, and Variables.Get("name") and Variables.Set("name", ...) reach the shared store by a name that lives inside a string literal. Both get read before comments and string literals are stripped away, and that one decision is what lets a Python control link to a C# cell instead of the graph stopping dead at each language boundary.
Cell 3 holds nothing but #!bind slider.value as threshold. To Python that line is a comment. To the scan it is a cell that reads slider and writes threshold, which is the pair of edges the graph needs.
When it refuses to cascade
When the layout is not sure it says so rather than guessing, and that matters more to me than the happy path.
| Action | Auto-run on | Auto-run off |
|---|---|---|
| Run a producer | its transitive dependents run one at a time, in dependency order | they draw a dashed border and wait |
| Drag the slider, let go | the same, from the cell that bound the trait | the same border, nothing runs |
| A cascaded cell fails | the cascade stops there, later cells keep their stale mark, the failed cell gets an error border | nothing was running |
| Two cells assign one name | a warning chip on every writer and no edges at all | the same chips, the same missing edges |
| Cells form a cycle | flagged, and the cycle's own edges excluded, so a cascade cannot loop | the same flags |
The failure row is the one to plan around. A cascade is not a transaction: if cell 4 throws, cell 5 keeps its stale marker and its old output, and the notebook is left honestly half updated.
The multi-writer row is easy to trip over. Give a variable the same name in two cells and both get a warning chip and the links vanish. The pricing sample that ships with the layout assigns counter twice on purpose so you can watch it happen.
The link runs both ways
Writing the variable from a cell moves the slider on the page.
Variables.Set("threshold", 80L);
The control moves, the author's observe callbacks fire, and every other kernel sees 80.
That symmetry is what makes a cell writing the variable it reads safe. A control driving a cell that writes the same name back is a loop the scan cannot see, because the write happens at run time and not in the source. What settles it is that a fresh reading of every bound variable is taken each time a cell completes.
#!bind --list tells you what is currently shared:
Widget traits shared as variables:
threshold <- slider.value
#!bind --remove threshold stops the two following each other without deleting anything, so a cell reading the name keeps working on its last value. A kernel restart does the same, and re-running the #!bind line reconnects it.
Where the limits are
| Limit | Value | What happens |
|---|---|---|
| One widget's saved page | 4 MB | the cell shows a message instead of the widget |
| One message between a widget and its kernel | 8 MB | refused with a diagnostic naming the widget, and the session continues |
| One projected value | 1 MB | the bind is refused, or a later change keeps the value that crossed before it |
A widget's state travels in the notebook file, but the JavaScript that draws it comes from a public CDN when the widget is shown, so a machine with no network draws an empty frame. A saved file holds the state the reader last saw, not the state the cell drew: drag the slider to 98, save, and the file says 98.
Widgets are live in the editor, whether you served the notebook with verso serve or opened it in VS Code. A notebook run by verso run has no view to talk to, so there the slider draws from its saved state and the C# cells read whatever that state holds. That is right for a scheduled job, and it means one file is both an instrument you play at your desk and a script that produces one answer in a pipeline. If the pipeline should pick the number, use notebook parameters rather than the slider.
What I take from this
Five cells, two languages, one variable, and a package. The reactive behavior is not in the engine, not in the file format, and not in the editor. It is in a layout extension that references Verso.Abstractions and nothing else, bundles no third-party libraries, and is MIT licensed like everything around it. If reactive execution can arrive as a package, so can whatever else you were going to ask us to build in.
Both samples, dag-notebook.verso and dag-notebook-widget.verso, sit in the sample folder on GitHub with the layout's source. The first seeds a small pricing model, where two base inputs feed revenue, revenue feeds profit, and a summary cell reads all three. The Interactive Widgets guide covers #!bind and the projected types, and the layout authoring guide is where to start on one of your own. If you build one, I would like to hear where it got awkward.