notebook.toml Schema¶
notebook.toml is the committed configuration for a notebook. It declares the cells, their metadata, the per-notebook environment, mounts, workers, database connections, and any AI/secret-manager wiring. The file is human-editable and git-diffable; the matching backend writer round-trips it so external edits survive UI saves.
Runtime state - display outputs, per-cell provenance hashes, console snapshots, uv sync timestamps - lives in .strata/runtime.json, not here. notebook.toml only changes on structural edits (add/remove/reorder a cell, change a worker/timeout/env/mounts/AI settings), plus one narrow exception for who last edited a cell.
The schema is defined in src/strata/notebook/models.py::NotebookToml and the round-tripping rules in src/strata/notebook/writer.py.
Top-level keys¶
notebook_id = "01HZJV4Y9G..." # required; UUID-like, backend-generated on create
name = "Iris classifier" # human-readable display name
created_at = 2026-04-12T10:31:00Z
updated_at = 2026-05-18T18:04:22Z
worker = "fly-cpu" # notebook-level default; overridden by @worker annotations
timeout = 300 # notebook-level default in seconds; overridden by @timeout
| Key | Type | Description |
|---|---|---|
notebook_id |
string (required) | Stable opaque ID generated on create. Never edit by hand. |
name |
string | Display name. Default: "Untitled Notebook". |
created_at |
datetime (UTC) | Set on create; never updated. |
updated_at |
datetime (UTC) | Bumped on structural edits only. Runtime writers don't touch it. |
worker |
string | absent | Notebook-level default worker name. Overridden by cell-level worker (below) or # @worker annotations. |
timeout |
float | absent | Notebook-level default cell timeout in seconds. Same precedence as worker. |
[env] - Notebook environment variables¶
[env]
LOG_LEVEL = "info"
DATA_BUCKET = "s3://my-bucket"
ANTHROPIC_API_KEY = "" # blanked: keys matching KEY/SECRET/TOKEN/PASSWORD/CREDENTIAL
What gets blanked. Values for keys whose name contains KEY, SECRET, TOKEN, PASSWORD, or CREDENTIAL are written as empty strings to disk. The blanked entry is still committed so users can see which env vars a notebook expects without leaking the value into the repo. A value typed in the Runtime panel stays in the server's memory for the session, and no response or WebSocket frame carries it: clients get "__strata_masked__" in its place (see Update Notebook Default Env). In a new session the key comes back empty: fill it in the Runtime panel, or let a secret manager fill it. Until then a cell sees the server's own value for that variable, if it has one: an empty secret-looking value is a placeholder, not an override.
Whole-block elision. If every entry is either empty or a blanked sensitive key, the writer omits the [env] block entirely on save. Typing an API key into the Runtime panel doesn't churn the committed file.
[ai] - LLM configuration for prompt cells¶
| Key | Type | Description |
|---|---|---|
model |
string | Notebook-level default LLM model. Overridden by # @model <id> in prompt cells. |
base_url |
string | OpenAI-compatible API base URL, e.g. a local Ollama or vLLM server. |
api_key |
string | API key. Written to disk as is, not blanked, so it is committed with the file; prefer the Runtime panel. |
max_output_tokens |
integer | Max output tokens requested. Default 4096. |
timeout_seconds |
float | Request timeout. Default 60.0. |
Every key is optional and overrides the same setting from the notebook's environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, MISTRAL_API_KEY, STRATA_AI_API_KEY) and from the server's STRATA_AI_*. See Provider configuration.
[secret_manager] - External secret-manager wiring¶
[secret_manager]
provider = "infisical"
project_id = "62a9b0f1c4..."
environment = "prod"
path = "/notebook-secrets"
base_url = "https://app.infisical.com"
Routing-only config for an external secret manager. Infisical is the only supported provider. The authentication token for the manager itself lives in the strata-notebook's process environment, not here. See Secret Manager.
Allowed keys: provider, project_id, environment, path, base_url. Unknown keys are dropped on save.
In every mode, base_url must be the server's INFISICAL_HOST (or the public default when unset); any other host is refused, since the server would log in there with its own credentials.
[[mounts]] - Filesystem mounts¶
[[mounts]]
name = "taxi_zones"
uri = "s3://nyc-tlc/misc"
mode = "ro"
[mounts.options]
anon = true
endpoint_url = "https://s3.us-east-1.amazonaws.com"
Each mount becomes a pathlib.Path variable in the cell namespace. Cells access remote data via standard Path operations; the executor materializes the prefix on first read.
| Key | Type | Description |
|---|---|---|
name |
string (required) | Identifier - injected as a Path variable in the cell. Must match [a-zA-Z_][a-zA-Z0-9_]*. |
uri |
string (required) | file:///path, s3://bucket/prefix, gs://bucket/prefix, az://container/prefix. |
mode |
"ro" | "rw" |
Default "ro". |
pin |
string | absent | Pinned version/etag - disables auto-fingerprinting. |
options |
table | Backend storage options passed through to fsspec. Common keys: anon, endpoint_url, profile. |
credential |
string | absent | Name of a server-defined credential (Named credentials) whose fields become fsspec storage options, so no secret lives in this file. options still win on a shared key. |
Cell-level mounts (under [[cells.mounts]]) supplement notebook-level ones.
Named credentials¶
A mount or a connection can name a credential instead of carrying one:
[[mounts]]
name = "raw"
uri = "s3://lab-bucket/raw"
credential = "lab-bucket"
[connections.warehouse]
driver = "postgresql"
host = "db.internal"
credential = "warehouse-ro"
The server defines what each name means, with STRATA_NOTEBOOK_CREDENTIALS:
STRATA_NOTEBOOK_CREDENTIALS='{
"lab-bucket": {"key": "${LAB_AWS_KEY}", "secret": "${LAB_AWS_SECRET}"},
"warehouse-ro": {"user": "analyst", "password": "${WH_PASSWORD}"}
}'
- A mount's fields become fsspec storage options; a connection's become driver
auth. ${VAR}resolves against the notebook's environment first, which is where a configured secret manager puts what it fetches, and then the server's.- The name is part of a cell's provenance, because a different credential can see different data. The values never are, so rotating a secret invalidates nothing.
- A name the server does not define, or a reference that is not set, fails the cell with a message naming the credential.
STRATA_NOTEBOOK_MOUNT_CREDENTIALS='{"s3": "org-bucket"}'applies a credential to every mount of a scheme that names none, so the organization's primary store works without editing notebooks.- A remote worker resolves names against its own
STRATA_NOTEBOOK_CREDENTIALS; only the name travels in the dispatch.
The @mount annotation takes the name too: # @mount raw s3://lab-bucket/raw ro credential=lab-bucket.
A named catalog in the server's STRATA_CATALOGS takes the same key, so the
catalog's secrets (a SQL catalog's database URI, a REST token, s3.* keys)
stay out of the catalog config:
STRATA_CATALOGS='{"lake": {"type": "sql", "warehouse": "s3://lake/wh", "credential": "lake-db"}}'
STRATA_NOTEBOOK_CREDENTIALS='{"lake-db": {"uri": "${LAKE_DSN}", "s3.access-key-id": "${LAKE_KEY}"}}'
Its fields become pyiceberg catalog properties, beneath the catalog's own keys.
Wherever a notebook reads the catalog (an @table snapshot, a DuckDB cell's
attach and the snapshots of the tables it reads, staleness checks), the
references resolve against the notebook's environment first, as a mount's do.
A scan through POST /v1/materialize, including client.materialize from a
cell, has no notebook, so it resolves them against the server's environment
only. A missing name fails the read with a message naming it. The
catalog's credential is not part of provenance: a table read is already keyed
by its snapshot.
[[workers]] - Remote worker registry¶
[[workers]]
name = "fly-cpu"
backend = "executor"
runtime_id = "fly-cpu-v1"
[workers.config]
url = "https://my-strata-worker.fly.dev/v1/execute"
transport = "direct"
token_env = "STRATA_FLY_WORKER_TOKEN"
| Key | Type | Description |
|---|---|---|
name |
string (required) | Worker name referenced in # @worker <name> annotations. Must match [a-zA-Z0-9][a-zA-Z0-9._-]*. |
backend |
"local" | "executor" |
Default "local". "executor" for HTTP workers; "local" for in-process. |
runtime_id |
string | absent | Stable fingerprint hashed into cell provenance. Bump to invalidate the cache for cells using this worker. |
config |
table | Backend-specific. For "executor": url, transport ("direct" push, the default, or "signed" pull-model), token (literal - dev only), token_env (env var name - preferred), and for "signed" an optional strata_url, the server address the worker uses for the manifest's URLs (default: the server's own URL). See Distributed Workers. |
[connections.<name>] - Named database connections¶
[connections.warehouse]
driver = "postgresql"
host = "warehouse.internal"
database = "analytics"
[connections.warehouse.auth]
user = "${WAREHOUSE_USER}"
password = "${WAREHOUSE_PASSWORD}"
[connections.warehouse.options]
application_name = "strata"
connect_timeout = 5
SQL cells reference these by name via # @sql connection=<name>.
| Key | Type | Description |
|---|---|---|
<name> |
section header | Connection name - referenced by # @sql connection=<name>. Must match [a-zA-Z_][a-zA-Z0-9_]*. |
driver |
string (required) | One of the shipped adapters: duckdb, sqlite, postgresql, snowflake, bigquery. MotherDuck and MySQL are planned but not yet implemented. |
auth |
table | ${VAR} indirections only, resolved at execute time and never hashed into provenance. A personal server reads them from its own process environment. A server in service mode reads them from the notebook's env ([env] and its secret manager) and never from its own, so a variable the notebook does not set fails the connection; give a connection a server-held secret with credential. |
credential |
string | absent | Name of a server-defined credential (Named credentials) whose fields become driver auth, underneath this block's own auth. |
options |
table | Driver-specific runtime tunables that don't change which objects the connection sees (application_name, connect_timeout, etc.). |
| (driver-specific top-level keys) | varies | uri, host, account, database, role, path, ... - interpreted by the driver adapter. |
catalog (DuckDB) |
string | absent | A REST or Glue catalog to attach under its name, from the server's catalogs or, in personal mode, this file's [catalogs.<name>]; the query reads each of its tables at the snapshot the cell's provenance folds. See DuckDB over the lake. |
mounts (DuckDB) |
list of strings | absent | Mounts declared for the cell (file, s3, gs or az) to expose as views by name over their Parquet, CSV or JSON files. |
Malformed connection preservation. If a [connections.<name>] block fails validation (bad name, missing driver, etc.), its body is written back verbatim under the same [connections.<name>] table on save, so a typo doesn't get silently erased by an unrelated edit. The annotation-validation layer surfaces a user-visible diagnostic.
[catalogs.<name>] - Notebook-level catalogs¶
On a personal server, a notebook can define the Iceberg catalogs its DuckDB
connections attach, instead of the server's STRATA_CATALOGS:
[catalogs.lake]
type = "glue"
"glue.id" = "123456789012"
"glue.region" = "eu-west-1"
credential = "lake-ro"
[connections.lake]
driver = "duckdb"
path = ":memory:"
catalog = "lake"
Each entry is a set of PyIceberg catalog properties, the same shape as an
entry in STRATA_CATALOGS, and credential names a server-defined
credential so no secret is written here. A notebook entry
wins over a server catalog of the same name, as a notebook's workers do.
- Only a DuckDB connection's
catalogreads these.@tableand the scans a Python cell runs resolve catalog names on the server, so they see the server's catalogs only. - A server in service mode ignores them, and a cell whose connection names one fails saying so: on a shared server the operator configures the catalogs.
[[variant_group]] - Active-variant pointers¶
[[variant_group]]
group = "model_choice"
active = "logistic"
mode = "sweep" # optional; absent means "switch"
Cells declare group membership via # @variant <group> <name> in their source. This block records which member is currently active - only the active cell participates in the DAG.
| Key | Type | Description |
|---|---|---|
group |
string (required) | Variant group identifier. Must match the group name used in # @variant. |
active |
string (required) | Currently active variant name within the group. Empty means the first variant in source order. Ignored in sweep mode. |
mode |
"switch" | "sweep" | absent |
switch (the default, written by leaving the key out) runs only the active variant; sweep runs every variant and hands downstream cells a {variant: value} dict. Any other value is treated as switch and flagged by annotation validation. |
[[cells]] - Cell registry¶
[[cells]]
id = "a1b2c3d4"
file = "a1b2c3d4.py"
language = "python"
order = 100
[[cells]]
id = "e5f6g7h8"
file = "e5f6g7h8.py"
language = "python"
order = 200
worker = "fly-cpu" # cell-level override of notebook default
timeout = 600 # cell-level override
[cells.env] # cell-level env additions (same blanking rules)
CUDA_VISIBLE_DEVICES = "0"
[[cells.mounts]] # cell-level mount additions
name = "model_weights"
uri = "s3://my-models/bge-large"
mode = "ro"
| Key | Type | Description |
|---|---|---|
id |
string (required) | Stable cell identifier. Backend generates an 8-character UUID prefix when cells are created via UI / REST; hand-edits can use any unique string (e.g. seed, top-orders). This is what @after and @loop start_from= resolve against - not @name. See Cell IDs. |
file |
string (required) | Path to the cell source under cells/. The backend names it <id>.py, or <id>.md, <id>.r and <id>.widget for markdown, R and widget cells. |
language |
"python" | "prompt" | "sql" | "markdown" | "r" | "widget" |
Default "python". |
order |
float | Display order. Float so cells can be inserted between existing ones without renumbering. Default 0. |
worker |
string | absent | Cell-level worker override. Beaten by # @worker in the cell source. |
timeout |
float | absent | Cell-level timeout override. Beaten by # @timeout. |
env |
table | Cell-level env additions / overrides. Same sensitive-key blanking as the notebook-level [env]. |
mounts |
array of MountSpec |
Cell-level mounts. Supplement notebook-level mounts. |
created_by |
string | absent | Who added the cell. Written by the server, not meant to be hand-edited. |
updated_by |
string | absent | Who last changed it. |
created_by and updated_by record who wrote a cell, so a reader can tell cells an agent wrote from cells a person did. The value is the authenticated principal where the server has one. Otherwise it is what the client declared: local for the browser, or an external agent's own name sent through MCP or --author. In personal mode nothing checks that declaration, so treat it as a claim rather than an attestation. Both keys are absent on cells written before authorship was recorded.
They are the one exception to the structural-edits-only rule above. An edit to a cell's source rewrites notebook.toml when it changes who last edited it, and at no other time. So one person editing their own cell never touches the file, and the first edit by someone else touches it once. That rewrite leaves updated_at alone, because who edited a cell is not a structural change.
Annotation precedence¶
When the same piece of metadata is declared in multiple places, the most specific wins:
# @worker X/# @timeout T/# @env K=Vannotations in the cell source (highest)- Cell-level
worker/timeout/envin[[cells]] - Notebook-level
worker/timeout/envin the top-level table
Annotations are the canonical per-cell configuration surface; there's no UI editor for per-cell overrides. See Cell Annotations.
What lives elsewhere¶
Belongs in .strata/ (runtime state, gitignored) |
Belongs in notebook.toml |
|---|---|
| Cached display outputs (DataFrames, plots, scalars) | Cell IDs, source filenames, order |
| Per-cell provenance hashes | Cell-level worker/timeout/env overrides |
| Per-cell console (stdout/stderr) snapshots | Mounts, workers, connections, variant pointers |
uv sync timestamps |
AI default model, secret-manager routing |
| Artifact-store SQLite + blobs | Notebook name, created/updated timestamps |
Runtime writers never touch notebook.toml; structural-edit writers never touch .strata/. The invariant is enforced at the writer layer.
What to commit¶
strata new writes a .gitignore covering the runtime side, so git add -A
in a notebook directory stages the right things. It never replaces an existing
one; strata new --no-git skips it.
The committed set is:
notebook.tomlpyproject.tomlanduv.lock(plusrenv.lockfor R notebooks)- everything under
cells/, including cell tests - the
.gitignoreitself
The .mcp.json and CLAUDE.md that strata agent writes are ignored: each
launch rewrites them with the server's port and the session id, so they
describe one machine's run rather than the notebook.
The rules live in strata.notebook.layout: committed_paths() returns the
set for a given directory and IGNORED_PATTERNS is what the .gitignore
contains, so this list is a rendering of the code rather than a second source
that drifts from it. The ZIP and snapshot exports carry exactly
committed_paths(), and strata import writes those files back.
Round-trip safety¶
The writer preserves unknown top-level keys verbatim. If you hand-edit the file with a key the parser doesn't know about, it survives saves - useful for experimental settings or external tooling. A malformed [connections.<name>] block (no driver, etc.) is likewise written back verbatim under its own [connections.<name>] table so you don't lose the data while you debug.