Sandbox — LSD is in active testing. All systems operational·payments are test-mode (no real charges).
Overview Use Cases News Docs Tool Store Support Community Get LSD
All docs
Docs/ Building Tools/ Rendering & payloads
Building Tools

Rendering & payloads

How a deliver step shows geometry, a chart, or a legend — return a ViewerPayload and the framework persists and routes it to the view that watches the entity.

TL;DRTo show a result in the viewer, a @tool.deliver step RETURNS a ViewerPayload (LayerPayload for 3D geometry, ChartPayload for a chart, LegendPayload for a colour legend) instead of hand-rolling ctx.emit. The framework writes the tables to Parquet, persists a sidecar, emits the payload's topic, and reconstructs it on reopen. The payload reaches any View that watch()es the entity — drag-drop, the 3D checkbox, and "Show in ▸" are all sugar over that one subscription. A custom viewer surface is a new ViewerPayload subclass plus a sink that gates each event on should_handle.

A pipeline doesn't only save results — it shows them. When a deliver step puts geometry in the 3-D viewer, draws a chart, or adds a colour legend, it does so by returning a ViewerPayload. This is the DELIVER-out half of the pipeline's I/O symmetry: a load step takes a Source in, a deliver step returns a ViewerPayload out, and the framework does the plumbing on both ends.

Return a payload, not an emit#

Python
from lsdtools import Tool, Table, LayerPayload

tool = Tool("geo")

@tool.deliver
def show_lines(t: Table) -> LayerPayload:
    """Draw the incoming geometry as lines in the 3D viewer."""
    return LayerPayload(kind="lines", tables={"path": t}, style={"width": 1.5})

The return annotation (-> LayerPayload) is what marks this deliver as viewer-delivering. When it runs, the framework:

  1. writes each table in tables to Parquet in the step's workspace,
  2. persists the spec + table paths + routing as a *_payload.json sidecar,
  3. emits the payload's topic with entity_id / step_id stamped centrally, and
  4. reconstructs it generically on reopen from the sidecar — no per-step code.

So you write one return instead of hand-rolling ctx.emit("viewer/layer/set", ...) plus your own parquet-write and reconstruct-on-reopen dance. The three built-ins cover the common surfaces:

Return Shows Topic
LayerPayload(kind=..., tables=..., style=...) 3-D geometry (the Parquet rides in tables) viewer/layer/set
ChartPayload(kind=..., title=..., series=...) a chart (spec-only by default) viewer/chart/set
LegendPayload(title=..., colormap=..., vmin=..., vmax=...) a colour legend viewer/legend/set

A plain deliver that only writes a file returns nothing — payloads are for showing.

Routing: a payload follows watch#

You never address a viewer by hand. A payload reaches whatever View watches the entity that produced it. That single rule (RFC-0002 — subscription is the routing rule) is the whole story:

  • a viewer shows a delivered payload iff it watches that payload's entity_id;
  • a viewer that does not watch the entity declines it — so two open viewers never disagree about who shows an untargeted layer;
  • the desktop's drag-and-drop, the 3-D checkbox, and the right-click Show in ▸ all do the same one thing under the hood: they call view.watch(entity).

In a script you name the link yourself with the same verb — attach the view, watch the entity, run:

Python
from lsdtools import Engine, views

engine = Engine("survey.lsd")
grades = grade_chart(...); engine.add(grades)   # an entity whose deliver returns a ChartPayload
panel  = views.chart(title="Grades"); engine.add(panel)
panel.watch(grades)      # this view now shows grades' result — and re-runs refresh it
engine.run_all()
panel.show()

The desktop's built-in 3-D viewer is itself a View the shell mounts; watching an entity from it (the checkbox / Show in ▸) is the same call. Because watch auto-pulls an entity that has already run, a payload delivered before the view existed still appears the moment the view watches the entity — which is why a reopened project draws immediately.

Terminal artifacts vs live streams#

Return a payload for a terminal artifact — the finished geometry, the final chart, the legend — because the framework persists it and replays it on reopen. For live, mid-run signals — progress ticks, deep-learning epoch metrics, log lines — keep using ctx.emit(...) on the injected StepContext: those are transient events, not artifacts, and are not persisted.

A new viewer surface#

The three built-ins are not special — they are just registered ViewerPayload subclasses. To teach the viewer a new kind of drawable, you add two pieces:

1. A payload subclass naming a new topic (see ViewerPayload):

Python
from lsd.flow.payload import ViewerPayload, register_payload

@register_payload
class HeatmapPayload(ViewerPayload):
    topic = "viewer/heatmap/set"

    def __init__(self, *, tables=None, **spec):
        super().__init__(spec=spec, tables=tables)

2. A sink on the front-end that subscribes to that topic and draws it — gating every inbound event on should_handle, the shared per-view routing rule, so your new surface follows watch exactly like layers and charts:

Python
# gui-tier: a viewer surface's sink (mirrors lsd_viewer's chart/legend sinks)
async def _on_set(self, e):
    data = getattr(e, "data", {}) or {}
    if not should_handle(data, self._target_id, self._watched):
        return                      # this heatmap belongs to a different viewer
    spec = data.get("spec") or {}   # your topic's payload shape
    ...                             # build a primitive, register it on the canvas

The sink reads its spec from data["spec"] and any table paths from data["paths"][key] — the same framework-stamped shape every payload carries. register_payload and the sink both live in the gui/product tier (lsd.flow.payload + lsd_viewer), which is why a new surface is a front-end task, not a pure lsdtools package one. The built-in geometry, chart, and legend sinks are the reference implementations.

LearnDeliver steps · How a package fits together · Naming: which is which

APIViewerPayload · View · watch · Tool.deliver

ExamplesClean a CSV · Multi-output mesh

Frequently asked questions

How does a deliver step show geometry or a chart in the viewer?

Return a ViewerPayload from the step. LayerPayload(kind=..., tables=..., style=...) draws 3D geometry, ChartPayload(kind=..., title=...) shows a chart, LegendPayload(title=..., colormap=...) shows a colour legend. The framework writes the tables to Parquet, persists a *_payload.json sidecar, and emits the payload's topic — you never call ctx.emit for a terminal artifact.

Which viewer does a delivered payload show up in?

Whichever view watches the payload's entity. Routing is by subscription (RFC-0002): a viewer shows a payload iff it watches that payload's entity_id. Two open viewers never disagree about who shows an untargeted layer — a viewer that does not watch the entity declines it.

When should I still use ctx.emit instead of returning a payload?

For live, mid-run streams — progress ticks, DL epoch metrics, log lines. Those stay ctx.emit events. Payload return is for terminal deliver artifacts only (the finished geometry, the final chart), because the framework persists and replays them on reopen.

By LSD Team · Last updated Jul 17, 2026 Ask on Discord View as Markdown