Cell Types¶
Strata Notebook has seven cell kinds:
| Kind | What it runs | Created by |
|---|---|---|
| Python | Python source in the notebook's venv | The default, pick Python from the + Add cell menu |
| Prompt | A text template sent to an AI model | Pick Prompt from the + Add cell menu |
| SQL | A query against a connected database | Pick SQL from the + Add cell menu |
| R | R source via the system Rscript |
Pick R from the + Add cell menu |
| Widget | A declarative control panel (slider, dropdown …) | Pick Widget from the + Add cell menu |
| Markdown | Prose between cells, rendered as markdown | Pick Markdown from the + Add cell menu |
| Loop | A Python cell executed N times in a row | Add a Python cell, then put a # @loop annotation at the top |
Compute cells participate in the DAG, cache by provenance hash, and can be routed to remote workers; widget and markdown cells are declarative (no subprocess). Pick the kind that matches the shape of the computation; this page walks through each.
See Concepts for the execution model; see Cell Annotations for the full per-annotation reference.
Python Cells¶
The default. A Python cell is just Python source: assignments at module scope become the cell's outputs, and free variables become inputs pulled from upstream cells.
Writing a Python cell¶
import pandas as pd
sales = pd.read_parquet("https://example.com/sales.parquet")
by_region = sales.groupby("region")["total"].sum()
This cell defines sales and by_region. A downstream cell that references either name will automatically depend on this one.
# downstream cell, reads by_region from upstream
top_region = by_region.idxmax()
print(f"Top region: {top_region}")
Variable flow and the DAG¶
Strata analyzes each cell's AST to extract:
- Defines: top-level assignments (
x = 1,df = pd.read_csv(...)) - References: free variables used but not defined locally
The DAG builder links references back to the last cell that defined each name (shadowing is handled by order). Edges flow producer → consumer. When you edit an upstream cell, every downstream cell that depends on it becomes stale automatically.
Only variables that a downstream cell references get stored as artifacts. Intermediate scratch variables stay in the subprocess and are discarded when the cell finishes.
The ambient strata client¶
Every locally-executed Python cell gets a ready strata client in its namespace, already bound to the running server - no import or construction needed:
# no `from strata_client import StrataClient` / `StrataClient(base_url=...)`
art = strata.materialize(
inputs=[trips],
transform={"executor": "scan@v1", "params": {"snapshot_id": trips_snapshot}},
)
strata.set_alias("taxi/tip-model", "champion", art.artifact_id, art.version)
It's a lightweight client with the same API surface for the common operations (materialize, put, set_alias, set_tag, resolve_alias, …); explicit from strata_client import StrataClient still works if you want the full client. The ambient strata is created fresh per cell run and closed automatically - you don't manage its lifecycle. It's an injected runtime tool, not a cell variable: it doesn't flow downstream and isn't part of provenance (using it has no effect on staleness), exactly like a @mount path or an @table variable.
Cells routed to a remote executor worker (not local execution) don't get the ambient strata - import a client explicitly there.
strata.promote() sends an upstream result, and the whole chain behind it, to
the team store - by the variable name the cell reads it under, not by an
artifact id you never see:
# `rows` is an upstream cell's variable, so it is one of this cell's inputs.
strata.promote("rows", name="taxi/rows", alias="champion")
This is the deliberate half of
STRATA_NOTEBOOK_TEAM_CACHE_PUBLISH:
under promoted, nothing reaches the team on its own and this is how something
does. It needs a team store configured (STRATA_NOTEBOOK_REMOTE_STORE_URL) and
raises if there is none. Only upstream results can be promoted from inside a
cell - a cell's own outputs are stored after it returns, so promoting one is a
job for the strip's Promote to team… or strata artifact promote once it has run.
The ambient client points at this server, never at the team store, and it
carries no team-store credential. When a cell publishes with a name
(strata.put(inputs=[], transform=…, data=…, name="taxi/tip-model")), the
artifact lands in the notebook's own store and reaches the team by promotion.
It appears in the registry dashboard: a promote strip under the cell, and the Registry tab in the bottom drawer (promote, approve, lineage - all in the UI).
Calls through
strataare side effects. On a cache hit the cell body doesn't re-run, so astrata.set_alias(...)won't re-fire - fine for idempotent calls (setting an alias to the version it already points at is a no-op). A cell that stores no variable is cached too, keyed on its own provenance, so a cell whose whole point is the side effect needs# @nocacheto re-fire.
Library cells (cross-cell defs and classes)¶
Top-level def and class definitions are shared across cells via a synthetic Python module: write a helper once, call it anywhere.
import math
CIRCLE_PRECISION = 4
def area(r):
return round(math.pi * r * r, CIRCLE_PRECISION)
def perimeter(r):
return round(2 * math.pi * r, CIRCLE_PRECISION)
Downstream cells reference area(7.5), perimeter(7.5), and CIRCLE_PRECISION directly.
How sharing works (slicing)¶
Defs and classes don't pickle reliably across the subprocess boundary, so they round-trip via source reconstitution: Strata writes a slice of the cell's source to disk, re-executes it in a fresh module on the consumer side, and hands the downstream cell the resulting attribute. The slice must be side-effect-free.
The slice keeps the module docstring, import / from import (no from X import *), def / async def, class, and assignments whose RHS is a literal constant: numbers, strings, bools, None, bytes, negations of literals, and nested tuples/lists/sets/dicts of literals. Everything else (non-literal assignments, augmented assigns, expression statements, control flow, bare annotations) is dropped from the slice but stays in the cell's runtime execution.
A single cell can therefore mix runtime work and library code:
# Runtime, dropped from the slice; flows through the artifact path.
raw_min = round(-math.tau * 7, 2)
raw_max = round(math.tau * 16, 2)
print(f"loaded raw bounds: [{raw_min}, {raw_max}]")
# Library, kept in the slice, exported as a synthetic module.
CLAMP_MIN = 0.0
CLAMP_MAX = 100.0
def clamp(value):
return max(CLAMP_MIN, min(CLAMP_MAX, value))
A downstream cell can call clamp(raw_max): clamp and CLAMP_MIN/MAX come from the synthetic module, raw_max from the artifact path.
When the slice isn't self-contained¶
Every name a kept def or class references must be bound by something else in the slice (or a Python builtin). When it isn't, Strata blocks the export with a module_export_blocked diagnostic, surfaced pre-flight, not just at run time.
runtime_threshold = math.sqrt(9) # dropped, non-literal RHS
def is_outlier(value):
return value > runtime_threshold
is_outlierreferences names not defined or imported in this cell: runtime_threshold
Other shapes that block on the same principle:
- Decorators / default values / base classes evaluated at module load:
@my_decoratorwheremy_decoratorisn't imported in the same cell, orclass Child(Parent)whereParentis computed at runtime. - Divergence: a name kept by the slice is also reassigned by dropped runtime code, so the synthetic module's value would differ from the cell's final state.
def f(): ...; f = wrap(f)exports the unwrappedf. - Lambda assignments:
add = lambda x: x + 1. Even thoughcloudpicklecould serialize the value, the synthetic-module path is reserved for source-backed library code.
The fix is usually one of: move the runtime line into its own cell, add the missing import to the same cell as the def, or take the dependency as a function argument.
Single-cell scope¶
The synthetic module is built from one cell's source only, with no transitive composition across cells. A def can't reach a name imported or defined in a different cell; each cell that hosts library code carries its own imports.
One concession: annotations that reference names outside the slice would normally block, but adding from __future__ import annotations relaxes this. PEP 563 stringifies annotations and the free-variable check drops them, so cross-cell type hints "just work" with the future import.
Walked through end-to-end in the library_cells example notebook.
Mutation warnings¶
A cell that changes an upstream value in place, where the source shows it (df["col"] = ..., obj.attr = ..., df.drop(columns=[...], inplace=True)), is read as producing a new version of that variable: the cell becomes its producer for downstream cells, and the upstream artifact is untouched.
Some mutations can't be seen in the source: an alias (d = df; d.drop(..., inplace=True)), a helper function that mutates its argument, a bare mutator like rows.append(x). Strata compares each input before and after the cell runs and raises a mutation warning for those, because the change is not stored: a downstream cell run on its own reads the value from before.
The fix is to copy, or rebind, before mutating:
Warnings surface as a pill on the cell and a structured entry in the execution log.
Python-cell annotations¶
| Annotation | What it does |
|---|---|
# @name X |
Display name for the DAG view |
# @worker X |
Route execution to a named remote worker |
# @timeout 60 |
Override execution timeout (seconds, default 300) |
# @env KEY=value |
Set an env var for this cell only - non-sensitive values only; literal lands in committed source. For secrets use notebook.toml [env] or the Runtime panel. |
# @mount … |
Attach a filesystem mount (see Annotations) |
# @table … |
Declare an Iceberg table input; its snapshot joins the provenance |
# @fetch … |
Download a URL as a recorded input; its digest joins the provenance |
# @dataset … |
Bind a registry name as a recorded input; its version joins the provenance |
# @nocache |
Always re-execute; never serve a cache hit |
# @loop … |
Turn the cell into a loop cell |
See Cell Annotations for the full reference.
Prompt Cells¶
A prompt cell is a text template that gets rendered with upstream variable values, sent to an AI model, and the response stored as an artifact. Prompt cells participate in the DAG and cache by provenance exactly like Python cells: same inputs + same template + same model config = cache hit, no API call.
Create a prompt cell by picking Prompt from the + Add cell menu. You never need to touch notebook.toml directly; editing the cell's source, wiring it into the DAG, and persisting the result all happen through the UI.
Basic syntax¶
1. Setosa is linearly separable from versicolor and virginica based on petal dimensions alone.
2. Versicolor and virginica overlap moderately on sepal width but separate well by petal length.
3. The dataset is balanced (50 samples per species) with no missing values.
{{ df }}is replaced with a text representation of the upstream variabledfbefore sending to the model.- The model's response is stored as an artifact named
summary(from# @name). - Downstream cells can read
summarylike any other upstream variable.
Template syntax¶
Variables are injected with {{ expression }}. The expression is resolved against upstream cell outputs and converted to text using type-specific rules:
| Upstream type | Text representation |
|---|---|
| pandas DataFrame | Shape and column list, then every column, as many rows as fit |
| pandas Series | Name, length and dtype, then as many values as fit |
| numpy ndarray | Shape + dtype + first 10 elements |
| dict / list | JSON, indented |
| Python object (stored as a pickle: a class instance, a fitted model) | A placeholder. The server never unpickles a cell's output, so the prompt says the value is a Python object and the cell's console names it. Convert it in the producing cell (str(model), a dict). |
| anything else | str(value) |
Each variable has a 2,000-token budget (about 8,000 characters) per template render. A frame or series drops rows past the budget and ends with ... (N more rows); other values are cut with a ... (truncated) marker.
Attribute access is supported for safe read-only operations:
{{ df.describe() }} # OK, pandas describe() is allow-listed
{{ df.head() }} # OK
{{ df.shape }} # OK, attribute access (non-callable)
{{ df.to_csv() }} # blocked (unknown method); left as-is in the template
Only a small set of methods is permitted (describe, head, tail on pandas objects), called with no arguments. Arbitrary method calls are blocked to keep template rendering side-effect-free.
Prompt-cell annotations¶
| Annotation | What it does | Default |
|---|---|---|
# @name <identifier> |
Output variable name; must be a Python identifier | result |
# @model <model_id> |
Override the notebook-level AI model | From provider config |
# @temperature <float> |
Sampling temperature (0.0 = deterministic; see Caching below) | 0.0 |
# @max_tokens <int> |
Response token ceiling | 4096 |
# @system <text> |
System prompt prepended to the request (one line; the last one wins) | None |
# @output json\|text |
Coerce the response to JSON (or keep as free-form text) | text |
# @output_schema {…} |
Inline JSON Schema pinning the response shape | None |
# @validate_retries N |
Total attempts for the validate-and-retry loop (1 initial + N−1 retries) | 3 |
# @nocache |
Call the model on every run; never serve a cache hit | Cache on |
Example using several at once:
# @name classification
# @model gpt-5.4
# @temperature 0.0
# @max_tokens 1000
# @system You are a data scientist. Return only valid JSON.
Classify each paper by topic:
{{ sampled_papers }}
Return a JSON object mapping paper ID to topic.
Schema-constrained output¶
# @output_schema {...} pins the shape of the model response to an inline JSON Schema. Strata picks the best provider-native path:
| Provider | Enforcement |
|---|---|
| OpenAI / Gemini | Native response_format: {type: "json_schema"} for any base URL containing openai, which includes Gemini's OpenAI-compatible endpoint. additionalProperties: false is auto-injected at every object node; strict mode is used when the user's required list covers every property (otherwise relaxed to strict: false). |
| Anthropic | Native /v1/messages with tool-use: the schema is sent as a tool's input_schema and tool_choice is forced to that tool. The returned tool_use.input is extracted verbatim. |
| Mistral / Ollama / vLLM | Fallback to response_format: {type: "json_object"}: valid JSON guaranteed, shape not enforced server-side. Client-side validation (see below) fills the gap. |
| Servers that reject the extensions | Some OpenAI-compatible servers 400 on response_format or stream_options outright. Strata retries once without them, appending a schema-guidance system turn; the result is marked degraded and the validate-and-retry loop carries full enforcement. |
Setting @output_schema implies @output json; you don't need both.
Validation is lenient about packaging: when a provider wraps otherwise-valid JSON in code fences or prose, Strata extracts the JSON document before validating instead of burning a retry on the wrapper.
Example: triage each review into a structured record.
# @name triage
# @output_schema {"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"sentiment":{"type":"string","enum":["positive","negative","neutral"]},"priority":{"type":"string","enum":["low","medium","high"]},"tags":{"type":"array","items":{"type":"string"}}},"required":["sentiment","priority","tags"]}}},"required":["items"]}
Triage these customer reviews:
{{ reviews }}
For each review return sentiment, priority, and 1–3 short tags.
A downstream cell can then destructure without regex-wrangling:
Long schemas don't have an escape hatch yet
# @output_schema is parsed as a single JSON value from a single
annotation line (see prompt_analyzer.py). For real-world
schemas this gets long fast and the resulting cell source isn't
pretty.
Two pragmatic workarounds while a multi-line / file-reference syntax is on the backlog:
- Generate the schema in an upstream Python cell and write
the schema-driven prompt cell by hand. The Python cell's
json.dumps(...)keeps the schema readable in source; the prompt cell pastes the result inline. - Keep the schema in a
schemas/-style sibling file inside the notebook directory, and have a setup cell load + cache it as a string. Downstream prompt cells can use a shorter inline schema for caching while documenting the rich version elsewhere.
Track the multi-line annotation feature in the issue tracker if this is blocking you.
Validate-and-retry¶
When @output_schema is set, Strata runs a validate-and-retry loop after every model call:
- Parse the response as JSON and run it through
jsonschema. - On success → store the artifact and return.
- On failure → append the bad response as an
assistantturn, feed the validator's path-addressed errors back as auserturn, and retry. - On retry exhaustion → surface a cell error with the last validator messages.
The default is 3 total attempts (1 initial + 2 retries). Override with # @validate_retries N. Cumulative input/output tokens across all attempts are recorded on the artifact so cost accounting is accurate. The retry count is surfaced on the cell result (validation_retries), and the UI shows "validated after N retries" when non-zero.
Retries are mostly invisible on OpenAI-strict (OpenAI, Gemini) and Anthropic-native paths because the provider enforces the schema at decode time. They earn their keep on the json_object fallback path (Mistral, Ollama, vLLM) where the provider only guarantees syntactic JSON.
Caching¶
A prompt cell's provenance hash mixes together:
- The rendered template text (after
{{ var }}injection) - The provenance hash of each upstream variable the template reads (rendering truncates, so the text alone would miss an edit past the cut)
- Model name
- Temperature
@max_tokens- System prompt
- Output type (
json/text) - Output schema fingerprint (when set)
Editing any of these invalidates the cache. In particular, tweaking @output_schema on a cached cell forces a fresh call, which is what you want when iterating on the response shape.
Keep temperature at 0.0 for prompt cells
With temperature=0.0 the model is deterministic: same inputs → same output, and cache behavior is intuitive. Bumping temperature makes the first response "sticky" in the cache: future runs return the stored stochastic sample rather than re-sampling.
Provider configuration¶
Set an API key in the Runtime panel under Environment Variables. The key determines which provider is used:
| Environment Variable | Provider | Default Model |
|---|---|---|
ANTHROPIC_API_KEY |
Anthropic | claude-sonnet-4-6 |
OPENAI_API_KEY |
OpenAI | gpt-5.4 |
GEMINI_API_KEY |
gemini-3-flash | |
MISTRAL_API_KEY |
Mistral | mistral-large-latest |
STRATA_AI_API_KEY |
Custom (requires [ai] config) |
set in [ai] |
Resolution order (highest priority wins):
notebook.toml[ai]section, per-notebook advanced overrides (see below)- Runtime panel env vars, set in the UI
- Server config (
STRATA_AI_*env vars), the admin default
For standard providers you only need step 2: drop your API key into the Runtime panel and Strata picks the matching default base URL and model.
Process environment is not consulted
A shell-exported OPENAI_API_KEY does not leak into notebooks. This is intentional: each notebook must explicitly opt in to an AI provider.
For self-hosted models (Ollama, vLLM) or custom endpoints, add an [ai] section to notebook.toml directly:
The [ai] section accepts api_key (use sparingly: it persists in notebook.toml; prefer the Runtime panel), base_url, model, max_output_tokens and timeout_seconds (default 60, or the server's STRATA_AI_TIMEOUT_SECONDS; a prompt cell ignores # @timeout). model is the notebook's default for every prompt cell, and there is no UI for it: set it here, or override it for one cell with # @model. Any service that implements the OpenAI /v1/chat/completions endpoint works, including OpenAI, Anthropic, Google and Mistral through their OpenAI-compatible endpoints, Ollama, and self-hosted vLLM, TGI or LiteLLM.
On a server in service mode the request is made by the server, so a base_url set here is checked like an @fetch URL: a host on a private, loopback or link-local address is refused unless the operator lists it in STRATA_NOTEBOOK_FETCH_ALLOWED_HOSTS. Personal mode reaches localhost as before.
The server's STRATA_AI_API_KEY is sent only to the server's own STRATA_AI_BASE_URL or a provider's default URL. A base_url set here that names another host needs a key from the notebook (the Runtime panel, or api_key), in every mode; otherwise the cell reports that the LLM is not configured.
SQL Cells¶
A SQL cell sends a query to a connected database via ADBC (DuckDB through its own driver) and stores the result as an Arrow Table artifact. Like Python and prompt cells, SQL cells participate in the DAG, cache by provenance hash, and surface their output to downstream cells.
# @sql connection=warehouse
SELECT customer, SUM(amount) AS total
FROM orders
WHERE amount > :min_amount
GROUP BY customer
ORDER BY total DESC
customer total
0 acme_inc 18420.50
1 north_winds 9112.75
2 bright_solar 6033.10
The cell above pulls min_amount from an upstream Python cell, sends a parameterized query through the warehouse connection, and stores the resulting rows as an Arrow Table, which downstream cells receive as a pyarrow.Table (.to_pandas() for a DataFrame).
Connections¶
A SQL cell references a named connection. Connections live in notebook.toml under [connections.<name>], but you don't need to edit that file by hand. Open the Connections panel in the right sidebar, click + Add connection, and fill in the form. The driver dropdown switches the field layout per backend (path for DuckDB and SQLite; URI + auth + role + search path for PostgreSQL; account / warehouse / database / schema for Snowflake; project / dataset / credentials for BigQuery).
[connections.warehouse]
driver = "sqlite"
path = "analytics.db"
[connections.prod]
driver = "postgresql"
uri = "postgresql://localhost:5432/prod"
[connections.prod.auth]
user = "${PGUSER}"
password = "${PGPASS}"
Notes:
- Driver-specific extras (e.g.
options.search_path,options.warehousefor Snowflake, future driver-specific keys) round-trip through the editor unchanged. The form editorializes the keys it knows; everything else is preserved. - Auth values use
${VAR}indirection. Literal credentials get blanked whennotebook.tomlis saved, so committing the file never leaks secrets. The form shows a warning border on a literal value so you know to switch it to a variable reference. A personal server reads the variable from its own environment; a server in service mode reads it from the notebook's env. - Relative
pathvalues are notebook-local.path = "analytics.db"resolves against the notebook directory at execution time. The on-disk value stays relative so a notebook moves cleanly between machines. - Currently shipped drivers: DuckDB, SQLite, PostgreSQL, Snowflake, and BigQuery. DuckDB uses the native DuckDB DBAPI; the other four are ADBC-backed (
adbc-driver-sqlite,adbc-driver-postgresql,adbc-driver-snowflake,adbc-driver-bigquery). For Snowflake, read cells userole;write=truecells switch towrite_rolewhen configured, otherwise they reuserole. For BigQuery, read cells usecredentials_path;write=truecells switch towrite_credentials_pathwhen configured, otherwise they reusecredentials_path. Snowflake and BigQuery do not have a session-level read-only flag like PostgreSQL'sSET default_transaction_read_only = on, so the safety boundary is the grants on the configured role or service account.
DuckDB over the lake¶
A DuckDB connection can read the organization's Iceberg catalog and the notebook's mounts. catalog names a REST or AWS Glue catalog the server configures ([tool.strata.catalogs.<name>]) or, on a personal server, one the notebook defines in [catalogs.<name>]. mounts names mounts declared for the cell, in [[mounts]] or with # @mount:
# @sql connection=lake
SELECT t.zone, count(*) AS trips
FROM lake.taxi.trips t JOIN raw USING (zone)
GROUP BY t.zone
Each mount is a view by its name over its Parquet, CSV or JSON files, read with the mount's storage options. file, s3, gs and az mounts work; GCS and Azure ones are read through the same fsspec filesystem (gcsfs, adlfs) a Python cell's mount uses, so a connection's mounts on one of those schemes must share their storage options. Every catalog table the query reads is an input the way an @table declaration is: the cell's provenance folds its current snapshot, the query reads that snapshot, and a new snapshot makes the cell idle (out of date) and the cells downstream of it stale. With # @cache snapshot the cell instead keeps reading the snapshots its first run read until it is rerun. Each mount's fingerprint is folded too, so a new file does the same. The catalog is attached read-only and the mounts are views; a write cell on the connection writes only its path database. A REST catalog's s3.* keys apply only under an s3:// warehouse; otherwise its tables are read with the credentials the catalog vends. A Glue catalog is attached through Glue's Iceberg REST endpoint in its glue.region for the account glue.id, both required; its glue.* keys (or client.*, or s3.*) sign the catalog's requests and read its tables' files, and without keys the AWS credential chain (environment, profile, instance role) does.
A read cell runs reads: 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 cell reaches the driver, naming the statement, because the read-only transaction the driver opens is one a COMMIT in the body can end. This holds on every driver, not only DuckDB. Use # @sql connection=<name> write=true for a cell that changes a database.
Schema discovery¶
The Schema panel in the sidebar shows the tables and columns visible through each declared connection. Click a connection to lazy-load its schema; click a table to expand its columns. The ↻ button re-fetches when the underlying database has changed externally. No SQL cell needs to run for this: the panel talks directly to each driver's catalog query surface (sqlite_master for SQLite, information_schema.tables JOIN columns for PostgreSQL, and the driver-specific catalog queries for Snowflake and BigQuery).
Bind parameters¶
:name placeholders resolve against upstream cell variables. One inside a string literal or a comment is not a placeholder, and strings end where the connection's dialect ends them (on Snowflake and BigQuery, \' does not). Strata coerces a strict allowlist of Python types (int, float, str, bytes, bool, None, Decimal, UUID, datetime/date/time) into ADBC bind values; anything else (a list, a table, a custom object) is rejected with a clear error. An upstream numpy scalar is stored as the Python number it holds, so it binds as that number. A value stored as a Python pickle is rejected without being loaded: the server never unpickles a cell's output. No string substitution ever: values flow through ADBC's prepared-statement layer, so adversarial strings ('; DROP TABLE …) round-trip as data, not SQL.
order_id customer_id amount placed_at
0 1042 7 245.99 2026-04-12
1 1093 14 180.50 2026-04-13
2 1117 3 299.00 2026-04-14
The DAG links the SQL cell to the Python cell automatically, with the same edge logic Strata uses for Python free variables. The run records the values it bound, so the lineage behind a result names the value it ran at.
Cache policies¶
A SQL cell's provenance hash folds together:
- The query's own tokens (so whitespace, comment and keyword-case edits don't churn the cache, and any other edit runs the query again).
- The bind parameters (type-tagged:
True≠1). - The connection's identity (host / DB / user / role / search_path, never the password).
- The hashes of every upstream artifact referenced via
:name. - The driver's freshness probe result for the touched tables.
- The driver's schema fingerprint for the touched tables.
- A salt derived from the
# @cachepolicy below.
# @cache controls how DB-side state factors in. Default is fingerprint.
| Policy | Behavior | When to use |
|---|---|---|
fingerprint |
Default. Probe-derived freshness token + schema fingerprint folded in. | Most queries. |
forever |
Static salt; never invalidates from DB-side state. | True reference data. User asserts. |
session |
Session-unique salt; invalidates across sessions. | Always-fresh queries / dashboards. |
ttl=<seconds> |
floor(now / ttl) in the salt; bucketed time-based invalidation. |
Stale-tolerant aggregations. |
snapshot |
Pins the query to one point in time through the warehouse's time travel (Snowflake, BigQuery), or to the Iceberg snapshots of a DuckDB connection's catalog tables. Refused on other drivers before a connection opens. See @cache. |
Reproducibility-critical reads. |
On reopen. fingerprint and snapshot open idle. Both say, in the cell's
own annotation, that the cached rows are good only while the source still
agrees, and asking the source is a query, not something to do while opening a
notebook. forever, session and ttl can come back ready, because each has
already said what it depends on and that is settled locally: a session cell
matches only within the session that ran it, and a ttl cell only inside the
same time bucket, so one whose window has passed comes back idle anyway.
The connection counts too. Repointing [connections.<name>] at another
database, or at a different credential, means the cell is no longer ready about
what it was ready about, so it opens idle rather than green with the previous
database's rows.
Per-driver freshness¶
fingerprint correctness depends on what the driver can probe.
| Driver | Probe | Granularity | Notes |
|---|---|---|---|
| PostgreSQL | pg_stat_user_tables + pg_class.relfilenode |
per-table | Up to ~500 ms stats-collector lag. |
| SQLite | The database file's size, mtime and header change counter, its -wal sidecar, plus PRAGMA data_version / schema_version |
DB-wide | Any commit to the file, from any process, changes the token, so a write to one table invalidates cells reading another. A :memory: database has only the pragmas. |
| DuckDB | PRAGMA database_size |
DB-wide | Advances when DuckDB writes blocks; two row states between block-aligned flushes can share a token. Pin # @cache session on a database that is being written to while you work. |
| Snowflake | INFORMATION_SCHEMA.TABLES.LAST_ALTERED |
per-table | Per-database scoping (one query per touched database). Bills cloud-services credits but each query is small. LAST_ALTERED updates even on 0-row DML, safe direction (over-invalidates, never under). |
| BigQuery | __TABLES__.last_modified_time |
per-table | Per-dataset scoping. __TABLES__ is the legacy-but-stable view; INFORMATION_SCHEMA.TABLES doesn't expose last_modified_time. Streaming-buffer caveat: tables receiving streaming inserts have last_modified_time lag by minutes-to-90-min until the buffer flushes; pin # @cache session on those queries. Permissions: bigquery.tables.get. |
The schema fingerprint catches metadata-only changes (ADD COLUMN, type changes, nullability flips) that the freshness token would miss.
A probe can only ask about a table the query names. A table named at run time (Snowflake's IDENTIFIER($tbl) or TABLE($tbl)), a table function (read_parquet(...), query_table(...), a Postgres set-returning function) or a file read in place of a table (DuckDB's FROM 'events.parquet') is not fingerprinted, so under fingerprint such a cell re-runs every time, and the header says so. Its result's content is folded into its provenance, so a downstream cell re-runs only when the rows actually changed. Declare # @cache session or # @cache ttl=... to reuse results instead, or read files through a mount on a DuckDB lake connection, whose fingerprint the cache does track.
Read-only by default¶
SQL cells are read-only by default, but the enforcement mechanism depends on the backend:
- SQLite:
mode=roplusPRAGMA query_only=ON - DuckDB: a file opens with
read_only=True, and every read runs insideBEGIN TRANSACTION READ ONLY(which also covers:memory:) - PostgreSQL:
SET default_transaction_read_only = on - Snowflake: the configured
rolemust be read-only - BigQuery: the configured
credentials_pathmust point at a read-only service account
For SQLite, DuckDB and PostgreSQL, Strata enforces read-only at the connection/session level. For Snowflake and BigQuery, Strata selects the read-scoped role or credentials, and the cloud platform's grants are the actual boundary. In all cases, the default path is “read unless you explicitly opt into write=true,” and the statement check above refuses anything but a read before the driver sees it.
In service mode a DuckDB or SQLite cell, which runs inside the server process, is also confined: its database file must be in the notebook's directory or outside the server's state (a SQLite uri is refused), a DuckDB connection reaches only its own database and the mounts and catalog tables it reads, and a SQLite write cell cannot ATTACH, DETACH or VACUUM. A BigQuery connection there must name a credentials_path key file in the notebook's directory, so it never runs as the server's own Google credentials. See Service mode.
Write cells¶
Setup, seeding, and migration scripts opt into writable execution per cell:
# @sql connection=warehouse write=true
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer TEXT NOT NULL,
amount REAL
);
INSERT INTO orders VALUES (1, 'alice', 25.50), (2, 'bob', 199.99);
- The body is split into individual statements on sqlglot's tokenizer (ADBC's cursor runs only the first statement otherwise). Each statement runs as written in the cell, and a trailing comment is not a statement.
:namebind placeholders work the same as in read cells.- The default cache policy is
session(one execution per session; same body in the same session is a cache hit). # @cache fingerprintand# @cache snapshoterror early on write cells: probe-based invalidation has no anchor when the cell mutates state.- The cell still produces an Arrow artifact: a per-statement status table with
stmt,kind(CREATE TABLE,INSERT, …), androws_affected(nullable;nullfor DDL). - Read cells using the same connection stay on the read path; the override is per-cell.
A query runs off the server's event loop, so a cancel lands at once: the run is abandoned and publishes nothing. The query itself may keep running in the database, because ADBC drivers differ on whether they can cancel one.
# @name and downstream consumption¶
A SQL cell's output variable name defaults to result; override with # @name <identifier>. Downstream cells receive the result as a pyarrow.Table (Arrow IPC artifacts deserialize through the standard notebook serializer); call .to_pandas() for a pandas DataFrame:
# @sql connection=warehouse
# @name top_customers
SELECT customer, SUM(amount) AS total
FROM orders GROUP BY customer ORDER BY total DESC LIMIT 5
# downstream Python cell
print(top_customers.shape) # (5, 2)
print(top_customers.to_pandas()["total"].sum())
# @after for setup-then-query pipelines¶
A read SQL cell that depends on a write SQL cell's side effects (the underlying database state) can declare an explicit ordering edge:
# @sql connection=warehouse write=true
CREATE TABLE products (sku TEXT PRIMARY KEY, category TEXT);
INSERT INTO products VALUES ('A', 'widgets'), ('B', 'gadgets');
# @after seed adds a DAG edge from the seed cell to this one even though no Python variable flows between them: the dependency is on a side effect (the SQLite file). This is what cascade execution and staleness recompute use to ensure the right ordering.
Worked example¶
The sql_orders_report example notebook walks through all of this end-to-end: a SQL seed cell, a Python threshold cell, two parameterized SQL queries, and a Python report cell: five cells, two languages, with both fingerprint and forever cache policies side by side.
SQL-cell annotations¶
| Annotation | What it does |
|---|---|
# @sql connection=<name> [write=true] |
Mark the cell as SQL; reference a declared connection |
# @cache <policy> |
Override the default fingerprint cache policy |
# @nocache |
Always re-execute; wins over any # @cache policy |
# @name <identifier> |
Name the output variable (default: result) |
# @after <cell-id> |
Add an ordering-only DAG edge to an upstream cell |
See Cell Annotations for the full reference.
Widget Cells¶
A widget cell is a declarative control panel - one control per line - that
feeds values into the DAG. Drag a slider or pick from a dropdown, and the cells
that read that value go stale; run them to see the result. Add one with the
+ menu (Widget), or declare language = "widget" in notebook.toml.
# @name Controls
alpha = slider(0, 1, step=0.01, default=0.5)
epochs = number(default=10, min=1, max=200)
optimizer = dropdown(["adam", "sgd"], default="adam")
augment = checkbox(default=False)
label = text(default="baseline")
Each line name = control(...) defines a variable downstream cells consume,
exactly like a Python cell's output:
# @name Train
model = train(data, lr=alpha, epochs=epochs, optimizer=optimizer)
report = evaluate(model)
report
Controls¶
| Control | Value | Signature |
|---|---|---|
slider |
number | slider(min, max, step=, default=) |
number |
number | number(default=, min=, max=) |
dropdown |
string | dropdown([...options], default=) |
checkbox |
bool | checkbox(default=) |
text |
string | text(default=) |
Arguments must be literals. Omit default= and it's inferred (a slider's min,
a dropdown's first option, False, ""). Omit a slider's step= and it's
derived from the range - roughly (max - min) / 100, snapped to a readable
1/2/5 × 10ⁿ increment (so slider(0, 1) steps by 0.01, slider(0, 100)
by 1). Pass step= explicitly to override.
How it works¶
A widget cell runs no code and no subprocess - it's declarative. Each control's current value is stored as a content-addressed artifact, so:
- Changing a control re-materializes its value and marks downstream cells stale - the same signal as editing an upstream cell. Run the stale cells (or accept the cascade) to propagate the change.
- Returning a control to a previous value is a cache hit - downstream cells that already computed for that value don't recompute.
- The declaration (
slider(0, 1, …)) is the committed cell file,cells/<id>.widget; the current value is runtime state in.strata/runtime.json(a drag never churns a committed file). It travels in a snapshot, so an imported copy computes the scenario the bundle was taken from rather than the declared defaults.
In the web UI a widget cell shows its controls in place of the code editor; click ✎ Edit controls to edit the declaration. Widget cells render in the web UI (the CLI and read-only TUI show them as their declaration).
Live mode¶
By default a control change marks downstream cells stale and you run them. Turn
on ⚡ Live (a # @live annotation on the widget cell) and changing a control
auto-runs the cheap downstream cells instead. Cells whose last run was
expensive (over a cost threshold) are left stale rather than re-run on every
drag - so a slider feeding a quick plot updates live, while one feeding a long
training cell waits for a manual run.
App view¶
Click App in the notebook header (or visit /#/app/<sessionId>) to open the
notebook as a read-only interactive app: only widget control panels,
markdown, and display outputs render - no editor, DAG, or toolbars. The
connection is read-only (edits and arbitrary cell runs are rejected server-side),
but viewers can still drive widgets - combined with ⚡ Live that's the
"tweak a parameter, see the result" dashboard you hand to a stakeholder.
Add # @app hide to a cell to keep it out of the app view (e.g. a setup cell
whose output is noise). Run the notebook's cells once in the editor so the app
has outputs to show, then share the app link.
Embedding the app view in another site¶
The app view can be dropped into another page - a dashboard, wiki, or internal
portal - as an <iframe>. Pick Embed from the notebook's Export menu to
copy a ready-to-paste snippet, or build it yourself:
<iframe src="http://localhost:8765/#/app/<sessionId>?embed=1"
title="Strata notebook" style="width:100%;border:0"></iframe>
<script>
addEventListener('message', (e) => {
if (e.origin === 'http://localhost:8765' &&
e.data && e.data.type === 'strata:embed:resize')
for (const f of document.querySelectorAll('iframe[title="Strata notebook"]'))
if (f.contentWindow === e.source) f.style.height = e.data.height + 'px'
})
</script>
?embed=1 drops the standalone chrome (the title bar and ← Edit link) so the
view blends into the host page, and the embedded app posts its content height to
the parent frame ({ type: 'strata:embed:resize', height }) so the optional
listener above sizes the iframe with no inner scrollbar. The listener accepts
heights only from the Strata server's origin, so another frame on the page
cannot resize it, and sizes only the iframe that sent the height, so several
embeds on one page each get their own. Widgets stay live inside
the frame, so with ⚡ Live on it's a fully interactive embedded panel.
Cross-origin embedding is opt-in. By default a notebook is framable only from
its own origin (Content-Security-Policy: frame-ancestors 'self'). To embed on
another host, list its origin in embed_frame_ancestors (env
STRATA_EMBED_FRAME_ANCESTORS), e.g. https://analytics.example.com, or * to
allow any host. Accepts a comma-separated list or a JSON array.
Every page of the web UI, the editor and the app view alike, also sends
script-src 'self'; object-src 'none'; base-uri 'self', so only Strata's own
bundled scripts run in them: markup that reaches the page
through a cell output or a markdown cell cannot execute script. A proxy that
injects its own inline script into the page needs to replace that policy.
Framing the editor¶
A platform that frames the full editor, not only the app view, adds ?framed=1
(/#/notebook/<sessionId>?framed=1). The editor then leaves out what the
parent page already has: Strata's brand and the notebook's name, the App,
Artifacts and Logs links (which would navigate the frame away), the
deployment-mode badge, Delete Notebook, and Back Home on the reconnect
screen. Reopening the notebook after a lost session keeps ?framed=1. Running,
editing and export are unchanged. Hiding Delete Notebook is not access control: a platform that
wants deletes to go through its own records refuses DELETE
/v1/notebooks/{session_id} and POST /v1/notebooks/delete-by-path itself. The
same embed_frame_ancestors rule decides which hosts may frame it.
Markdown Cells¶
Plain prose between cells, rendered with markdown-it + DOMPurify for safe HTML output. Useful for section headings, methodology notes, and annotating decision points in a notebook. Markdown cells are not part of the DAG - they don't produce artifacts, don't participate in cascade execution, and don't have an id / variable that downstream cells can reference. They survive saves verbatim; a markdown export escapes raw HTML, as the UI does.
## Stage 1: Load + Clean
The next two cells pull last quarter's events and drop rows with
missing timestamps. The clean DataFrame `events_clean` is what
everything downstream reads.
Create a markdown cell via the + Add cell menu (pick "Markdown") or by setting language = "markdown" on a cell entry in notebook.toml. The cell source lives in cells/<id>.md rather than .py.
See Markdown showcase for what the supported renderers handle (headings, lists, tables, code fences, fenced HTML attributes) and what's stripped by the security guard.
Artifact URIs¶
Cells expose their outputs as artifacts under a stable URI scheme:
For loop cells, each iteration gets a suffix:
A notebook's <artifact_id> names a cell variable, not its content: nb_<notebook_id>_cell_<cell_id>_var_<variable>. Each new result for that variable is stored as the next @v=N of the same id, with its provenance hash (code + inputs + env) recorded on the version. The cache lookup searches by that hash, so same code + same inputs + same env finds the stored version whatever its number. A notebook keeps each id's current value plus its last 3 earlier versions, so reverting a recent edit is still a cache hit (STRATA_NOTEBOOK_KEEP_SUPERSEDED_VERSIONS sets how many).
You don't usually need to construct these ids by hand; the inspect panel surfaces them and # @loop start_from=<cell-id>@iter=k references them by cell ID + iteration index, not the full URI.
Loop Cells¶
A loop cell is a regular Python cell with a # @loop annotation. The body runs N times, with a carry variable threaded between iterations. Each iteration's state is stored as its own artifact, so you can inspect any intermediate step.
Use loop cells for iterative refinement (hill climbing, MCMC, training loops with checkpoints), simulations, and anything where you'd want to pause and inspect intermediate states, or fork a new run from a promising one.
Minimal example¶
Two cells: a seed and a loop.
# loop cell
# @loop max_iter=40 carry=state
# @loop_until state["best_score"] < 1e-3
import random
# Each iteration: read `state`, compute the next step, rebind `state`.
candidate = state["x"] + random.uniform(-0.1, 0.1)
score = candidate**2 # some objective
if score < state["best_score"]:
state = {**state, "x": candidate, "best_score": score, "iter": state["iter"] + 1}
else:
state = {**state, "iter": state["iter"] + 1}
After execution, state holds the final iteration's value and every intermediate iteration is queryable.
The cell's display output (a figure, display(...), a trailing expression) is what its final iteration displayed. It is saved like any Python cell's, so it is still there after a reopen, in strata cell show and in strata export. Earlier iterations' displays are not kept.
Required directives¶
| Directive | What it does |
|---|---|
# @loop max_iter=N |
Hard cap on iterations. Required: it is the safety bound on the loop. |
# @loop carry=VAR |
The variable threaded between iterations. Required. Must be re-bound (or updated in place) by the cell body each iteration, and seeded by an upstream cell on iteration 0. |
These can be on the same line: # @loop max_iter=40 carry=state.
Optional directives¶
| Directive | What it does |
|---|---|
# @loop_until <expr> |
Early termination when <expr> is truthy (evaluated against the current state) |
# @loop start_from=<cell>@iter=k |
Seed iteration 0 from a specific prior iteration's artifact, for forking runs |
Per-iteration artifacts¶
Every iteration's carry value becomes its own artifact with an @iter=k suffix:
strata://artifact/nb_..._cell_<loop_id>_var_state@iter=0@v=1
strata://artifact/nb_..._cell_<loop_id>_var_state@iter=1@v=1
...
The inspect panel shows an iteration picker so you can scrub through the intermediate states. The final iteration's artifact is also the cell's canonical output (no @iter suffix); downstream cells read it via the normal DAG path.
Forking a loop¶
Intermediate iterations are first-class artifacts, so you can branch a new run from any step of an old one without re-running the expensive prefix.
Scenario. You ran a hill-climbing search for 50 iterations. Glancing at the inspect panel, iteration 17 looked like it was about to find a better local optimum before the sampler drifted away. You want to explore what happens if you push harder from that exact state with a different step size.
- Open the loop cell's Inspect panel, scrub to iteration 17, copy its
artifact URI. It'll look like
strata://artifact/nb_..._cell_hill_climb_var_state@iter=17@v=1. - Add a new loop cell below. Reference the original cell's ID (not the full
URI) in
start_from:
# new loop cell, continues from iteration 17 of the previous run
# @loop max_iter=20 carry=state start_from=hill_climb@iter=17
state["step_size"] *= 0.5 # smaller steps from here on
state = sample_and_score(state)
- Run the new cell. It reads iteration 17's carry value as its seed, runs up to 20 more iterations under the modified strategy, and stores those iterations as its own artifact chain. The original run stays untouched.
You now have two parallel forks materialized in the artifact store. Either one can be forked further, and the inspect panel shows both chains. The fork is downstream of the cell it starts from: when that cell's result changes, the fork and everything reading it run again.
This is the escape hatch for "that intermediate state looked promising, let me explore from there": the thing that's hard to do in a plain for-loop once you've thrown away the intermediates.
When not to use a loop cell¶
- Tight
forloops over short collections: a regular Python cell with aforloop is simpler and the extra per-iteration artifact overhead isn't worth it. - Loops where intermediate state is genuinely disposable: store only the final answer in a regular Python cell.
- Anything that needs to branch out into multiple parallel runs: loop cells are sequential by design. Use separate cells, or model the fan-out in Python.
A loop cell checks the cache like any other cell, so running something downstream of a finished loop does not re-run it. A loop cell cannot declare @fetch or @dataset; read those in an upstream cell.
Reach for loop cells when being able to inspect or fork from iteration k matters. That's the feature you're paying for.
R Cells (0.2.0)¶
R is a first-class notebook language alongside Python. R cells run R source via the system Rscript, with the same provenance + caching + Arrow-IPC artifact pipeline as Python cells. Data crosses the language boundary as arrow/ipc: a pandas.DataFrame produced by a Python cell becomes a data.frame in the next R cell; an R data.frame (or tibble) becomes a pandas.DataFrame in the next Python cell. Non-tabular R values (S3 objects, lists with classes, environments) are stored as RDS and tagged r_only=true - a downstream Python cell that consumes one fails with a structured StrataRArtifactError rather than a confusing NameError.
R environments¶
R environments are managed from the Environment panel, at parity with Python's uv-backed venv:
- System R by default. A notebook with no
renv.lockruns R cells against your system R library. The R card reads System R and shows the detected R version - cells work immediately as long asarrow+jsonliteare available system-wide. - One-click renv bootstrap. Initialize renv creates a project-scoped, reproducible environment: it installs
renvif missing, inits a bare project library, installsjsonlite+arrow, and snapshots torenv.lock. Progress streams live on the card (the first run compilesarrowfrom source, which takes a few minutes). When it finishes the card flips to In sync. - Per-package install. A missing-package error in an R cell surfaces a structured hint with an Install button that runs
renv::install()+renv::snapshot()for the named package. - Automatic restore on open. Opening a notebook that has an
renv.lockrestores the project library automatically - theuv syncanalogue for R. Editingrenv.lockinvalidates R cells' cache the same way editinguv.lockinvalidates Python cells'.
renv.lock is committed config (like uv.lock); the built renv/library/ is gitignored.
On a server that isolates cell code (service mode, or any server with STRATA_NOTEBOOK_HARNESS_USER set), Initialize renv and Install are refused: renv builds packages from source, which runs their code, and writes into the notebook directory. Add packages to renv.lock where the notebook is authored and commit it; the server restores that lock as the harness user when the notebook opens. See What a cell can read.
Plots¶
R cells display plots inline, like a Python cell's matplotlib figure. Base graphics (plot(), hist(), …) and grid-based plots (ggplot2, lattice) are captured to PNG and rendered in the cell output. A bare trailing plot object auto-renders - a last-line p where p <- ggplot(...) shows the plot without an explicit print(p), mirroring the R console. A cell that draws several plots produces an ordered list of image outputs.
Warm Rscript pool¶
Warm Rscript pool. Notebooks containing R cells keep a small pool of
pre-spawned R workers (R startup, .Rprofile/renv activation, and
jsonlite/arrow loads already paid), so an R cell run skips the ~1–2s
interpreter cold-start. Workers are single-shot - one cell each, then
replaced - preserving per-cell isolation; editing renv.lock drains and
respawns the pool. Adding the first R cell starts the pool, so a notebook
that began as pure Python does not need reopening. Pure-Python notebooks
and machines without Rscript never start one.
R execution and display are complete; remaining R polish is tracked on GitHub: #84 (cross-language run-all batching).
What you need today¶
- R
>= 4.2onPATH(Strata callsRscript). - The
arrowandjsonliteR packages - either available in the system R library, or installed into a project library via Initialize renv in the Environment panel (which installs both for you).
Example¶
See examples/r_lm_vs_sklearn/ for a working notebook: a Python cell synthesises a housing dataset, an R cell fits lm(price ~ sqft + bedrooms + age + location, data = housing_train) and returns tidy coefficient + model-stats + prediction data.frames, a Python cell fits the same model with LinearRegression and prints a side-by-side comparison. The R formula's auto-factor-encoding of location is the one-line stats-edge moment.
R-cell annotations¶
The same annotation parser handles both Python and R cells (#-prefixed comments at the top of the source). Supported on R cells:
| Annotation | Effect |
|---|---|
# @name <text> |
Cell display name in the UI. |
# @env KEY=value |
Sets Sys.getenv("KEY") for the cell's process. |
# @mount data file:///x |
Binds data inside the R cell to the mount's local path (a character string - R has no pathlib.Path). |
# @timeout 60 |
Per-cell execution timeout in seconds. |
# @fetch data <url> |
Downloads a URL as a recorded input; data is the file's path as a string. |
# @nocache |
Always re-execute. |
# @worker gpu-box |
Runs the cell on an executor worker with R (see below). |
An R cell with a worker runs harness.R on that worker (a strata-worker of
this version or later), under the worker's
Rscript and its R library, which needs jsonlite and arrow
(docker build -f worker.Dockerfile --build-arg WITH_R=true). Its outputs come
back the way a local run's do, so it is a cache hit here afterwards, a
downstream Python cell reads its data frame, and an R-only value returns as RDS.
A worker whose R has renv restores the notebook's renv.lock once per lock
and runs the cell against that library; one without renv uses its own R
library (see Workers). An @fetch on an R cell
is read on this machine, so an R cell with both @fetch and @worker fails;
run it locally, or fetch in an upstream Python cell.
Loop annotations (@loop, @loop_until) and prompt-cell annotations (@output_schema etc.) do not apply to R cells, and an R cell that declares @dataset fails; read the dataset in an upstream Python cell.
What if Rscript isn't installed?¶
An R cell whose harness can't find Rscript on PATH returns a clean cell-level failure with the message Rscript not found on PATH. Install R (https://cran.r-project.org/) and reopen the notebook. No server crash, no other cells affected.
Choosing between kinds¶
| Reach for a… | When you want… |
|---|---|
| Python cell | Ordinary computation. Default. |
| Prompt cell | An AI response as a first-class, cached, DAG-participating artifact. |
| SQL cell | A query against a connected database, with bind parameters, schema discovery, and probe-based caching. |
| Loop cell | Iterative refinement where pausing or forking from an intermediate state matters. |
| R cell | A computation where R's stats / formula syntax / domain packages are the right tool, while keeping the rest of the pipeline in Python. |
Mixing is encouraged: a typical pipeline might be a SQL cell for extraction → Python cells for transformation → an R cell for the linear model → a prompt cell for narrative summarisation.
Any kind of cell can also live inside a variant group: a tabbed slot where multiple cells share one place in the DAG and you switch between them without forking the notebook.