VersoHow-to

Run the same notebook in CI that you run at your desk

Declare your inputs once, hand them in with --param, and let the exit code decide whether the build goes red.

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
regional.verso 4 parameters verso run --param region=us-east exit 0 verso run --param region=us-west exit 0 verso run --param region=eu-central exit 1 verso run --param region=ap-south exit 0
One file, four runs, four exit codes. Only the failing leg turns the job red.

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.