# Rendering & payloads
URL: https://lsd.tools/docs/tools-rendering
Updated: 2026-07-17

> To 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](/docs/tools-deliver)
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](/docs/tools-load) *takes* a [`Source`](/docs/api-source) in, a deliver step *returns* a
[`ViewerPayload`](/docs/api-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](/docs/api-viewerpayload) 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`](/docs/api-view)
[**watches**](/docs/api-view-watch) 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`](/docs/api-context): 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](/docs/api-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.

## Related

**Learn** — [Deliver steps](/docs/tools-deliver) · [How a package fits together](/docs/start-architecture) · [Naming: which is which](/docs/tools-glossary)

**API** — [ViewerPayload](/docs/api-viewerpayload) · [View](/docs/api-view) · [watch](/docs/api-view-watch) · [Tool.deliver](/docs/api-tool-deliver)

**Examples** — [Clean a CSV](/docs/ex-clean-csv) · [Multi-output mesh](/docs/ex-multi-output)

## FAQ
### 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.
