Skip to content

Cell Annotations & Environment

Annotations are metadata directives written in the leading comment block of a cell. They control display names, execution routing, timeouts, environment variables, filesystem mounts, and cache behavior, without separate configuration UI. The cell source is the single source of truth.

# @name Train Classifier
# @worker my-gpu
# @timeout 120
# @env CUDA_VISIBLE_DEVICES=0
x = embeddings
y = labels
classifier.fit(x, y)

Annotations are parsed from the first contiguous block of #-prefixed lines. Once a non-comment, non-blank line is encountered, parsing stops. Each annotation is one line in the format # @key value.

In the editor you type them directly into the cell. From a script or agent you can either write them into the source you pass to strata cell add/edit, or splice a single directive without touching the body: strata cell annotate <notebook_dir> <cell_id> --set worker=gpu-box --unset timeout (see the CLI).


@name

Set a human-readable display name for the cell. Shown in the DAG view and as a badge in the cell editor.

# @name Load arXiv Papers
import pandas as pd

papers = pd.read_parquet("https://...")

For Python cells, any non-empty string is accepted: spaces, parentheses, and special characters are fine.

For prompt cells, @name also sets the output variable name and must be a valid Python identifier:

# @name research_themes
Given these paper counts: {{ category_stats }}
Identify 3 research themes.

If no @name is set, the DAG view falls back to showing the cell's defined variable names, then the cell ID.

@name is a display label, not a referenceable ID

@name sets what shows in the DAG view. It is not what @after or @loop start_from= references. Those resolve against the cell's id in notebook.toml, which is a separate field - see Cell IDs below.


Cell IDs

Every cell has an id in notebook.toml. That ID is what @after and @loop start_from= reference across cells. The defaults:

  • Created via the UI or REST: the backend generates an 8-character UUID prefix like a1b2c3d4. This is what you'll see in fresh notebooks.
  • Hand-written notebook.toml: any string is accepted, as long as it's unique within the notebook. Friendly IDs like seed, threshold, or top-orders are common in example notebooks where the author wants @after seed to read like prose.

notebook.toml example:

cells = [
    { id = "seed",      file = "seed_database.py", language = "sql",    order = 0 },
    { id = "threshold", file = "threshold.py",     language = "python", order = 1 },
    { id = "top-orders", file = "top_orders.py",   language = "sql",    order = 2 },
]

…then in cells/top_orders.py:

# @sql connection=warehouse
# @after seed
SELECT category, COUNT(*) FROM products GROUP BY category

The @after seed resolves to the cell with id = "seed". There is no UI for renaming a cell ID; a friendly ID is set by editing notebook.toml.

@name and id are independent: a cell can have id = "seed" and @name "Seed database" simultaneously. The DAG view shows the @name; @after and @loop start_from= use the id.


@worker

Route the cell's execution to a named worker instead of the local machine.

# @worker df-cluster
category_stats = ctx.sql("SELECT topic, COUNT(*) FROM papers GROUP BY topic").to_pandas()

Workers are HTTP endpoints that implement the Strata executor protocol. Register them via the Workers panel in the sidebar; the persisted result lands in notebook.toml as:

[[workers]]
name = "df-cluster"
backend = "executor"
runtime_id = "df-cluster"

[workers.config]
url = "https://my-datafusion-worker.fly.dev/v1/execute"
transport = "direct"

During execution, the UI shows a pulsing "dispatching → df-cluster" badge on the cell. After completion, the worker name appears in the cell's metadata.

Workers can be anything that speaks HTTP: a GPU box on RunPod, a DataFusion cluster on Fly, a beefy EC2 instance, or a local process on a different port. The built-in remote_executor.py provides a reference implementation:

strata-worker --host 127.0.0.1 --port 9000

If no @worker is set, the cell runs locally in the notebook's Python environment.


@timeout

Override the execution timeout for a single cell, in seconds. The default is 300 seconds (5 minutes); the value must be a positive number.

# @timeout 300
# @worker my-gpu
embeddings = model.encode(abstracts, batch_size=256)

Useful for cells that download data, train models, or call slow external APIs. The timeout applies to the full execution including any remote worker round-trip.

@timeout does not apply to prompt or SQL cells. A prompt cell's model call is bounded by [ai] timeout_seconds in notebook.toml, or the server's STRATA_AI_TIMEOUT_SECONDS (default 60 s); a SQL cell's deadline is set at the connection.


@env

Set an environment variable for this cell only, overriding the notebook-level value.

# @env CUDA_VISIBLE_DEVICES=0
# @env OMP_NUM_THREADS=4
import torch

model = torch.nn.Linear(384, 10).cuda()

Format: # @env KEY=value. Multiple @env lines are supported. The variable is available in os.environ during cell execution.

Don't put secrets in # @env

# @env values live in committed cell source (cells/*.py). The sensitive-key blanking that the notebook.toml [env] writer applies (KEY/SECRET/TOKEN/PASSWORD/CREDENTIAL values blanked before commit) does not apply to # @env - the literal value you type goes straight to git.

For API keys and other secrets, use the notebook's [env] block in notebook.toml (blanked automatically) or the Runtime panel in the UI (kept in the server process only). See notebook.toml [env]. # @env is for non-sensitive overrides like CUDA_VISIBLE_DEVICES or OMP_NUM_THREADS.


@mount

Attach a filesystem mount to the cell. Mounts provide read or read-write access to external storage (local paths, S3, GCS, Azure) during execution.

# @mount raw_data s3://my-bucket/dataset ro
# @mount scratch file:///tmp/work rw
df = pd.read_parquet(raw_data / "events.parquet")
scratch / "summary.txt"  # → Path("/tmp/work/summary.txt")

The mount name becomes a pathlib.Path variable in the cell's namespace. No /mnt/<name> directory convention - the variable directly references the resolved local path (the cached mirror for remote URIs, the URI's local filesystem path for file:// URIs). Use standard Path operations: / for joining, .read_text(), .iterdir(), etc.

Strata mirrors a remote mount in its own process, not the notebook's venv, so its filesystem library goes where Strata is installed: the s3 extra for s3:// (s3fs), gcs for gs:// (gcsfs) and azure for az:// (adlfs), for example uv tool install "strata-notebook[s3]".

Format: # @mount <name> <uri> [ro|rw] [credential=<name>]. Defaults to ro (read-only) if the mode is omitted. credential= names a server-defined credential instead of putting a secret in the cell. The mount name must be a valid Python identifier (it's an injected variable).


@fetch

Declare a URL the cell reads. The bytes are downloaded into the notebook's cache and their digest becomes part of the cell's provenance, so the cell goes out of date (idle) and its downstream cells stale when the URL starts serving something else - instead of the URL string being in the source hash and the bytes being in nothing.

# @fetch zones https://example.org/taxi_zones.csv
import pandas as pd

lookup = pd.read_csv(zones)

<name> is injected as a pathlib.Path to the downloaded file, which keeps the URL's file name, so code that looks at the extension still works. Like a mount variable it lives only in the declaring cell. In an R cell it is the path as a string. A @loop cell cannot declare a fetch, because each iteration is a separate run; fetch in an upstream cell instead.

Format: # @fetch <name> <url> [sha256=<digest>] [refetch=never|stale|always].

  • sha256=<digest> pins the bytes. The digest is the fingerprint, checked without the network while the cached copy matches. If the URL serves anything else, the cell fails with both digests and the error code fetch_pin_mismatch (error_code on the WebSocket cell_error frame, the REST and MCP run results, and the strata run --format json entry), so a client can tell a moved pin from any other failure without reading the text. strata cell pin-fetch <notebook_dir> <cell_id> [NAME ...] writes the pin for you: it sets sha256= on each named fetch (all of the cell's by default) to the digest of the bytes the notebook last downloaded, keeping the line's other options. Run the cell first so there are bytes to pin. After a mismatch, running it again accepts what the URL serves now.
  • refetch=stale (default) checks the URL with a conditional GET (ETag / Last-Modified): at most once a minute while staleness is being recomputed, and always right before the cell runs, so what a run records is what the URL served then.
  • refetch=never uses the cached bytes once there are any.
  • refetch=always downloads again on every check, ignoring validators.

The first download happens only when the cell runs: opening or editing a notebook sends no request to a URL it has never read, and the cell is never shown as ready before that first run.

Each artifact the cell stores records the URL and the digest of the bytes the run read as one of its inputs, and when those bytes were downloaded. That record is how a publication page lists the URL under "External inputs" with its digest and retrieval time, and its RO-Crate gives the URL's File node an sdDatePublished. A snapshot export lists every fetch with whether it is pinned, so an unpinned one can be flagged before the snapshot is shared, and with --include all carries the fetched bytes, so a pinned or refetch=never fetch runs on the importing machine without its URL.

Only http and https are fetched. In service mode every redirect hop passes the same guard as a worker's URLs: no private, loopback or link-local address unless the host is listed in STRATA_NOTEBOOK_FETCH_ALLOWED_HOSTS. The connection goes only to an address that passed the check, so a name cannot pass with a public address and then connect to 127.0.0.1.

When HTTPS_PROXY or HTTP_PROXY covers a URL, a service-mode fetch goes through that proxy instead. Each hop still passes the address check first, and the proxy is sent the host name, so an egress proxy that admits hosts by name decides. It also resolves the name itself, so it should refuse non-public addresses too. Hosts in NO_PROXY connect directly, held to the checked address.

Personal mode allows private addresses, so http://localhost:8000/data.csv works on your own machine, and uses the proxy settings as usual. A URL that cannot be checked shows the cell as out of date, and running it fails with the reason. A cell with @fetch runs on its own in Run All rather than in a batch.

On a remote worker the server does the fetching: the bytes travel with the cell's other inputs, uploaded on the direct transport and staged behind a signed URL on the signed one, so the worker never contacts the URL and needs no route to it. The worker must be running this version of Strata or later. An older one does not recognise the input and the cell fails with a NameError on <name>.

@dataset

Declare a registry name the cell reads. The name resolves to one artifact version, and that version becomes part of the cell's provenance, so when champion moves, a cell that reads the team's champion model goes out of date (idle) and its downstream cells stale. Resolving the name with the strata client inside the cell records nothing: the name is in the source hash and the version is in nothing.

# @dataset model taxi/model@champion
predictions = model.predict(features)

Format: # @dataset <name> <registry-name>[@<alias>|@v=<n>].

  • taxi/model@champion follows the alias. The cell goes out of date when the alias points somewhere else.
  • taxi/model follows the name pointer the same way.
  • taxi/model@v=3 pins version 3 of the artifact the name points at. A pinned dataset never makes the cell out of date, as snapshot= does for @table.

The server resolves the name in its own registry, or in the team store when STRATA_NOTEBOOK_REMOTE_STORE_URL is set. The version is copied into the notebook's own store, keeping its id and version, and <name> is bound to its value like an upstream variable: a table as a DataFrame, a JSON value or a pickled object as itself. Artifacts written outside a notebook (a core transform, strata.put) are Arrow tables. Anything else, such as an image, arrives as a pathlib.Path to its bytes.

Each artifact the cell stores records the name and the version it resolved to as an input, so lineage, in the dashboard and in strata artifact lineage, walks from a downstream result through the cell to the named version. The copy keeps that version's own inputs, and the steps behind it, up to ten deep, are copied with it, so the walk goes on past the dataset to whatever made it. A step the registry no longer holds ends the walk there, as it does in the registry. The Registry tab lists, beside each name, the cells that read it.

The registry is asked at most once a minute while staleness is being recomputed, and always right before the cell runs. A name that does not resolve shows the cell as out of date, and running it fails with the reason. A cell with @dataset runs on its own in Run All rather than in a batch. @loop and R cells cannot declare one; read the dataset in an upstream Python cell.

@table

Declare an Iceberg table input. The table's current snapshot id becomes part of the cell's provenance: when new data lands in the table, the cell goes out of date (idle), its downstream cells go stale, and the normal cascade machinery re-runs them - no manual data-version bookkeeping. For an end-to-end walkthrough (build a warehouse, scan it, retrain on new data, pin a snapshot), see Lake-Aware Cells.

# @table trips file:///data/warehouse#nyc.trips
art = strata.materialize(
    inputs=[trips],
    transform={"executor": "scan@v1", "params": {"snapshot_id": trips_snapshot}},
)

Two variables are injected into the cell's namespace: <name> - the table URI string - and <name>_snapshot - the snapshot id resolved when the cell's provenance was computed. Passing <name>_snapshot to the scan makes the cell fully deterministic: it reads exactly the snapshot its provenance recorded. For a table with no snapshots yet, <name>_snapshot is None (a scan reads it as zero rows) and the cell is never served from the cache, so it sees the table's first write.

Format: # @table <name> <uri> [snapshot=<id>]. The URI is one of the forms client.materialize accepts:

  • <catalog>:<namespace>.<table>, a table in a catalog the server names under STRATA_CATALOGS (REST, Glue, SQL or any other PyIceberg catalog type), e.g. # @table orders lake:sales.orders;
  • <warehouse>#<namespace>.<table>, a table in a SQL catalog kept in that warehouse;
  • <namespace>.<table>, a table in the server's default catalog.

The warehouse may be local or on S3, GCS (gs://) or Azure (abfs://, abfss://; pyiceberg has no az:// catalog support); files on GCS and Azure are read with the same STRATA_GCS_* and STRATA_AZURE_* settings the artifact blob store uses. An Azure table's metadata is read with adlfs, or with PyArrow only when the table's locations have no @<account> host (Azure Storage). The name must be a valid Python identifier.

Catalogs are named on the server, not in notebook.toml, because the scan the cell runs happens in the server and has to resolve the same name. (A DuckDB connection, which attaches its catalog itself, can also name one the notebook defines on a personal server; see notebook-level catalogs.)

snapshot=<id> pins the table: the cell reads that snapshot forever and never goes out of date on new data (the lake-side analog of a mount pin). Without a pin, the snapshot is re-resolved every time staleness is evaluated.

Like mount variables, the injected names live only in the declaring cell's namespace - they are not cell defines and do not flow to downstream cells. To use the snapshot id downstream, export it as a real variable: scanned_snapshot = trips_snapshot.

If the catalog is unreachable when provenance is computed, the cell is conservatively treated as out of date; if it is still unreachable at execution time, the run fails with a clear error.

@nocache

Force the cell to always re-execute: never serve a provenance cache hit.

# @nocache
import urllib.request

latest = urllib.request.urlopen("https://api.example.com/now").read()
print(latest)

By default every cell is cached by provenance (sha256(inputs + source + env)), including a leaf cell that only prints, an unchanged rerun replays its output instantly instead of recomputing. That is exactly what you want for deterministic work, and it makes the notebook a good scratchpad (see Driving a notebook with an agent). But it is wrong for a cell whose point is a side effect or fresh value:

  • writes a file, sends a request, mutates external state: replaying the cached result would skip the effect;
  • reads the clock, random, a live API: you want a fresh value each run.

# @nocache opts such a cell out. # @nocache off re-enables caching (useful to override a default you set elsewhere). It applies to every language (on a SQL cell it wins over any # @cache policy, forever included) and sits alongside the read-write-@mount rule, which already forces re-execution for the same reason.

A cell that reads a @nocache (or read-write-mount) cell's value is keyed on the bytes that value stored rather than on the producer's provenance, which is the same on every run. A new value re-runs the consumer; a run that produces the same bytes is still a cache hit for it. Within one run (a cascade, Run All, headless strata run) each cell executes once, so two consumers of a @nocache cell see the same value.


Prompt Cell Annotations

Prompt cells (language prompt) accept an additional set of annotations that configure the AI call.

@model

Override the notebook-level AI model for this cell only.

# @model claude-sonnet-4-6
Summarize {{ df }} in one paragraph.

@temperature

Sampling temperature. Defaults to 0.0.

# @temperature 0.3

@max_tokens

Ceiling on output tokens for this call.

# @max_tokens 1024

@system

System prompt prepended to the conversation.

# @system You are a terse data analyst. Answer in bullet points.

Only one @system line counts: when there are several, the last one wins.

@output

Force the response format.

# @output json

Accepts json (the response is parsed/coerced as JSON) or text (free-form text - the default). Auto-set to json when @output_schema is present, so the schema and # @output json don't need to appear together.

@output_schema

Inline JSON Schema pinning the response shape. When provided, Strata dispatches to provider-native structured output (OpenAI's json_schema with strict mode; Anthropic's native tool-use) so the response comes back as validated JSON rather than free-form text. Providers without schema support fall back to json_object (valid JSON, shape not enforced), and the @validate_retries loop catches shape violations.

# @output_schema {"type": "object", "properties": {"themes": {"type": "array", "items": {"type": "string"}}}, "required": ["themes"]}

Editing the schema invalidates the cell's cache: the schema is part of the provenance hash.

@validate_retries

Total attempts for the validate-and-retry loop (1 initial call + N-1 retries). Defaults to 3. Only takes effect when @output_schema is set; each failed validation feeds the prior response and path-addressed errors back as a retry turn.

# @validate_retries 5

Loop Cell Annotations

A Python cell carrying @loop is executed iteratively. The body runs once per iteration and the carry variable threads state between them.

@loop

# @loop max_iter=50 carry=state
# @loop_until state["converged"]
state = step(state)

Iteration 0 reads state from the upstream cell that defines it (or from start_from=, below); the body must rebind it, or update it in place, every iteration.

Key/value parameters:

  • max_iter=<N>, hard upper bound on iterations.
  • carry=<var>, the variable threaded between iterations.
  • start_from=<cell-id>@iter=<k>, (optional) resume from another loop cell's stored iteration k. Useful for forking a converged run to explore a variant. <cell-id> is the upstream loop cell's id in notebook.toml (not its @name) - see Cell IDs. The fork depends on that cell: when its result changes, the fork runs again. Iteration k must come from that cell's latest run: if the latest run stopped before k (a lower max_iter, an earlier @loop_until exit, a failure), the fork fails rather than seed from a step an older run left behind.
  • until=<expr>, (optional) the same early exit as @loop_until, inline. Parameters are split on whitespace, so the expression cannot contain a space (until=acc["loss"]<0.05); use @loop_until for anything longer.

@loop_until

Python expression evaluated after each iteration in the cell's namespace. When it returns truthy, the loop exits early. A name it reads that the body does not bind (a tolerance set upstream) is an input of the cell, like any other reference. An expression that raises fails the cell at that iteration.

# @loop max_iter=100 carry=acc
# @loop_until acc["loss"] < 0.05

Each iteration's carry state is stored as …@iter=k artifacts; the final iteration becomes the cell's canonical artifact. Progress is broadcast over WebSocket as cell_iteration_progress messages. The @loop and @loop_until parameters are part of the cell's cache key, so editing one (say max_iter) runs the loop again rather than returning the old result.


SQL Cell Annotations

A cell with language = "sql" runs a query through a declared connection. See SQL Cells for the full feature walkthrough; this section is the per-annotation reference.

@sql

Marks the cell as SQL and binds it to a named connection.

# @sql connection=warehouse
SELECT * FROM orders WHERE amount > :min_amount

Key/value parameters:

  • connection=<name>, required. Must reference an entry under [connections.<name>] in notebook.toml. Manage these via the Connections panel in the sidebar; you don't need to edit the file directly.
  • write=true, opt the cell into writable execution. Without this flag the cell may only read: SELECT, set operations, VALUES, TABLE, SUMMARIZE, PIVOT, DESCRIBE, SHOW and plain EXPLAIN. Anything else (DDL, DML, COPY, ATTACH, USE, CALL, SET, PRAGMA, and EXPLAIN ANALYZE, which runs the statement it describes) is refused before the statement reaches the driver, naming it. The connection is still opened read-only, but that is a second line rather than the boundary: the read-only transaction a driver opens is one a COMMIT in the cell body can end. With the flag the cell can run setup scripts (CREATE TABLE, INSERT, DROP). It is per-cell; read cells on the same connection stay read-only.
# @sql connection=warehouse write=true
DROP TABLE IF EXISTS events;
CREATE TABLE events (id INTEGER PRIMARY KEY, label TEXT NOT NULL);
INSERT INTO events VALUES (1, 'alpha'), (2, 'beta');

Write cells split the body into individual statements via sqlglot, run each in sequence, and emit a per-statement status table (stmt, kind, rows_affected). Default cache policy for write cells is session; fingerprint and snapshot error early because probe-based invalidation has no anchor when the cell mutates state.

@cache

Override the default fingerprint cache policy on a SQL cell.

Policy Behavior
fingerprint Default. Probe-derived freshness token + schema fingerprint folded into the hash. The cell opens idle, because trusting the cache means asking the source.
forever Static salt; never invalidates from DB-side state.
session Session-unique salt; invalidates across sessions.
ttl=<seconds> floor(now / ttl) bucketed time-based salt.
snapshot The result is pinned to one queryable state of the warehouse. Snowflake, BigQuery, and DuckDB over a catalog; refused on other drivers.
# @sql connection=warehouse
# @cache forever
SELECT * FROM dim_country

# @cache snapshot needs a driver whose warehouse can query a table as it stood at a moment in time. Snowflake (AT (TIMESTAMP => ...)) and BigQuery (FOR SYSTEM_TIME AS OF ...) can, and so can a DuckDB connection with a catalog (see below). SQLite, PostgreSQL and DuckDB without a catalog cannot, and the cell is refused before any connection is opened.

# @sql connection=warehouse
# @cache snapshot
SELECT region, SUM(amount) AS revenue FROM orders GROUP BY region

The first run reads the warehouse clock and runs the query with every table pinned to that moment. The timestamp is the cell's cache key and is recorded on its artifact. Running the cell again finds it there and hits the cache, even after new rows land, because the cell still names the same state. The timestamp moves only when the cell changes (its query, binds, connection or upstream inputs) or when you rerun it (↻), which pins to now.

Time travel only reaches back so far, so each run reports how long its state stays queryable:

State as of 2026-09-15T10:05:00+00:00; queryable until 2026-09-16T10:05:00+00:00.

On Snowflake the horizon is the shortest DATA_RETENTION_TIME_IN_DAYS among the tables the query reads. On BigQuery it is 48 hours, the shortest time travel window a dataset can be configured with, since a dataset's own setting lives in a region-scoped view the connection does not name. After the horizon, a rerun works, but the recorded state can no longer be queried again. The horizon is also on the publication page of a result built from the cell.

On a DuckDB connection over a catalog, the state is the Iceberg snapshot of each catalog table the query reads. The first run records those snapshot ids on its artifact and reads each table AT (VERSION => id); later runs read the same snapshots and hit the cache, and a new snapshot of a table does not make the cell out of date. Only a rerun (↻) or a change to the cell moves it to the current snapshots. The run says which snapshots it shows:

State of lake.taxi.trips at snapshot 5739646342007261951.

Every table the query reads must be one of the catalog's, since only those have snapshots to read again: a query that reads a mount or a table in the connection's own database file is refused, naming it. An Iceberg snapshot stays queryable until the catalog's maintenance expires it, which Strata cannot see, so no horizon is recorded. Per-driver freshness probe details are in SQL Cells.

@name

For SQL cells, @name sets the output variable name (default result), the same way it does for prompt cells.

# @sql connection=warehouse
# @name top_customers
SELECT customer, SUM(amount) AS total
FROM orders GROUP BY customer ORDER BY total DESC LIMIT 5

A downstream Python cell can then reference top_customers directly as a pyarrow.Table; call .to_pandas() for a pandas DataFrame.


Variant Cells

Variant cells let you keep multiple alternative implementations of the same DAG slot side by side and switch between them. The canonical use case is "we want to try three models for this experiment": three training cells all produce a model variable, and downstream cells reference model without caring which variant produced it.

# @variant classifier logreg
from sklearn.linear_model import LogisticRegression

model = LogisticRegression(max_iter=1000).fit(X_train, y_train)
# @variant classifier rf
from sklearn.ensemble import RandomForestClassifier

model = RandomForestClassifier(n_estimators=200).fit(X_train, y_train)

Both cells declare # @variant <group> <name> with the same group (classifier) and different names (logreg, rf). At any given time exactly one variant is active; only the active variant participates in the DAG, so downstream cells see one producer for model. In the UI the group renders as a tab strip: clicking a tab switches the active variant, and the cell editor shows that variant's source.

Switching variants

The active variant per group is persisted in notebook.toml:

[[variant_group]]
group = "classifier"
active = "rf"

Switching is a one-line diff. Each variant carries its own provenance hash, so re-running a variant you've already trained is a cache hit: flip-flopping between two variants is free after each has run once. Downstream cells go stale on switch (their input artifact comes from a different upstream cell) but become cache hits on the way back.

If notebook.toml doesn't pin a selection, or pins a name no cell provides, the DAG falls back to the first variant in source order. A variant_active_unknown diagnostic surfaces in the UI when the selection drifts (e.g. you renamed a variant in source without updating the toml entry).

Sweep mode - compare all variants at once

Switch mode answers "which model do I pick?". Sweep mode answers "how do all three compare on the same downstream pipeline?" - it runs every variant on each group execution and hands a downstream cell the whole set as a {variant_name: value} dict.

[[variant_group]]
group = "classifier"
mode = "sweep"        # default is "switch"; `active` is ignored in sweep

The variant cells are unchanged - same # @variant <group> <name> annotation, same defines contract. Only the consumer changes: instead of seeing one model, a downstream cell receives a dict keyed by variant name and can compare them in one pass:

# downstream cell - `preds` is {"logreg": ..., "rf": ..., "gbm": ...}
scores = {name: accuracy(p, y_test) for name, p in preds.items()}

Migrating switch → sweep is a one-line toml edit (no cell changes). In the UI, the tab strip becomes a display selector (clicking a tab just shows that variant's source; it no longer changes what runs), with a group-level status rollup. Provenance includes the sorted variant→artifact map, so adding a variant only runs that one and renaming/removing a variant restalens the downstream as expected.

Caveats worth knowing: a variant that fails stops the downstream. Running a consumer materialises every variant first, and the first one to fail fails the consumer's run with that variant's error; the consumer does not run on a partial dict. Fix or remove the failing variant and run again. A sweep group of one is legal but pointless - you'd get a one-key dict. Sweep-group members and their downstream consumers always run as single-cell executions - they're excluded from run-all's shared-namespace batching, so a sweep notebook doesn't get that speedup. This is deliberate: batching would let one variant's in-namespace state leak into a sibling's, corrupting its provenance, so sweep trades the batch speedup for per-variant isolation.

Fan-out - run a downstream cell once per variant

Sweep mode hands a downstream cell the whole {variant: value} dict. Sometimes you'd rather run the downstream work itself once per variant - score each model on its own, tune each independently, dispatch each to its own GPU worker. That's # @per_variant:

# @per_variant
# Runs once per model variant. `preds` is bound to THAT variant's list
# (a scalar), not the whole dict - so this cell computes one accuracy.
accuracy = sum(p == t for p, t in zip(preds, y_true)) / len(y_true)

The cell runs N times (once per variant of its upstream sweep group), each instance with the variant's scalar value bound. Its own output becomes per-variant too, so a downstream cell decides how to consume it:

  • Another # @per_variant cell continues the fan-out, zipping by variant name (instance logreg reads the upstream logreg value).
  • A plain cell collapses it back to a {variant: value} dict - the same v1 sweep machinery:
# plain downstream - `accuracy` is {"logreg": 0.9, "rf": 0.88, ...}
best = max(accuracy, key=accuracy.get)

Each instance is an independent materialize: its own provenance, its own cache entry, its own worker dispatch - so # @per_variant + # @worker gpu fans out to N independent jobs, and adding a variant only runs the new instance.

Choosing the group. Bare # @per_variant infers the group when the cell reads from exactly one sweep group. If it reads from two or more, name the one to fan out over - # @per_variant model - and the others collapse to dicts. A cell fans out over one group; comparing across two groups (cartesian) isn't supported. The validator flags the mistakes: per_variant_no_sweep_source (nothing to fan out over), per_variant_ambiguous_group (bare form, ≥2 groups), per_variant_on_variant_member (a cell can't both be a variant and fan out). The named group is part of the cell's cache key, so switching it runs the instances again.

Defines contract

All variants in a group must produce the same set of top-level bindings. The validator compares each variant's defines against its siblings and flags variant_contract_mismatch on any outlier. If logreg exposes only model and rf exposes model + feature_importance, downstream cells that depend on the missing name would break under one selection but not the other.

Imports don't count toward the contract: they're scaffolding, not interface. The variants above each bring in a different sklearn class, which is fine; only the values the cells produce need to match.

Mixing cell kinds

A variant group can mix any cell kinds. A Python variant and a prompt variant can sit in the same group as long as they both produce the contract names, e.g. one variant calls a deterministic regex classifier, another asks an AI model to classify.

Adding and removing variants

The variant tab strip carries a + button that clones the active variant as a sibling. The new cell starts as a copy of the active body with the # @variant line rewritten to an auto-generated name (<active>_copy, then _copy2, _copy3, …). Rename happens by editing the annotation line in source, the standard annotation-as-truth pattern, no separate rename UI.

Deleting a variant tab removes only that variant. If you delete the active one, the next variant in source order auto-promotes. Deleting the last variant in a group removes the cell entirely and drops the [[variant_group]] entry, the group dissolves.

Bootstrapping

The first variant of a new group is created by typing the annotation: add # @variant <new_group> <variant_name> to any existing cell, save it, then use the + tab to add siblings. (There's no UI affordance for the bootstrap step, source is the only place a group comes into existence, which keeps notebook.toml honest about which groups exist.)


Widget and App View Annotations

@live

On a widget cell, # @live makes a control change auto-run the cheap downstream cells instead of only marking them stale. # @live off turns it back off. See Live mode.

@app hide

# @app hide keeps a cell out of the read-only app view, for a setup cell whose output is noise. It applies to any cell.


Cross-Cell Ordering

@after

Add an ordering-only DAG edge from another cell to this one. Useful when the dependency is on a side effect, e.g. a SQL seed cell creates the database state that subsequent SQL cells query, and no Python variable flows between them.

# @sql connection=warehouse
# @after seed
SELECT category, COUNT(*) FROM products GROUP BY category

Multiple @after lines stack; each cell ID adds one edge. Whitespace- separated IDs on a single line work too: # @after seed migrate. Self- references and unknown cell IDs are silently dropped at the DAG layer, and no diagnostic reports them, so a misspelled ID leaves the cell without the edge.

<cell-id> is the id field in notebook.toml, not the cell's @name. See Cell IDs for how to set friendly IDs like seed or migrate.

The edge participates in upstream/downstream wiring and the topological order, but contributes no variable to consumed_variables, so it doesn't affect per-variable provenance hashes.


Precedence Rules

When the same setting is configured at multiple levels, the most specific wins:

Setting Annotation Cell config (notebook.toml) Notebook default
Worker # @worker X cell.worker field notebook.worker field
Timeout # @timeout N cell.timeout field notebook.timeout field, else 300 seconds
Env vars # @env K=V cell.env overrides notebook.env defaults
Mounts # @mount ... cell.mounts overrides notebook.mounts defaults
SQL connection # @sql connection=X (annotation only) none; required for SQL cells
Cache policy # @cache <policy> (annotation only) fingerprint (read), session (write)
Cache opt-out # @nocache (annotation only) cache on (serve provenance hits)

Annotations always take priority. This lets you override per-cell behavior without editing notebook.toml.


Notebook-Level Environment (Runtime Panel)

Notebook-wide environment variables are set via the Runtime panel in the sidebar. These apply to all cells unless overridden by a cell-level @env annotation.

Common use cases:

  • API keys: OPENAI_API_KEY, ANTHROPIC_API_KEY (for prompt cells)
  • Database URLs: DATABASE_URL, REDIS_URL
  • Feature flags: DEBUG=true, LOG_LEVEL=info

Sensitive values are not persisted to disk

Environment variables with names containing KEY, SECRET, TOKEN, PASSWORD, or CREDENTIAL have their values blanked from notebook.toml when saving. The key names are preserved as a "which vars are configured" reminder only when something real is configured alongside them. A notebook whose [env] would contain nothing but blanked sensitive slots is persisted without an [env] block at all, so typing an API key in the Runtime panel doesn't churn the committed notebook.

Notebook env vars are stored in the [env] section of notebook.toml:

[env]
DATABASE_URL = "postgres://localhost/mydb"
OPENAI_API_KEY = ""  # value blanked; name kept because DATABASE_URL above is real config