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 from https://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:

    1. Python → viewer: commands (e.g., highlight, set color-by).

    2. 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 cellucid

  • A 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)

  • Capture durable state (sessions: the whole history)

    • request = viewer.request_session(), then session = request.result() in the next cell (no browser download required); session.apply(adata) plans what applying a step would change, plan.apply() writes it

    • viewer.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:

  1. Checks the dataset and the launch choices (an in-memory AnnData is served in priority order, made from it as the viewer reads it).

  2. 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 (a MessagePort); 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 one action on 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.py and static/widget.js are 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) and data/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 origin host) and app/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 viewerId and 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.com by default); the output says when it cannot.

Troubleshooting (jump table)#

Next steps#