Jupyter embedding architecture#
This page explains how Cellucid shows the web app inside notebooks and how the bidirectional communication works. JupyterLab is tested end to end by the project; Jupyter Notebook 7, VS Code, Colab, marimo and Databricks render anywidgets with the same widget protocol and are expected to work, but are not tested (Supported environments matrix).
If you only need the user-facing quickstart, start with:
The mental model (one sentence)#
A notebook viewer is:
an anywidget whose output frames the Cellucid app, relaying the app’s reads and its one viewer channel to the kernel over the notebook’s own connection (commands one way, events the other; no port, no server).
A browser-tab viewer (AnnDataViewer) is the same API over a local server: the
page and the data over HTTP, the channel over one WebSocket.
Components and responsibilities#
Viewer classes#
Implementation:
cellucid-python/src/cellucid/jupyter/
Key classes:
AnnDataViewer(jupyter/_viewer.py): the viewer API — hooks, state, commands, results (pull,push, …), session capture — and a browser-tab viewer that serves the data and the app viaAnnDataServer.Widget(jupyter/_widget.py, front endjupyter/static/widget.js): a subclass of bothAnnDataViewerandanywidget.AnyWidget. It prepares the sameAnnDataServerbut never binds it: requests are answered byAnnDataServer.answer_request, and the viewer channel runs over_CommSocket, a socket made of widget messages.
Convenience function:
show_anndata(...)→Widget, displayed in the cell’s output.
Identity and routing#
Each viewer has:
viewerId: names the viewer when its page opens the channelviewerToken: the secret that page proves it holds, once, in the channel’s first frame
A widget hands both to its page in a message; a browser tab carries them in
its address. A browser-tab viewer also runs its own server on its own port:
with port=None it binds 127.0.0.1:8765 (the one local port Google Drive
sign-in accepts) or, when another viewer holds it, the next free port, and prints a
note saying so; a port given explicitly is used or refused.
How the widget connects#
The widget’s front end creates a frame at
<web_source_url>/?viewer=embedded(https://www.cellucid.comby default). The address carries no credentials.The page posts
readyto its parent. The widget accepts it only from its own frame and from the app’s origin, and answersconnectwith aMessagePortand the viewer’s id and token, posted to that exact origin.Everything after travels on that port: the page’s requests (
{t: 'fetch', method, path, range}, answered with status, headers and bytes) and the viewer channel’s frames. The widget relays each to the kernel as an anywidget message (binary buffers for bodies; requests that arrive together go as one message) and back. A Python kernel takes every batch at once (it answers each request on a thread). An R kernel sets the trait_fetch_messagesto 2: at most two batches are with it at once (one being answered, the next ready), and the requests asked meanwhile gather in the widget into the next batch, sent as soon as one is answered (IRkernel spends about 1.7 ms of its own on each message: 450 requests in 450 messages took 1.28 s, in 15 messages 0.27 s). A request waiting in the widget that the page withdraws ({t: 'cancel', id}, sent when the read that asked it is cancelled, from the reader worker through the page) is dropped before the kernel reads for it. A request the kernel already holds is answered, and its answer dropped. A request the kernel leaves unanswered for long (a cell running) is waited for and said (“still waiting”), never asked again: a copy would only queue behind it. The front end is shared byte for byte with cellucid-r, so R’s widget behaves the same.In the kernel,
Widget._on_page_messageanswers requests from the dataset and feeds channel frames toserve_viewer_channel, the same code a WebSocket runs.
The web side is cellucid/assets/js/data/viewer-embed.js (the transport) and
formats/io/host-fetch.js (requests to https://host.cellucid.invalid/…,
from the page and its reader workers). The full rules are in
Architecture: the viewer channel.
Between cells#
The kernel handles a widget’s messages on its own thread between cells. So
the viewer loads after the cell that showed it ends, hooks run while the
kernel is idle, and a call that would wait for the viewer on that thread
(wait_for_ready, wait_for_event, a session request’s result) raises at once
(Widget._require_answerable) with what to do instead. Actions never wait:
each returns its ActionRequest at once, and its result() collects the
answer in a later cell; push holds a column it cannot build yet (a format
only the browser reads, before the viewer said how many cells it has) and
sends it on the ready event. request_session() asks now; its result()
– or apply_session_to_anndata(adata, request), which returns the plan –
collects in a later cell. load_session(path) sends the file now; its
result() collects the viewer’s answer in a later cell.
How a browser tab’s URL is constructed#
The viewer URL is built from the client-reachable server URL plus query params:
viewer=trueviewerId=<id>viewerToken=<token>anndata=true
Key method:
AnnDataViewer._get_client_server_url(): the exactclient_server_url=argument, when supplied, or the bound data-server URL otherwise. It does not infer or probe a proxy (see Configuration, environment variables, and logging).
The viewer channel#
Python → frontend: actions and commands#
When you call:
viewer.highlight_cells(cells=[1, 2, 3], color="#ff0000")
the generated method (_actions_generated.ViewerActions) calls
viewer.act("highlight.cells", ...), which checks and converts the
parameters by the action catalogue (_actions.action_params: cells by row,
mask or obs name as uint32 original positions), registers the request its
answer completes, and sends one action command. Python validates the
command (_wire._require_frontend_message) and sends it on the channel
(cellucid._viewer_channel.send_viewer_command): an action whose parameters
hold arrays as one binary frame (the JSON header names each array’s place in
the parameters, then the arrays, little-endian), every other command as one
JSON text frame. Inside with viewer.turn("QC"): every action names the
turn (turn, turnLabel), so the viewer records its steps as one turn of
the history. Before the viewer has connected, the command waits in the
viewer’s outbox and is sent, in order, when it connects.
Every change and every read is an action (colouring, highlighting,
filtering, labels, pushed fields and gene sets, the camera, the data mapping,
analyses, the history). The other commands are freeze before shutdown,
requestSessionBundle for the session, load_session (a session file
pushed into the viewer, one binary frame), and ping / debug_snapshot.
Frontend → Python: events (hooks)#
The viewer sends events on the same channel:
JSON text frames (hover, click, ready, pong, debug snapshot, command errors, data mapping, color-by, result removals, the results inventory, history changes and a loaded session’s answer)
binary frames for selections (little-endian
uint32cell indices, strictly ascending), results (cells, then values), action answers (the outcome as UTF-8 JSON) and sessions
The channel belongs to one viewer, so events name none; Python hands each to that viewer, one at a time and in order, on the channel’s thread.
In Python, AnnDataViewer._handle_frontend_message, in this order:
validates the event (
_wire._require_inbound_jupyter_event; a bad one closes the channel with1008),records the event into
viewer.state(latest snapshot; the latesthistory_changedisviewer.state.history, read byviewer.history) and the results intoviewer.results, hands it to thewait_for_event(...)calls waiting for it, an action’s answer to itsActionRequestand asession_loadedto itsLoadSessionRequest, and keeps the most recent events fordebug_connection(),triggers hook callbacks registered via:
@viewer.on_selection,@viewer.on_hover,@viewer.on_click,@viewer.on_ready,@viewer.on_command_error,@viewer.on_data_mapping,@viewer.on_history_change, and@viewer.on_message(on_messagecallbacks first).
The event is recorded before any hook runs. A hook that raises is logged
(cellucid.viewer_channel, “A viewer event hook failed”; several failures as
one ExceptionGroup) and the channel stays open; the other hooks still run,
and the viewer.state update and the wait_for_event hand-off have already
happened.
Protocol details are documented here: Hooks/events protocol and schema.
Results and sessions (the round trip)#
Every result the viewer holds (highlight groups, user-defined fields, scores,
gene sets, the visible and the opened cells) arrives as a result event when
it is made, and all of them whenever the viewer opens its dataset, so
viewer.pull(adata) in a later cell writes them without waiting
(Round trip (results back from the viewer, columns and gene sets into it)).
A whole session is captured on request:
Python sends a command on the channel:
{type: "requestSessionBundle", requestId: "..."}
The frontend writes the history as a
.cellucid-sessionfile of format 4 in memory (every tracker, its steps and the values they show; the chat bar’s conversations are left out).The frontend sends the bytes back on the channel as one binary frame (header
{type: "session_bundle", requestId: "..."}, then the file); the widget carries it in pieces.Python:
checks a pending request is waiting for it,
streams the frame to a temp file and reads and checks it whole (
cellucid.read_session),completes the request with a
session_bundleevent (includes path), or acommand_errorwith itsrequestIdfor an invalid file.
request.result()returns theCellucidSession;viewer.apply_session_to_anndata(adata, request)returns the plan of applying the step shown, andplan.apply()writes it.
This design keeps:
memory bounded (streamed to disk),
notebook UX simple (no manual file download/upload),
and allows reuse of the same session format as the web app.
Troubleshooting#
Symptom: “The notebook output is empty, or says the app did not load”#
Likely causes (ordered):
the front end does not render widgets (
anywidgetmissing where the notebook server runs),the browser cannot load
web_source_url(the widget says the page has not answered as a Cellucid viewer),the app at
web_source_urlis too old to be embedded.
How to confirm:
run
viewer.debug_connection()in a new cell and readconnected_outputs,channel_connectedandstate.
Fix:
install
anywidgetin the notebook server’s environment and reload,allow
https://www.cellucid.com, or host a current copy and passweb_source_url=.
Symptom: “Hooks worked once, then stopped”#
Common causes:
a cell is running (messages wait until the kernel is idle),
the same viewer was shown in a second output or page, which took the channel over (the first copy froze and says the viewer is open elsewhere),
the viewer was garbage-collected (lost references),
you re-ran a cell and created a new viewer, but are watching the old output.
Fix:
keep a reference to the viewer object,
call
viewer.stop()when done,create a new viewer after notebook reloads.
Symptom: “A browser-tab viewer’s page is blank”#
Likely causes: the exact source generation could not be established, the
server is not reachable from the browser (remote kernel), or it died. Read
server_health, anndata_store_probes, viewer_index_probe and
frontend_roundtrip in viewer.debug_connection(); forward the port or pass
a proxy’s base as client_server_url=; see
Server mode architecture, endpoints, and security.