Marimo notebooks
Python notebooks scoped to a workspace and your managed tables — reactive, governed, no separate data-science stack required.
Marimo notebooks are Python notebooks that run inside your Scrydon cluster, scoped to a workspace, and pre-wired to your managed tables. They're reactive — cells re-run when their inputs change — and they go through the same governance as everything else.
Open first, start compute when you need it
Opening a notebook is deliberately separate from running Python. Scrydon authenticates the page, reads the notebook document, and renders native editable Marimo cells without starting a kernel or allocating an isolated workload. You can inspect and edit cells immediately while the status remains Not connected.
Compute starts in either of two ways:
- Connect prepares an isolated runtime, reconciles dependencies, and connects the Marimo kernel without running any cell.
- Run on a cell while disconnected performs that same preparation, then sends the selected Run exactly once after the kernel is ready.
Both paths show the current phase: authorizing, provisioning isolated compute, loading source, syncing dependencies, connecting governed storage, and ready. Repeated clicks, browser retries, or another tab joining the same preparation do not create duplicate runtimes.
Edits made before connecting are included when the runtime starts. Merely opening or editing the document does not install packages, mint a storage delegation, or call the Runtime Plane.
Runtime sizes
Every notebook runtime runs at one of four named sizes. A size sets the memory and CPU available to the Python kernel:
| Size | Memory | CPU |
|---|---|---|
| Extra small | 512 MB | 0.25 CPU |
| Small | 1 GB | 1 CPU |
| Medium | 4 GB | 2 CPU |
| Large | 8 GB | 4 CPU |
An organization picks one size for notebooks opened for editing and one for published apps. Out of the box, editors run at Small and published apps at Extra small. An organization admin changes either in Settings → Compute (Notebook editor size, Published app size). Larger sizes fit fewer notebooks on the same capacity.
Your deployment can cap the largest size it allows. Sizes above that cap show as Not available on this deployment in Settings → Compute and cannot be selected. Scrydon never quietly runs a smaller size than the one chosen: a runtime that would exceed the cap, or that does not fit on the available compute, is refused.
When a size change takes effect:
- An open editor keeps its size until its runtime restarts, for example after it has been idle long enough to be stopped.
- A published app starts at the new size from the next viewer session.
When a runtime is refused or runs out of memory
- Capacity. If compute is in use or the size is above the deployment's cap, the editor says that all compute for the workspace is in use or that the runtime size exceeds what the deployment allows. A published app says it could not start because compute is at capacity or its runtime size is not available. Close editors or apps you are not using. Retrying does not help while the size is above the cap: an admin has to choose a different size, or the operator has to raise the cap.
- Out of memory. When the kernel uses more memory than its size allows, it is stopped and its variables are lost. The editor names the memory limit it ran into; a published app says it ran out of memory and was stopped. Where Scrydon cannot tell why the runtime stopped, the editor says it stopped unexpectedly and may have run out of memory.
For admins: if a notebook or app keeps running out of memory, choose a larger editor or published-app size in Settings → Compute. If the size you need is not available, ask your platform operator to raise the deployment's maximum size.
Start an ontology-backed app
Choose New app in Horizon → Apps or Analytics → Notebooks. Select an ontology and either Blank app or Live operating picture. The live starter asks for an object type and scalar latitude, longitude, observation timestamp, receipt timestamp and label properties. Heading is optional. Set the object limit and freshness intervals; the interpolation interval cannot exceed the stale interval.
The live starter creates linked map, table and details components with matching query declarations. If a property is unavailable, update the ontology or its source binding first. The starter does not normalize feeds or prove source health. A blank app records the ontology dependency without adding queries.
Create notebook app saves a marimo notebook and a private publication draft, then opens the editor. It does not start compute or publish. Open Publish as app to review the saved declarations and follow the validation and publication steps below. The server rechecks the selected revision and your workspace before creating. If creation cannot be confirmed, check Notebooks before trying again: the notebook may already exist. If absent, reopen New app to refresh the context and schema.
Read app data while editing
Save the notebook's apps.declare(...) cell, then run its SDK widget cells.
Tables, maps and details can read declared ontology data directly in the editor,
including with the local development runtime. Reads use your current permissions
and the accessible published ontology revision in your active workspace.
The editor refreshes data every five seconds while access remains valid.
Operational action controls are disabled in the editor.
Source edits or loss of access close the widget connections. A brief connection problem does not: the editor keeps retrying and the widgets stay connected. If your computer slept, rerun the widget cells. After saving changes, rerun the affected widget cells. If app data remains unavailable, check that the declared ontology is installed and accessible in the current workspace and environment. Editing does not validate a release; use the separate validation, preview and publication steps below to prepare an app for Horizon.
Prepare and publish an app release
Choose Publish as app in the notebook toolbar. Opening the panel reads the saved source identity and the published ontologies you can access; it does not start compute. Save notebook edits before validating.
Choose Save draft to keep your app title, slug, description and audience for your next visit. The access declaration belongs in the saved notebook source. Draft settings belong to your user, notebook and workspace environment, and remain available after signing in again if you still have access to the notebook. Saving a draft does not publish a release. Validation saves the current settings first; publishing also saves release notes.
If another tab saves newer settings, your local edits remain visible and saving stops with a conflict. Load saved settings replaces the local settings with the saved version and clears the old candidate so you can review and validate again. If a save response is lost, Retry saving draft repeats that exact save request; it does not create a second version.
- Set the title, stable slug, description and audience. The default audience is Only me. Selecting Workspace does not grant viewers extra data access.
- In a notebook cell, use
apps.declare(...)to name the ontology and the queries, links, watches and actions used by your SDK components. Save the notebook. The panel compiles this declaration and shows a read-only access summary with exact published revision pins. It cannot edit access separately from the source. Declarations constrain runtime access; they do not replace authorization. - Review the saved source digest and choose Validate saved notebook. This starts isolated dependency preparation and initial-run validation. The panel shows progress, cancellation and bounded failure diagnostics. Editing the notebook invalidates this candidate even if the edit has since been saved.
- Choose Preview app to open the validated source in an isolated run view. The unpublished banner shows the exact source digest. Queries use your current permissions; operational actions are disabled. Editing the notebook ends the preview, including edits that are subsequently saved. Validate again to preview changed source. Closing the preview ends its session.
- Review the current release and your release notes, then choose Publish this candidate. Under Runtime and packages the panel shows what the validation covers: the runtime image, the marimo and Scrydon SDK versions, and the digest of the resolved package set. Individual package names are not listed. Under Live queries and their declared freshness each live query shows the staleness bound its declaration sets (stale after a number of seconds). That is the declared bound, not a measurement of how fresh the source is now. Validation alone does not publish. A concurrent publication is refused so you can review the new current release. After a confirmed publication, choose Open in Horizon to view the app under your current permissions.
When you reopen Publish as app for a saved app, the panel shows its current release and Open in Horizon without another validation. A withdrawn release has no open link.
To take a published app down, choose Withdraw app in the same panel and confirm. New sessions are blocked and every open session is revoked. Only the notebook that published the current release can withdraw it: when the live release came from another notebook, the panel does not offer Withdraw app and a direct request is answered as not found. To make the app available again, publish a new release.
To go back to an earlier release, open Publish as app, find it under Earlier releases and choose Roll back to this release. New sessions open that release; open sessions stay on theirs. Rollback does not restore data, permissions or watch settings, and it checks current policy and dependencies again. You can roll back only to a release this notebook published, only while the app is published, and only when the live release was also published by this notebook. Otherwise the panel does not offer Roll back to this release and a direct request is answered as not found. A release published before environment binding needs a new validation and publication instead.
A release runs only on the runtime image it was validated with. When the platform is upgraded and admits a new Marimo runtime image, a release validated on the previous image cannot start on it. Re-validation after a platform upgrade describes how Scrydon moves it to the new image without an author.
Preview is a view as the author, not an impersonation of the intended audience. A lost or expired session removes the frame. Retry starts a new session only if the saved source and validation are still current; otherwise save, refresh and validate again. Previewing never changes the published release or starts a watch.
For example, in a marimo cell that receives apps from from scrydon import apps:
@app.cell
def _(apps):
access = apps.declare(
ontologies=[apps.ontology("maritime", alias="data")],
queries=[apps.query(
"vessels", ontology="data", object_type="Vessel",
properties=["name", "latitude", "longitude", "observedAt"],
max_objects=1000, live=True, stale_after_ms=60_000,
max_interpolation_gap_ms=10_000,
)],
)
return accessUse literal values in this declaration. Dynamic variables and computed calls cannot grant publication access. The server resolves the ontology name to an accessible published revision and validates the permit before preparation. Existing notebooks that only saved access JSON in a draft must add this cell before creating another candidate. Published releases remain unchanged.
If preparation returns no confirmed result, Retry the same preparation request uses the original request. If it was already accepted, the server returns that candidate's current status even if the saved source or runtime profile has changed. It does not rebuild the candidate. Changed notebook source still requires fresh validation before publication.
If validation reports The published app runtime profile is unavailable, no candidate was accepted. The deployment must provide a published Marimo runtime before validation can continue. You can try Validate saved notebook again after that runtime is available; this starts a new preparation request.
Validation checks the saved candidate's initial run. It cannot prove every possible input path or that each audience member can access its data.
If an older candidate requires environment revalidation, choose Edit settings and validate again and publish the newly validated candidate. Existing release history remains available. For an uncertain earlier publication, check its original result before starting a replacement request.
If publication has an unknown outcome, choose Check publication result. This reads the outcome of the same request without publishing again. A confirmed commit identifies the release created by that request; release history separately identifies the current release.
After a page reload or a new sign-in, open Publish as app, find the original entry under Publication requests, and choose Recover request. Review its saved title, request ID, release notes and candidate digest, then choose Check publication result. Recovery works for your own requests while you retain notebook and app access. It remains useful after editing the notebook or after validation expires. It does not rerun validation, change release notes or publish another release. Older requests are available through Older requests.
An unknown result is not confirmation of failure. Keep checking that same request; do not create a replacement publication to resolve an uncertain outcome. Once a result is confirmed, Edit settings and validate again returns to your saved draft. Preparations that have not reached a publication request cannot yet be restored after a page reload.
Re-validation after a platform upgrade
When an operator has turned on automatic re-validation (the
analytics.appRevalidation.enabled Helm value, off by default), Scrydon
re-validates each published app on the new image without an author. For every
app it:
- Restores the release's saved package set on the new image.
- If that set does not match the new image, rebuilds it pinned to the exact
package versions the release was published with.
marimoandscrydonfollow the new image; every other package stays at the version the author validated. A package the new platform version pins at a different version fails the rebuild instead of being dropped or upgraded. The rebuild installs from your package index, so it needs the same approved package egress as an author's build. - Runs the app's initial run on the new image, as validation does.
- Publishes a new release on the new image. The platform publishes it, not a person, and its release notes read Re-validated on runtime image … (was …). The earlier release stays in the release history. A rollback to a release validated on an earlier image is re-validated the same way.
Re-validation changes only the runtime and the package set. It never changes the notebook source, the app's data access, its audience, its pinned ontology revisions or its classification marking. If one of those would have to change, the platform does not re-publish the app, and its author publishes it again.
While a release cannot start on the new image, Horizon shows The required app runtime is unavailable with one of four messages:
- Updating this app for the new platform version. Re-validation is queued or running. Nobody needs to act; try again shortly.
- This app needs its author to publish it again. Re-validation failed, or it is waiting on something only the author or an administrator can fix.
- A newer version of this app is available. Retry to open it. The author published the app again after this tab opened it. Retrying opens the newer release.
- This release was validated on an earlier app runtime, so it cannot start on this one. Ask its publisher to validate and publish the notebook again. The platform knows of no re-validation for this release: automatic re-validation is off, or it has not reached the app yet.
As the author, open Publish as app. While the platform is working on the app's live release, or has failed to, the current release shows a notice with the short digest of the target platform image:
| Notice | What it means and what to do |
|---|---|
| Updating this app for the new platform version. | Re-validation is queued or running. Nothing to do. |
| This app could not be re-validated for the new platform version yet. It will be retried. Publishing it again updates it now. | The platform could not restore or rebuild the saved packages right now, for example because the package index is blocked by your egress policy, or the release was published before environments were saved with it. Scrydon retries, waiting longer each time up to a day. Allow the package hosts, or validate and publish the notebook again. |
| The app's initial run failed on the new platform version. Check the notebook's cells and packages, then publish again. | The initial run failed on the new image. Choose Validate saved notebook to see diagnostics for the failure, fix it, and publish again. |
| The packages this app was published with cannot be installed on the new platform version. Publish it again with updated packages. | Update the notebook's packages to versions that install with the new platform, then validate and publish. |
| A pinned ontology dependency no longer validates. Publish the app again against its current ontologies. | An ontology revision the app pins moved or is no longer visible. Validate and publish the notebook against the current ontologies. |
| The platform refused to re-pin this release automatically. Publish the app again to update it. | Something the platform may not change automatically would have had to change. Validate and publish the notebook again. |
A failure is final for that platform image: Scrydon does not retry it, and the next platform upgrade tries again. A failing initial run or package install is tried twice before it is reported, because a first failure can be temporary. The notice names the reason only; it does not list the failing cell, package or ontology. Publishing the app again always replaces the release and clears the notice.
What they're for
| Use case | Why marimo |
|---|---|
| Ad-hoc analysis on a managed table | No need to export — query the table from Python with the user's masks already applied |
| Build a one-off report | Reactive cells make iterating quick |
| Sketch out a feature engineering pipeline before turning it into a workflow | Same data surface, same governance |
| Run model evaluations against historical data | Notebooks can call back into the workflow engine for inference |
How they're scoped
A notebook is owned by a workspace and authenticated against the user who opened it:
- Opening the document grants no compute or storage credential.
- When compute is requested, Scrydon creates a short-lived, notebook- and runtime-bound delegation inside a trusted broker. The Python kernel receives no bearer token, database password, cloud credential, Dapr identity, or Kubernetes token.
- Reads apply your column masks — the same policies the Analytics table preview enforces.
- Reads and writes re-check your live workspace membership and access policy on every brokered operation and land in the audit trail under your identity.
- The runtime cannot reach the raw Iceberg catalog or storage provider credentials.
There is no marimo-admin super-user that bypasses governance — the notebook is just another caller.
The scrydon data SDK
Every notebook has the scrydon Python library pre-installed. App authoring also includes anywidget and jsonschema automatically; you do not need to install them in a notebook cell. It gives you a governed read/write loop against your workspace's managed tables:
import scrydon
from scrydon import tables
scrydon.whoami() # who am I connected as, and until when?
tables.list() # DataFrame of the workspace's managed tables
tables.get("suppliers") # schema + governance flags (masked/hidden per column)
# Read — column masking already applied, paginated under the hood
df = tables.read("suppliers", limit=10_000)
df.attrs["scrydon"] # {"table_id": ..., "masked_columns": [...], ...}
# Write back — append | upsert | replace
tables.write("suppliers", df, mode="append")
# Publish a brand-new managed table from a DataFrame
tables.create("supplier_risk_scores", scores_df, classification="internal")
# Idempotent create for re-run notebooks: reuse the table if it already exists
# instead of raising (its schema and rows are left as-is — add rows with write()).
tables.create("supplier_risk_scores", scores_df, classification="internal", exist_ok=True)
# Declare a primary key: upserts, and any ontology binding over the table, need one
tables.create("supplier_risk_scores", scores_df, primary_key=["supplier_id"], exist_ok=True)Everything a create or write produces is a first-class managed table backed by the Iceberg lakehouse — the same storage every Analytics-created table uses. It shows up in Analytics → Tables immediately (never as a "Legacy" StarRocks-native table), takes column access policies, can be bound to ontology object types, and is readable by workflows, chat tools, and BYO Iceberg engines.
Mask round-trip guard. If your role sees masked placeholder values in a column, tables.write refuses to write those columns back — writing placeholders would silently corrupt the table. Drop the masked columns from your DataFrame first (they're listed in df.attrs["scrydon"]["masked_columns"]).
Workspace objects are also available through bounded, governed storage helpers and the standard fsspec interface:
from scrydon import fsspec, storage
with fsspec.open("workspace://reports/summary.json", "rb") as source:
summary = source.read()
content, observed_digest = storage.read_bytes_with_digest("reports/summary.json")
storage.write_bytes(
"reports/summary.json",
updated_content,
expected_sha256=observed_digest, # prevents overwriting a concurrent edit
)If another tab or job changed an object after you read it, the write returns a conflict instead of overwriting the newer value. Read the current version, merge deliberately, and retry.
You can also use any installed Python library — pandas, numpy, matplotlib, altair, … — or install additional packages (e.g. plotly) straight from the notebook. See Python packages and egress below.
Ontology instances
Managed tables are the physical layer; the ontology is the semantic layer over them — object types like aircraft or supplier bound to a table, with identity columns that define what makes a row unique. scrydon.ontology lets a notebook read and write those object types by name, without knowing which table backs them:
from scrydon import ontology
# Which object types have data bound in this workspace?
ontology.list_types() # DataFrame: ontology_slug, object_type_slug, label
# Read instances — each row is `id` (the instance's canonical key) plus one
# column per property. Masking, row filters, and clearance apply exactly as
# they do for the underlying table.
df = ontology.read("aircraft", limit=1_000)
df = ontology.read(
"aircraft",
where=[{"property": "status", "op": "eq", "value": "active"}], # AND-combined, max 8
)
# Write instances back — an upsert keyed on the object type's identity columns.
# Existing instances are updated in place; new ones are inserted. Don't include
# the `id` column — it's computed server-side from the identity columns.
ontology.write("aircraft", df.drop(columns=["id"]))A write goes through the same governed write path as tables.write — the platform resolves the bound managed table, applies the classification cap and mask round-trip guard, and audits the write under your user. The rows appear in the Analytics table preview and in the ontology instance graph immediately.
Identity columns are required to write. Upserting instances needs the object type's binding to declare identityColumns in the Ontology Workbench — that's what tells the platform which existing rows to update. Without them, ontology.write raises OntologyIdentityError. Reads work regardless.
Knowledge search and LLM helpers
The same session token also powers governed knowledge-base search and LLM completions — no API keys to manage, and the platform's data-loss-prevention scanning, billing, and audit all apply automatically:
from scrydon import knowledge, llm, embed
# Semantic search over the knowledge bases in this workspace environment.
hits = knowledge.search("What is the Q3 maintenance schedule?", top_k=5)
for hit in hits:
print(round(hit["score"], 2), hit["content"][:100])
# Restrict to one knowledge base by id
hits = knowledge.search("supplier onboarding", collection="kb-id-here")
# LLM completion through your organization's default LLM integration.
answer = llm.complete("Summarize these findings in two sentences: ...")
# Full transcript + response metadata
result = llm.complete(
messages=[
{"role": "system", "content": "You are a terse analyst."},
{"role": "user", "content": "Why partition large tables?"},
],
detail=True,
)
print(result["content"], result.get("tokens"))
# Embeddings through your organization's embedding integration.
vectors = embed(
["the quick brown fox", "lorem ipsum"],
model="openai/text-embedding-3-small",
)
print(len(vectors), len(vectors[0]))All platform capabilities
Every platform AI capability is available through the governed
/api/platform/v1/** operation family behind simple helpers. Each call runs
through your organization's integrations with credentials, DLP, billing, and
audit applied server-side; the notebook never sees a vendor API key:
from scrydon import stt, ocr, tts, image, video, moderation
# Transcribe audio — pass a file path or raw bytes.
transcript = stt.transcribe("interview.mp3", language="en")
# Extract text from a document (PDF / image).
text = ocr.extract("scan.pdf")
result = ocr.extract(pdf_bytes, mime_type="application/pdf", detail=True)
# Synthesize speech — returns decoded audio bytes.
audio = tts.synthesize("The pipeline finished successfully.", voice="alloy")
with open("status.mp3", "wb") as f:
f.write(audio)
# Generate images — entries carry a URL and/or inline base64, per vendor.
images = image.generate("A schematic of an Iceberg table layout", n=2)
# Generate video — returns a URL or inline bytes, per vendor.
clip = video.generate("A drone flyover of a container port", duration=5)
# Classify text for content-safety categories.
# Each result carries flagged, categories, categoryScores, and orgVerdict.
results = moderation.moderate("Text to classify for harmful content.")
for r in results:
if r["orgVerdict"] == "fail":
print("Policy fail:", r.get("triggeredCategories", []))Each helper accepts an optional model="vendor/model" override and raises a clear <Capability>NotConfiguredError when your organization has no matching integration — an org admin can enable one under Settings → Platform → Extensions. Binary inputs (audio, documents) accept a file path (MIME type inferred from the extension) or raw bytes, and are capped at the platform upload limit.
Web search and integration tools
The token also reaches the platform's governed web search and your organization's enabled integration tools:
from scrydon import search, tools
# Web search through the org's configured search integration.
for hit in search.web("Apache Iceberg snapshot expiry", max_results=5):
print(hit["title"], hit["url"])
# Discover the org's enabled integration tools (name, description, input schema).
for tool in tools.list("send a message"):
print(tool["id"], "-", tool["description"])
# Run one — parameters are validated server-side against the tool's schema.
output = tools.run(
"org:acme:slack:send-message",
{"channel": "#alerts", "text": "Pipeline finished."},
)tools.run executes through the same governed bridge as workflows and chat: organization policy, server-side credential resolution, DLP, and audit all apply, and vendor code runs only inside the platform's integration sandbox. search.web accepts an optional provider= to pick a specific configured search integration (credentials still resolve server-side), and an optional recency= of "day", "week", "month", or "year" to keep only results published within that window. Each hit carries a publishedDate (ISO 8601) when the provider supplies one.
Governance notes:
- Search results respect your clearance and workspace membership — the check runs on every call, so access changes take effect immediately, not at token expiry.
llm.completeuses the organization's default LLM integration (or passmodel="vendor/model"to pick an enabled one). If none is configured, you'll get a clearLlmNotConfiguredError— an org admin can enable one under Settings → Platform → Extensions.embedrequires an explicitmodel(unlikellm.complete) — embedding dimensions must match wherever you store the vectors, so there's no silent default. If no embedding integration is configured, you'll get anEmbeddingNotConfiguredError.- Which capabilities and tools actually work is decided by your organization's enabled integrations — the notebook token only says you may ask. A missing integration returns a typed
...NotConfiguredError, never a silent fallback.
Notebooks from extensions
Installed extensions can ship ready-made notebooks. On Analytics → Notebooks, use From extension to browse notebooks shipped by your organization's installed extensions and add one to the current workspace — it becomes a normal workspace notebook you can open, edit, and re-sync like any other.
Creating and uploading notebooks
Analytics → Notebooks → New Notebook creates a fresh notebook from a template — or uploads an existing marimo .py file (use Upload .py file in the same dialog).
Ready-made demo notebooks live in the scrydon/packs repository under notebooks/: a lakehouse quickstart, a publish-a-dataset walkthrough, and a governed write-back demo. Download a file, upload it into your workspace, and run it top to bottom.
Reactivity
Marimo's reactivity is what makes it useful here. Change a parameter at the top of the notebook (e.g. the table name, the date range, the filter), and every dependent cell re-runs. No stale state, no "run all cells" rituals.
Sharing
Notebooks are workspace-scoped. Sharing a notebook with another workspace member is a one-click op — they get a read-only view that re-runs in their own session, with their own masks applied.
Notebooks do not export raw data. A user with low clearance who opens a notebook produced by a higher-clearance user re-runs the queries under their own identity — they don't see the high-clearance user's cached results.
What they don't do
- Marimo notebooks are not a replacement for the workflow engine. They're for analysis, not for production triggers.
- Pipeline runs use fresh, single-use isolated compute and never reuse variables or files from your interactive session. For a stable schedule or production trigger, call that job from a workflow or automation.
- They don't expose a public HTTP API. They're a UI surface.
Python packages and egress
The notebook runtime ships with a pinned base environment and the scrydon helper library. Everything else a notebook uses is declared in the notebook itself, in its PEP 723 script metadata block, and reconciled with uv before the kernel is marked ready:
# /// script
# requires-python = ">=3.12"
# dependencies = ["marimo", "pandas", "plotly", "scrydon"]
# ///New notebooks are created with this block already in place.
Adding a package
Use any of Marimo's install affordances — they all do the same governed thing:
- the Install button on a
ModuleNotFoundErrorbanner, - the Packages panel in the left sidebar,
- the install link beside a missing import in a cell's output.
Scrydon adds the package to the notebook's dependency block, resolves and installs it, and saves the notebook — in that order. If the install fails, the notebook is left exactly as it was and the reason is shown. On success, re-run the cell to pick the new module up.
Because the package is recorded in the notebook source, it survives a reload, a replacement runtime, and a single-use pipeline job. There is no such thing as a package that is installed but undeclared.
Platform-managed packages
marimo and scrydon are installed by the platform from verified artifacts. You can leave them listed in the dependency block — new notebooks do — but a version constraint or extra on either is ignored rather than honoured, because the platform's own build is what gets installed either way. Removing them from the block is refused.
Notebooks written before this behaviour shipped may carry an auto-generated pin such as marimo>=0.24.0. It is ignored on the next preparation; you do not need to edit anything.
Restrictions
A declared dependency must be a plain requirement — a name, optional extras, and an optional version specifier. Direct references (package @ https://…, git+https://…, a local path, -e) are refused: they are not reproducible from the notebook source, which is the whole point of declaring them.
Packages that ship in the runtime's base image (pandas, numpy, and Marimo's own dependencies) are pinned by the platform. Declaring one is harmless, but asking to upgrade it does not move it off the pinned version.
Dependency reconciliation requires approved package egress. For public PyPI, pypi.org and files.pythonhosted.org must be allowed by your organisation's egress policy. A denied host fails preparation before the requested cell runs.
Connection or dependency preparation fails
- Isolation unavailable — no qualified compute tier can satisfy your organisation's policy. Ask an operator to restore the Runtime Plane or Kata/KVM capacity; Scrydon does not fall back to the Analytics host.
- Package egress denied — the package index or download host is absent from the egress allowlist.
- A package would not install — the reason is shown where you asked for it.
No compatible version could be resolvedmeans no distribution matches this runtime's Python version and architecture;The notebook's dependency block is invalidmeans an entry in the block is not a plain requirement;The notebook changedmeans another tab saved first — reload and try again. - Preparation timed out or failed — choose Retry. Retry creates a new fenced generation and cleans up the failed one; it does not replay a cell that may already have crossed the execution boundary.
- Source conflict — another tab or job saved a newer notebook version. Reload, merge your edits, and save again.
- Stale connection after replacement or idle culling — reconnect. The old lease cannot attach to the replacement runtime.
Remedies for blocked dependency reconciliation:
-
Ask an IT admin to enable egress. An org admin can open Settings → Governance → Egress and either enable Allow Scrydon default traffic (covers PyPI:
pypi.organdfiles.pythonhosted.org) or add a custom domain to the allowlist. See the Notebook egress governance guide. A changed policy replaces the runtime before the next execution. -
Upload the package manually. A future release will let you upload Python wheel files directly to the platform, making them available without any egress. This is the path for completely air-gapped deployments. (This capability is not yet available — check the release notes for when it ships.)
If Allow Scrydon default traffic is enabled, packages still need a compatible distribution for the runtime's Python version and architecture. Resolver details stay in operator logs; the notebook receives a stable, safe failure reason.
Maps and other browser-rendered resources
Some libraries render output that fetches assets from the browser rather than the kernel — for example a Plotly map (go.Scattermap) loads its basemap tiles client-side. Those requests are governed by the notebook document's Content-Security-Policy, not the sandbox egress allowlist, so allowing a host for package installs does not by itself let a map's tiles load.
- Basemaps work out of the box when Allow Scrydon default traffic is enabled. The platform's sanctioned basemap (CARTO) is permitted for notebook output, so use a token-free CARTO style —
style="carto-positron"orstyle="carto-darkmatter"— and the map renders. If your org has opted out of Scrydon default traffic, no basemap is permitted and the map area stays blank. - Any other host — including
style="open-street-map"— is an explicit operator opt-in. OpenStreetMap's public tile CDN is a separate third party that observes each viewer's viewport, so it is not part of the sanctioned defaults. An operator can allow it: in Settings → Governance → Egress, add the host (tile.openstreetmap.org) and turn on Browser resources for that entry. Adding a host without that switch allows only the kernel to reach it, which is why a map can stay blank even though the host is on the allowlist. See Notebook egress governance. - The notebook tells you when this happens. If a resource is refused, a notice appears above the notebook naming the exact host — you do not have to open the browser console to find out why a map is blank. It only appears for resources an administrator can allow (images and data fetches); a blocked script is refused by design and is never presented as something to request.
- Tiles are not the only case. Any output that pulls images, fonts, or data straight from an external host in the browser is subject to the same document policy. Keep notebook output self-contained, or use the sanctioned basemap, unless an operator has widened the policy.
Related
- Architecture → Analytics stack — where marimo sits in the cluster.
- Managed tables — what the notebooks read from.
- Classification & masking — what governance applies.
- Notebook egress governance — configure which external hosts notebooks can reach.
- Connect an external engine — your notebook and a Spark or Trino cluster read the same governed Iceberg catalog; an engine token is all they need.
Ontology app components
The scrydon.apps module provides components for the ontology app host.
In Horizon, the table reads a declared query
from the app's published release under your current permissions. Filters and
ontology revisions come from that release; a widget cannot choose another source.
The table requires the trusted app host. Published frame transport uses an isolated run document; native
controls and widget interactions do not grant access to notebook editing or
package management. An expired or revoked viewer session cannot keep its runtime
connection alive through widget activity.
Components follow the viewer's light or dark theme and the Scrydon visual
language: each is a card with a title and a status chip that pairs a shape with a
word (Live, a warning triangle for degraded, a dashed ring while waiting), so
status never depends on colour alone. Lay components out with mo.hstack and
mo.vstack; the MIK example places the map beside the
selection details with the tables underneath.
Horizon opens an authorized published release from Apps and shows startup, runtime and session failure states. A running app retains its release until you choose Open available update. Share copies an authenticated link; it grants no additional access. Open in notebook appears only when you may edit the source notebook. Expiry, denied renewal, workspace changes and page departure remove the entire app frame. Retry opens a fresh session.
While a session starts, Horizon keeps the app behind a loading indicator until its first component has rendered, so you never see a half-built page. The app then fills the page without notebook menus or cell margins.
Running it in an ordinary standalone notebook displays an unavailable state rather than opening a direct data-source connection.
from scrydon import apps
vessels = apps.table(
"vessels",
columns={"name": "Vessel", "speed": "Speed"},
selection_group="contacts",
title="Live vessels",
)
vesselsDeclare vessels and both properties in the app configuration. Components with
the same selection group share selection when their authorized results contain
the selected object. In a dependent marimo cell, read
vessels.value["selection"]: it is None or a reference with ontology,
objectType, and objectId. The ontology value is the dependency alias.
Data refreshes update existing rows without rerunning dependent Python cells.
Selection is a semantic interaction and does update Python. Losing access clears
rows and selection. The table cannot execute an operational action. Supply
translated column labels and a supported locale (en, es, fr, de, or nl)
for status messages. The platform runtime supplies the SDK and its widget assets;
local development environments need the scrydon[apps] extra.
Click anywhere in a row to select it. Use Search loaded objects to filter the displayed columns. Click a column heading to cycle through ascending, descending, and source order. Numbers sort numerically; missing values remain last. These controls operate on the bounded, authorized result already loaded. They do not widen the release query or fetch additional objects. Search and sort settings survive live updates and temporary query outages. Hiding a selected row with search keeps the shared selection, so its map marker and details remain available while the query still contains it.
Live queries backed by managed Iceberg sources can refresh from committed source notifications when the source event pipeline is enabled. Horizon also reconciles when it renews app authority, normally every five seconds. A source read failure clears the affected query's rows and selection, then retries while the session remains authorized. Other queries keep their data. Recovery preserves the notebook frame. Incomplete or oversized results show unavailable data instead of a partial table. For ontology objects backed by managed Iceberg tables, every page of a refresh reads the same committed snapshot. New ship reports can arrive during that refresh without mixing old and new pages. If a committed source notification is ahead of the snapshot just read, the platform rereads against that version boundary once. A reader that remains behind shows unavailable data and retries; the notification itself is never treated as fresh rows. An expired snapshot or a changed source, schema, mapping, or access decision makes the refresh unavailable and requires a fresh read. Source types that cannot provide this consistency guarantee are unavailable in published app queries. Reconnects and expired continuations obtain a fresh authorized snapshot automatically.
A successful read alone does not establish upstream feed health. Installed poll-backed track streams use configuration-bound evidence from every declared source to report healthy, degraded, or unknown. A committed failed poll sends a checkpoint so connected queries can refresh that status even when no vessel position changes. Sources without authoritative health evidence remain unknown. Track freshness is shown separately on the map.
Selected object details
details = apps.details(
"vessels",
fields={"name": "Vessel", "speed": "Speed"},
selection_group="contacts",
title="Vessel details",
)
detailsThe panel follows the selected object in a map or table using the same selection
group. Declare every displayed field in the query. It shows permitted scalar
values, updates them in place, and clears them when the object leaves the query
or the query becomes unavailable. No object is selected automatically. A query
outage clears selection; select an object again after recovery. Property values
are displayed as text; missing values use a dash. Tables and details show
numbers to at most six significant digits, so a stored 0.91 reads 0.91
rather than its floating-point storage, and timestamps read as
2026-10-12 03:31:00Z. Long values wrap instead of widening the panel.
Add a relationship table beside the details to inspect linked observations or other permitted objects:
reports = apps.related(
"reports",
columns={"name": "Report", "observedAt": "Observed"},
source_selection_group="contacts",
selection_group="reports",
title="Vessel observations",
)
reportsDeclare reports as an app link dependency with its source query (vessels),
target query (for example, observations), link type, and maximum object count.
The columns must be permitted properties of that target query. Both endpoint
types must be concrete; wildcard link endpoints are not supported in published
apps. Only targets present in the authorized target query and connected to the
selected source are shown. No browser-side property matching infers a relationship.
Selecting a report uses its own selection group and keeps the vessel selected.
An apps.details("observations", ..., selection_group="reports") panel can inspect
that report. Changing or clearing the vessel selection immediately clears old
relationship rows. Failures affect the relationship view without clearing healthy
vessel data. Endpoint-query updates and the normal authority heartbeat refresh
related rows. Reads spanning different sources are independently authorized;
they do not claim one atomic snapshot across those sources.
Alert review
Use a declared detection query to show severity and status filters alongside the table's search and sorting controls:
alerts = apps.alerts(
"detections",
columns={"title": "Alert", "severity": "Severity", "status": "Status"},
severity="severity",
status="status",
acknowledge_action="acknowledge-detection",
response_watch="restricted-area",
acknowledge_input="detectionId",
selection_group="alerts",
title="Detection review",
)
evidence = apps.related(
"detection-evidence",
columns={"name": "Observation", "observedAt": "Observed"},
source_selection_group="alerts",
selection_group="evidence",
title="Alert evidence",
)
mo.vstack([alerts, evidence])Declare every displayed property and both filter properties in detections.
The ontology defines the severity and status vocabulary; the component derives
options from the authorized result. Filters apply locally to that bounded result.
They persist through live updates and recovery. Filtering a selected row out of
view keeps the selection and shows its title beside the acknowledgement control.
An unavailable query or removal from the authorized result clears its selection,
details, evidence and acknowledgement control. Recovery does not reselect it.
detection-evidence must be a declared link from the detection query to a
permitted observation query. Add apps.details("detections", fields=..., selection_group="alerts") for scalar alert details. Read
alerts.value["selection"] in Python for semantic selection changes; live data
refreshes remain in the browser.
Omit acknowledge_action for a read-only list. When supplied, it names an app
action declaration permitting acknowledge_input (default detectionId). The
component fixes that input to the selected object's ID. The ontology action owns
status changes, evidence preservation and the acknowledgement audit record; the
widget does not write a status itself. Its button uses the Horizon confirmation
and result-check flow described below. Rendering, selection and data refreshes
never submit. alerts.value["action_result"] exposes the current child widget's
bounded outcome. Horizon retains an unresolved confirmed intent even if the
selected row disappears.
For watch detections, declare the ontology's
acknowledgement behavior. It accepts
only the detection ID and records who acknowledged it and when on the server.
Map status="acknowledgementStatus" to filter acknowledgement separately from
the detection's condition. The first acknowledgement is preserved across retries
and competing operators.
Set response_watch to a watch declaration ID in the app permit to add View
response in Horizon for the selected alert. The watch must emit the detection
type read by this query. Horizon checks current access to both the detection and
its recorded response flow before showing Open response above the notebook.
Click that link to navigate. Lookup never creates or advances a flow, and no flow
ID or URL enters Python or the notebook frame. Missing, ambiguous and denied
responses use the same unavailable message.
Changing selection, losing query data, or ending the session removes the result. Results also expire after 30 seconds; request a fresh lookup when needed. A failed read can be retried with the same widget control. Preview does not offer response navigation. Watches and dispatch configuration have their own lifecycle; publishing this component does not enable monitoring or automatic response.
Operator actions
Use an action declaration from the app settings to create a proposal button:
acknowledge = apps.action(
"acknowledge",
inputs={"id": "ship-1"},
label="Acknowledge vessel",
)
acknowledgeThe declaration selects the ontology action type, allowed input properties and
input byte budget. Inputs may be strings, finite numbers or booleans, up to 64
fields and 16 KiB of UTF-8 JSON. Strings allow at most 4,096 UTF-16 code units;
NUL and unpaired surrogates are refused. Python integers must be exactly
representable in JavaScript (at most 2**53 - 1 in magnitude). A declaration may
set a smaller byte budget. The action schema validates required fields and types
again at submission.
To act on a selection, create the widget in a dependent cell using the selected
object's ID, for example vessels.value["selection"]["objectId"], after checking
that a selection exists. Other inputs can come from ordinary marimo form controls.
Each widget instance fixes its inputs; rebuild it to change them.
Clicking the widget opens a Horizon confirmation dialog showing the declared action ID and exact inputs. Cancel has initial keyboard focus. Only confirmation submits. Loading, rendering, rerunning, refreshing data and reconnecting do not submit actions. Operational actions are disabled in saved-source preview.
Horizon shows the outcome above the app. An unknown outcome may already have committed: use Check result to reconcile that request. There is no automatic resubmission. One unresolved intent is retained per mounted viewer, including across notebook reruns; resolve it before proposing another action. Close a terminal result panel before starting the next action. Current authorization and existing approval requirements still apply; the widget does not grant approval.
acknowledge.value["action_result"] exposes the bounded widget outcome. Use it
for presentation, never as permission to automatically retry. Horizon's pending
intent belongs to the current viewer navigation. Closing or reloading the whole
app removes that panel; an unknown outcome must be checked in the operational
record before deliberately creating a new action. It is not recovered from
browser storage.
Live ontology map
picture = apps.map(
"vessels",
latitude="latitude",
longitude="longitude",
observed_at="observedAt",
received_at="receivedAt",
label="name",
heading="heading",
center=(4, 54), # longitude, latitude
zoom=5,
selection_group="contacts",
title="North Sea",
)
pictureDeclare all mapped properties in the query. Coordinates are numeric WGS84 degrees; observed and received times are UTC ISO strings. Heading is optional and uses degrees clockwise from north. Invalid or future-dated positions are omitted with a mapping diagnostic. The map receives the release's freshness policy from Horizon.
Updates retain the map viewport, markers and shared selection. New positions
animate from the displayed position for at most 500 ms without extrapolation.
Stale positions, large observation gaps and implausible jumps snap to the accepted
position. max_speed_mps controls this visual plausibility check (default 100);
it does not create a detection rule. Equal-time corrections snap, and older
observations do not move an existing marker backwards.
Stale markers display a stale label and a dashed outline. Their tooltips show the actual observed and received times. Short trails retain at most 64 accepted positions and break across snapped gaps. The packaged Natural Earth overview basemap requires no external tile service. Pan, keyboard navigation and zoom controls remain available during updates. Query failure clears tracks and selection; recovery keeps the map mounted.
Governed zone layers
Add zone layers to apps.map using separately declared queries:
areas = apps.zones(
"zones",
geometry="boundary",
label="name",
classification="category",
title="Operational areas",
)
# Add to the apps.map(...) call above:
# zone_layers=[areas]The zones query must permit all three mapped properties. boundary contains
GeoJSON geometry text (a JSON string with type and coordinates), for example:
{"type":"Polygon","coordinates":[[[3,52],[4,52],[4,53],[3,52]]]}Polygon holes and MultiPolygon geometry are supported. Coordinates are two numeric WGS84 longitude/latitude values, with longitude within ±180° and latitude within ±85°. Rings must close and have at least three distinct vertices. Split geometry at the antimeridian. The display does not accept URLs, Features, custom coordinate systems, or arbitrary geometry embedded in notebook code.
A geometry is limited to 16 KiB of UTF-8 text and 1,024 positions, with at most 32 rings per polygon and 16 polygons. Each layer allows at most 1,000 zones and 32,000 positions; a map allows eight layers. Label and classification must be nonempty strings of at most 512 characters. An invalid mapping, geometry, or exceeded limit clears that layer and shows a diagnostic; the display never silently truncates zone coverage. Corrected data recovers through the same subscription.
Each layer has a visibility toggle on the map, and a legend below it with its permitted names and classifications and its own source status. Hiding a layer does not pause its query or change monitoring. Visibility survives updates and query recovery. A failed zone query clears its polygons and legend while vessel updates continue; a failed vessel query does not clear available zones. Layer color identifies the layer, not a severity or classification. Current source health remains unknown unless supplied by an authoritative health contract.
Zone rendering does not validate full polygon topology or enable a watch. Monitoring rules and their geometry validation belong to the independent detection service. Source-health reporting, the remaining COP components, and end-to-end latency/load qualification are still in development.
Point layers
Objects with a last known position but no live motion — a vessel registry, fixed
sensors, ports — draw as their own layer with apps.points, passed in
apps.map(point_layers=[...]):
registry = apps.points(
"vessels",
latitude="lastLatitude",
longitude="lastLongitude",
label="name",
classification="riskLevel",
heading="heading", # optional, degrees from north
selection_group="vessels", # selecting a point selects it in that group
title="Vessels of interest",
)
# apps.map(..., point_layers=[registry])Each point is a ship symbol turned to its heading; an object with no heading, or
a layer that declares none, draws as a ring. A classification of critical or
high, elevated, medium or moderate, and low or info takes the
matching severity colour; any other value takes the layer's colour. The name
shows on hover, focus and selection. Selecting a point publishes it to the
layer's selection_group, so a table or details panel on the same group follows
it, and a selection made there highlights the point.
The query must permit every mapped property. Latitude stays within ±85° and longitude within ±180°; a heading is 0–360 or null. An object with an invalid fix is left off the map and the layer reports it; more than 1,000 objects clears the layer. A map allows eight point layers, each with a visibility toggle and a legend counting its classes.
Feed an app from a pipeline notebook
A published app reads only its declared queries; it never runs SQL. To show something derived — a join of a managed table with ontology objects — compute it in a separate pipeline notebook, write the result to a managed table, and bind an object type to that table. The app then declares a query over the new type like any other.
from scrydon import ontology, sql, tables
vessels = sql.query("SELECT vessel_id, name, last_latitude, last_longitude FROM mik_vessel_registry")
zones = ontology.read("MaritimeZone", ontology_slug="scrydon-pack-mik-north-sea-v2", limit=100)
exposure = join_positions_to_zones(vessels, zones) # plain pandas
created = tables.create(
"mik_vessel_zone_exposure", exposure, exist_ok=True, primary_key=["exposure_id"],
)
if not created["created"]:
tables.write("mik_vessel_zone_exposure", exposure, mode="replace")The table is created in the workspace environment the notebook runs in, which is
where a silver_table binding resolves its source.table. Create it with
primary_key set to the binding's identityColumns: a governed read serves a
bound table only when its primary key is that identity, and the app otherwise
shows the query as unavailable. A key cannot change in place, so with
exist_ok=True an existing table with another key raises TableExistsError.
mode="replace"
with an empty DataFrame empties the table, so a run that finds nothing clears the
previous result. Build an empty result with explicit dtypes: tables.create
infers the column types from them. The pipeline runs as the person who runs it,
under their clearance and masking; the app's viewers then read the derived
objects under their own. The pipeline notebook declares duckdb and
sqlglot in its dependency block, because sql.query needs them
(Query tables with SQL). The
MIK example ships one as exposure.py.