Overview: bidirectional communication (Python ↔ viewer)#
Cellucid’s notebook integration is intentionally simple and robust:
show_anndata(...)shows a notebook widget (an anywidget) whose output holds the Cellucid web app, loaded by the browser fromhttps://www.cellucid.com.The widget connects the app to the kernel through the notebook’s own connection (a Jupyter comm): the viewer reads the dataset’s bytes from the kernel over it, and opens one channel on it, on which both directions travel:
Python → viewer: commands (e.g., highlight, set color-by).
Viewer → Python: events (e.g., selection, hover, click).
No port is opened and no server address is involved, so it works the same in
every notebook front end that shows widgets — locally, on a remote JupyterHub,
in VS Code or in Colab. The kernel handles the widget’s messages between
cells. A browser-tab viewer (AnnDataViewer) has the same
API, with the channel on a WebSocket to its local server (/_cellucid/ws).
At a glance#
Audience
Wet lab / beginner: read the “Fast path” sections and use the copy/paste snippets.
Computational users: read “Practical path” + the environment matrix + troubleshooting.
Developers: read “Deep path” + reference (schemas + endpoints).
Time
First working “select → highlight” loop: ~5–10 minutes
Full read: ~25–45 minutes (more if you try every troubleshooting recipe)
Prerequisites
pip install cellucidA notebook that shows widgets (JupyterLab is tested; Notebook 7, VS Code, Colab, marimo and Databricks are expected to work, see Supported environments matrix)
A dataset you can view via
show_anndata(...)
Fast path (what you can do)#
Once you have a viewer (from show_anndata(...)), you can:
Act on the viewer (Python → viewer): everything a person can do is an action, and every action is a method
colour by an obs field or a gene (
viewer.color_by(field=...))highlight cells by row, mask or obs name (
viewer.highlight_cells(cells=...))clear highlights (
viewer.highlight_clear_all())hide/show cells (
viewer.cells_visibility_set(cells=..., visible=...))reset the camera (
viewer.camera_reset(view="live"))read what the viewer holds (
viewer.fields_list(),viewer.history_get(), …:request.result()in a later cell)group several actions into one turn of the history (
with viewer.turn("Mark T cells"):), undone together
Receive events (viewer → Python)
@viewer.on_ready: viewer is ready for interaction@viewer.on_selection: user selects cells@viewer.on_hover: user hovers cells (debounced)@viewer.on_click: user clicks a cell@viewer.on_command_error: an action you sent was refused by the viewer@viewer.on_data_mapping: the viewer resolved or changed its data mapping@viewer.on_history_change: the viewer’s history changed (a step, a move, an edit, a tracker, a loaded session)@viewer.on_message: one envelope stream for every current event type
Pull results back (one call)
viewer.pull(adata)writes every highlight group, label, cluster, score and gene set the viewer holds ontoadata.obs/adata.uns(Round trip (results back from the viewer, columns and gene sets into it))viewer.push(adata.obs["leiden"])andviewer.gene_set_save(...)send columns and gene sets the other way
Capture durable state (sessions: the whole history)
request = viewer.request_session(), thensession = request.result()in the next cell (no browser download required);session.apply(adata)plans what applying a step would change,plan.apply()writes itviewer.load_session(path)adds a saved session’s trackers to the viewer’s history
If you want the minimal working loop, start with Quickstart: minimal round-trip (select → highlight).
Practical path (how it works, conceptually)#
One viewer = one widget (or one browser tab)#
When you run:
from cellucid import show_anndata
viewer = show_anndata(
adata,
dataset_name="My study",
dataset_id="my-study-v1",
)
Cellucid:
Checks the dataset and the launch choices (an in-memory AnnData is served in priority order, made from it as the viewer reads it).
Shows a widget whose output frames
https://www.cellucid.com/?viewer=embedded. The page asks the widget for the viewer’s id and token and a private channel (aMessagePort); the address carries no credentials.
A browser-tab viewer (AnnDataViewer) instead serves the app and the data
from one local origin and opens
http://127.0.0.1:<port>/?viewer=true&viewerId=<id>&viewerToken=<token>&anndata=true.
The viewer channel#
When the viewer page loads, it opens its channel — over the widget in a
notebook, a WebSocket to /_cellucid/ws in a browser tab — and says hello
with its viewerId and viewerToken. Python checks the token and answers
welcome; from then on the channel belongs to that one viewer.
When you call
viewer.highlight_cells(...), Python sends oneactionon the channel and returns its request; the viewer applies it as a step of its history and answers it. An action sent before the viewer has connected waits and is sent, in order, as soon as it does.When a user selects, hovers or clicks, the viewer sends an event on the channel (a selection as binary cell indices), and Python triggers your registered hooks.
See Architecture: the viewer channel for the frames and the rules.
Deep path (implementation pointers)#
If you want to trace the implementation:
Python:
cellucid-python/src/cellucid/jupyter/(viewer classes + hook registry;_widget.pyandstatic/widget.jsare the notebook widget)Python:
cellucid-python/src/cellucid/_viewer_channel.py(the channel: handshake, command outbox, events, sessions)Python:
cellucid-python/src/cellucid/_server_base.py(verified web-generation delivery)Web app:
cellucid/assets/js/data/viewer-embed.js(the embedded page’s transport),data/viewer-channel.js(the channel) anddata/viewer-host-source.js(ViewerHostDataSource; commands and events)Web app:
cellucid/assets/js/app/viewer-host/command-handler.js(a host’s actions, dispatched as originhost) andapp/main.js(wires session capture + ready event)
Important notebook constraint: between cells#
The kernel handles a widget’s messages between cells. Hooks fire and
viewer.state updates while the kernel is idle; a call that waits for the
viewer in the cell that asks (wait_for_ready(), wait_for_event(), a
session request’s or an action’s result()) raises at once and says what to
do instead; actions (push(), color_by(), …) never wait. See Supported environments matrix.
Edge cases (high-level)#
Multiple viewers: each viewer has a unique
viewerIdand its own channel.Large selections: sending huge index lists back and forth can be slow.
Callbacks run on the viewer channel’s thread, not the notebook’s: be careful with thread-safety when they touch shared objects such as your
adata.Restricted networks: the browser must load the app from
web_source_url(https://www.cellucid.comby default); the output says when it cannot.
Troubleshooting (jump table)#
Viewer doesn’t load / empty output → Troubleshooting (hooks)
Events don’t arrive → Troubleshooting (hooks) (“No events arrive”)
Commands don’t affect the UI → Python → frontend actions
Origins and browser security → Security: CORS, origins, and mixed content
Next steps#
Minimal working loop: Quickstart: minimal round-trip (select → highlight)
Commands (Python → viewer): Python → frontend actions
Events (viewer → Python): Frontend → Python events
Robust callback patterns: Writing robust callbacks
Full reference (schemas + connectivity arguments): Reference (hooks, commands, schemas, endpoints)