Sooner or later a notebook that produces a number somebody cares about has to run on a schedule, with different inputs, while nobody is watching. The usual answer is to rewrite it as a script, and from that day on you are maintaining two things that slowly drift apart.
I did not want to make that trade, so Verso goes the other way. The same file runs at your desk and in a pipeline, the inputs are declared once inside the file, and the command line supplies them. This guide takes a notebook from a form in the editor to a scheduled GitHub Actions job that turns the build red when a cell fails.
Before you start
You need the .NET 8.0 SDK or later, the Verso command line tool, and a notebook to run. Any .verso, .ipynb, or .dib file will do.
dotnet tool install -g Verso.Cli
verso info
verso info prints the CLI version, the .NET runtime, the engine version, and every extension, serializer, and formatter it discovered. Run it once now so you know what a healthy environment looks like, because you will want to compare against it later. The built-in kernels travel with the tool, so there is nothing else to install on the agent.
Step 1: declare the inputs
Parameters live in the notebook's metadata. Each one gets a type, and if you want it, a description, a default, a required flag, and a place in the ordering.
{
"verso": "1.0",
"metadata": {
"title": "Regional Sales Report",
"defaultKernel": "csharp",
"parameters": {
"region": { "type": "string", "description": "Region to process",
"required": true, "order": 1 },
"reportDate": { "type": "date", "description": "Report date",
"required": true, "order": 2 },
"batchSize": { "type": "int", "default": 1000, "order": 3 },
"dryRun": { "type": "bool", "default": false, "order": 4 }
}
}
}
You do not have to write that by hand. Insert a parameters cell in the editor and you get a form for the name, type, and default, and it writes the same metadata into the file for you.
There are six types, and each one is coerced to a CLR type before any cell runs.
| Declared type | CLR type | Format | Example |
|---|---|---|---|
string |
string |
any text | us-east |
int |
long |
integer | 1000 |
float |
double |
decimal number | 0.95 |
bool |
bool |
true/false/yes/no/1/0 |
false |
date |
DateOnly |
yyyy-MM-dd, strict |
2026-04-08 |
datetime |
DateTimeOffset |
ISO 8601, UTC when no offset | 2026-04-08T08:00:00Z |
Step 2: read them from any cell
Parameters go into the shared variable store before the first cell executes. That means they are ordinary variables in every kernel, not something a Python cell has to be handed on its way in. One variable store, eight languages covers how that store works.
In C# they are top-level variables, already the right type:
var cutoff = reportDate.AddDays(-7);
Console.WriteLine($"{region}: {cutoff:yyyy-MM-dd} to {reportDate:yyyy-MM-dd}, batch {batchSize}");
In a SQL cell the same names come through as bindings:
SELECT product, SUM(amount) AS total
FROM sales
WHERE region = @region AND sale_date = @reportDate
GROUP BY product
In a Python cell they are just names. One declaration, three languages reading it.
Step 3: run it from a terminal
Now leave the editor and run the same file from a shell.
verso run pipeline.verso \
--param region=us-east \
--param reportDate=2026-04-08
--param is repeatable and uses an equals sign. Values are parsed against the declared type before anything executes, so a bad date is an error you see right away instead of a crash forty seconds in.
What comes back is the cells, in order, then a summary:
─── Cell 2 (csharp) ──────────────────────
us-east: 2026-04-01 to 2026-04-08, batch 1000
─── Summary ──────────────────────────────
Cells: 5 total, 5 succeeded, 0 failed
Time: 3.4s
Two flags are worth knowing here. --show-parameters prints the resolved values alongside the cells, which is what a pipeline log needs when someone asks a month later what the nightly run actually used. --interactive prompts on standard input for any required parameter you left out, which is what you want at your desk.
Step 4: let the exit code decide
verso run streams to the terminal and returns a specific code. Nothing is written back to the notebook unless you pass --save, so a scheduled run cannot quietly rewrite the file in your repository.
| Code | Meaning |
|---|---|
| 0 | Every executed cell succeeded |
| 1 | One or more cells failed, or a fatal error outside a cell |
| 2 | Execution timed out |
| 3 | Notebook file not found or unreadable |
| 4 | Serialization error, meaning an invalid notebook format |
| 5 | Missing required notebook parameters |
Code 5 is the one that saves you time. A required parameter with no value stops the run before a single cell executes and tells you what is missing:
Error: Missing required notebook parameters:
region (string) Region to process
reportDate (date) Report date
Supply values with --param or use --interactive to be prompted.
By default a run keeps going past a failed cell, so one pass shows you every failure instead of only the first. --fail-fast stops at the first one. --timeout caps the whole run and defaults to 300 seconds, which is short for a report that reads a database, so pick that number on purpose rather than finding out at 6am.
Step 5: the workflow file
Here is a complete scheduled job: install the tool, run the notebook, write machine-readable results, and keep them whether the run passed or not.
name: Nightly report
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Install the Verso CLI
run: dotnet tool install -g Verso.Cli
- name: Run the notebook
run: |
verso run reports/regional.verso \
--param region=us-east \
--param reportDate=$(date -u +%Y-%m-%d) \
--show-parameters \
--output json \
--output-file results.json \
--fail-fast \
--timeout 900
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
- name: Keep the results
if: always()
uses: actions/upload-artifact@v4
with:
name: regional-results
path: results.json
--output json writes the notebook path, one entry per cell carrying its index, id, language, status, elapsed time and outputs, the resolved parameters, and a summary of totals. Parse that when a later step needs to know which cell failed.
Notice what is not a --param. Command-line arguments show up in process listings, so a password belongs in the step's env: block and gets read from the connection string:
#!sql-connect --name prod --connection-string "Server=$var:dbServer;Database=Analytics;User Id=svc_report;Password=$env:DB_PASSWORD"
$var: reads the variable store, so it picks up a parameter. $env: reads the process environment. Neither value reaches the command line.
Step 6: one notebook, four regions
Once the inputs are on the command line, a matrix runs the same file several ways:
strategy:
fail-fast: false
matrix:
region: [us-east, us-west, eu-central, ap-south]
verso run reports/regional.verso \
--param region=${{ matrix.region }} \
--output json --output-file results-${{ matrix.region }}.json
fail-fast: false keeps the other legs running when one of them fails, so a bad region does not hide the state of the rest.
Coming from Papermill
If you already run parameterized notebooks with Papermill, the shapes line up and the mechanics differ. Here is the mapping.
| Papermill | Verso |
|---|---|
A cell tagged parameters holding assignments |
metadata.parameters with typed definitions |
papermill in.ipynb out.ipynb |
verso run notebook.verso |
-p name value |
--param name=value |
An injected-parameters cell added after the tagged cell |
Values coerced into the shared variable store before cell one |
| An executed copy of the notebook at the output path | Streamed output, --output json, or --save |
The difference that matters in a pipeline is when the validation happens. Papermill's defaults are Python assignments in a cell. Verso's are declared types, so --param batchSize=abc fails with a parse error and a missing required name fails with exit code 5, both before any cell runs.
The full mapping, including Azure DevOps snippets, is in the Papermill migration guide.
When the run passes locally and fails on the agent
The usual cause is a kernel or an extension the agent does not have. Run verso info as a step before the notebook and it will list what was discovered in that environment. If the notebook needs an extension you build alongside it, point the run at the build output with --extensions ./MyExtension/bin/Release/net8.0/, a flag that run, serve, and convert all accept.
The notebook has not changed through any of this. It is the same file you opened in the editor, with a form at the top, and the pipeline is just another caller. Set one up against a report you already have, and tell me where it falls over.