# Example: live node metrics
URL: https://lsd.tools/docs/ex-graph-outputs
Updated: 2026-07-17

> Declare graph_outputs={"rows": int, "cols": int} on a step and set them from inside run() with ctx.set_output(...). They render as read-only scalars on the node card, are excluded from the cache key, and StepContext.progress reports a progress bar — all cache-neutral side channels.

A node card can show live numbers — rows processed, a computed score — without those values ever
affecting caching. Declare them as `graph_outputs`, set them with `ctx.set_output(...)`, and report
progress with `ctx.progress(...)`.

## The code

```python
# tool.py
from lsdtools import Tool, StepContext, Table

tool = Tool("metrics", label="Metrics")


@tool.load
def demo(n: int = 100) -> Table:
    """A table to measure."""
    return Table({"i": list(range(n)), "sq": [i * i for i in range(n)]})


@tool.shape(graph_outputs={"rows": int, "cols": int})
def summarize(t: Table, ctx: StepContext) -> Table:
    """Pass the table through unchanged, reporting its size to the node card."""
    ctx.progress(0.3, "measuring")
    ctx.set_output("rows", len(t))
    ctx.set_output("cols", len(t.columns))
    ctx.progress(1.0, "done")
    return t
```

## What each line does

- **`graph_outputs={"rows": int, "cols": int}`** — declares two read-only scalar outputs. They show
 on the node card, ride along in the step's `STEP_FINISHED` event, and are **excluded from the cache
 key** — setting them never invalidates a cached run.
- **`ctx: StepContext`** — an **injected** capability object. It is cache-neutral (progress, cancel,
 scratch dir, `set_output`) — unlike a full [`Context`](/docs/api-context), which a step may not
 read. Injected parameters never appear as form fields.
- **`ctx.progress(fraction, message)`** — reports a `[0, 1]` progress fraction; the desktop drives a
 progress bar from it. Best-effort — it never fails the step.
- **`ctx.set_output("rows", len(t))`** — records a graph-output value. `len(t)` is the row count;
 `len(t.columns)` the column count.
- The step **returns `t` unchanged** — the metrics are a pure side channel.

## Run it in Python

```python
from lsdtools import Engine

s = summarize(demo(n=100))
res = Engine().run(s)
res.output.print() # → the table, unchanged (100 rows)

print(s.rows, s.cols) # → 100 2
```

After the run, the graph outputs are readable straight off the step instance (`s.rows`, `s.cols`) —
the same values the desktop paints on the node card.

## Try changing it

- Add a `mean_sq: float` graph output: `ctx.set_output("mean_sq", ...)` from a `group_by` aggregate.
- Check `ctx.cancelled` inside a loop to bail out early on a long run.
- Write a scratch file under `ctx.scratch_dir` for intermediate debugging artifacts.

## Related

**Learn** — [Multiple outputs](/docs/tools-multi-output) · [Shape steps](/docs/tools-shape)

**API** — [Context](/docs/api-context) · [Table](/docs/api-table) · [Engine](/docs/api-engine)

**Examples** — [Multi-output mesh](/docs/ex-multi-output) · [A dockable panel](/docs/ex-panel)
