This one is for the case where you want the part of Verso that runs the code, without the notebook wrapped around it. The engine is a headless .NET library. It has no UI dependencies, no Blazor, and no knowledge of VS Code, and the editor, the command line tool, and the VS Code host are all consumers of the same public API. That means anything they do is available to your own application: running C# or Python from a string, sharing state between languages, loading an extension somebody else wrote.
Fair warning, this is an advanced scenario. If what you want is a notebook, use the notebook. This guide is for the case where a notebook runtime belongs inside something else: a rules engine that has to evaluate user-authored expressions, an internal tool that reports on data using code its users write, a test harness that executes documented examples.
Before you start
You need .NET 8 or later and one package:
dotnet add package Verso
That is the engine, and it is not the editor. The Razor components live in a separate assembly and none of them come along for the ride.
The parts
Scaffold is the session. It owns the in-memory notebook model, the kernel registry, execution dispatch, and the theme, layout, and settings subsystems. ExtensionHost finds and loads extensions, and that is where the kernels come from in the first place, so the two always show up together.
A complete program
Here is the whole thing. Read it once, then I will walk back through the parts that are easy to get wrong.
using Verso;
using Verso.Abstractions;
using Verso.Extensions;
// 1. Load the built-ins: kernels, themes, layouts, formatters.
var extensionHost = new ExtensionHost();
await extensionHost.LoadBuiltInExtensionsAsync();
// 2. Open a session over a blank notebook.
var scaffold = new Scaffold(new NotebookModel(), extensionHost, filePath: null);
// 3. Subsystems come after the extensions are loaded, never before.
scaffold.InitializeSubsystems();
// 4. Whatever the host sets is visible to every kernel.
scaffold.Variables.Set("threshold", 0.95);
// 5. Run code that never becomes a cell.
var outputs = await scaffold.ExecuteCodeCaptureOutputsAsync("1 + 41", language: "csharp");
foreach (var output in outputs)
Console.WriteLine(output.Content);
// 6. Or build a real notebook and watch it run.
scaffold.OnCellExecuted += cellId =>
{
var cell = scaffold.GetCell(cellId);
Console.WriteLine($"{cell?.Language}: {cell?.LastStatus} in {cell?.LastElapsed}");
};
scaffold.AddCell("code", language: "csharp", source: "var score = 0.97;");
scaffold.AddCell("code", language: "csharp",
source: "score > Variables.Get<double>(\"threshold\")");
await scaffold.ExecuteAllAsync();
await scaffold.DisposeAsync();
That is the whole surface for a first program. Everything below is what each step is doing.
Why the order matters
InitializeSubsystems() asks the extension host for themes, layouts, and settable extensions, and builds the subsystems out of what it finds. Call it before the extensions are loaded and it builds them out of nothing. Call it after, the way step 3 does, and it also subscribes to the host's load and status events, so the subsystems refresh on their own when an extension is added or toggled later.
Until you call it, the ThemeEngine, LayoutManager, and SettingsManager properties are null. That is usually the first surprise.
Three ways to execute
ExecuteCodeCaptureOutputsAsync(code, language) runs a string and hands back its outputs. Nothing is added to the notebook. This is the one to reach for when the notebook model is an implementation detail and all you want out of the engine is an evaluator.
AddCell plus ExecuteAllAsync() is a real notebook run. ExecuteAllAsync resets the kernels first, so a full run behaves as though the file had just been opened.
ExecuteCellAsync(cellId) runs one cell without resetting anything, which is what an editor does when you press Run on a single cell. Both of the cell methods also return ExecutionResult values directly, which is simpler than subscribing when you only care about the call you just made.
One store, every language
Every kernel in the session reads and writes the same variable store, and your host code reads and writes it too:
scaffold.Variables.Set("threshold", 0.95);
var back = scaffold.Variables.Get<double>("threshold");
There is no per-kernel isolation, and that is the point of the thing. A value your application sets before a run is a plain variable inside a C# cell, and the same name is readable from Python or SQL in the same session.
The built-in C# kernel is in the Verso package. Other languages arrive as their own packages, and because LoadBuiltInExtensionsAsync also scans assemblies sitting beside yours that reference Verso.Abstractions, adding one is a package reference and nothing else:
dotnet add package Verso.FSharp
dotnet add package Verso.Ado
Watching it happen
Subscribe when you need to drive progress in your own UI. The cell events carry only the cell's id, so look the cell up and read what was stamped on it:
| Event | Signature | Fires when |
|---|---|---|
OnCellExecuting |
Action<Guid> |
A cell begins executing |
OnCellExecuted |
Action<Guid> |
A cell finishes, with ExecutionCount, LastElapsed, and LastStatus already set |
OnCellOutputUpdated |
Action<Guid> |
A running cell appends a live output |
OnKernelRestarting / OnKernelRestarted |
Action<string?> |
A kernel restart starts and completes |
OnKernelRestartFailed |
Action<string?, Exception> |
A kernel restart throws |
The kernel events carry the language name rather than a cell id, because a restart is not about any one cell.
Loading somebody else's extension
await extensionHost.LoadFromAssemblyAsync("./MyExtension.dll");
A third-party assembly loads into a collectible AssemblyLoadContext that isolates its dependencies while handing it the host's own Verso.Abstractions types, so the interfaces match. Built-ins load into the default context instead, since they ship alongside the host.
Cleaning up
Scaffold is IAsyncDisposable. Disposing it disposes every registered kernel, clears the internal registries, and disposes the extension host, which unloads the extensions.
In a long-lived process, dispose per session rather than holding one open forever. Kernels hold compiler state and, for some languages, a separate process, so a session nobody closes is a session that never hands any of that back.
Where the engine stops
The boundary between the engine and a user interface is INotebookService, and it lives in Verso.Blazor.Shared, not in the engine. The browser host implements it in process, the VS Code host implements it over JSON-RPC, and the CLI's headless runner does not implement it at all: it drives Scaffold directly with no UI boundary. If you are embedding, you are in the third position.
Two host capabilities are worth knowing about, because kernels ask for them through public abstractions. WriteOutputAsync streams an output while a cell is still running, which is how PowerShell's host writes reach a cell before it finishes. RequestInputAsync asks for a single value from the user, which is how Read-Host works in the editor. A host that does not support them keeps the default behavior, which throws NotSupportedException, and kernels are written to treat both as optional, so implement them when your front end can and skip them when it cannot.
Reference material
The embedding guide is the short version of this page, and the engine and execution pipeline architecture pages go deeper into what happens between ExecuteCellAsync and an output.
If the last release put layouts and a marketplace in reach of an extension author, this is the same engine pointed the other way, with your application as the front end. Build something on it and tell me where the API fights you.