---
title: "3. Share the motion reports"
description: "Expose the detector's reports, then write a second stream, in a project of its own, that reads them and keeps a log of motion events."
type: "docs-page"
url: "https://tatolab.com/docs/tutorial/share"
site: "Tatolab"
section: "Tutorial"
---

# 3. Share the motion reports

> Expose the detector's reports, then write a second stream, in a project of its own, that reads them and keeps a log of motion events.

Right now the motion reports go nowhere: nothing outside `motion_watcher` can read them. In this step you'll **expose** them, then build a second stream in a separate project, `motion_log`, that reads them and appends each start and stop of motion to a file.

The second stream stands in for anything else that wants the reports, such as a teammate's dashboard, an alerting script or a model. It gets them without the motion watcher changing, and without opening the camera a second time.

![This step: expose the detector's reports, then read them from a stream in another project.](https://tatolab.com/media/docs/share-light.jpg)

## Expose the reports

In `motion-watcher/stream.py`, give the camera and the detector names, and expose two outputs:

```python title="stream.py" {10-12,18,19}
"""A motion watcher: the camera in a window, and motion reports other streams can read."""

from tatolab.stream import CameraSource, DisplayWindow, StreamBuilder, stream

from nodes.motion_detector import MotionDetector


@stream
def motion_watcher(stream_builder: StreamBuilder) -> None:
    """Watches the camera for motion, and shares the camera and the motion reports."""
    camera = stream_builder.add(CameraSource, name="camera")
    detector = stream_builder.add(MotionDetector, name="detector", config={"sensitivity": 0.02})
    window = stream_builder.add(DisplayWindow, config={"title": "Motion watcher"})

    stream_builder.connect(camera.output("video"), window.input("video"))
    stream_builder.connect(camera.output("video"), detector.input("video"))

    stream_builder.expose(camera.output("video"))
    stream_builder.expose(detector.output("motion"))
```

Save. `tatolab dev` loads the new version.

- **`name="detector"`** names the node. Without it, a node is named after its class in lowercase, `motiondetector`. The name becomes part of each of its outputs' addresses, so pick one that reads well.
- **`stream_builder.expose(...)`** lets other streams on this machine read an output. The motion reports now have the address `motion_watcher/detector/motion`: the stream, the node, and the port.
- Exposing the camera's video too means another stream can use the picture without opening the camera again. One process owns the device; everything else reads the exposed output.

Exposing changes nothing inside `motion_watcher`. Its nodes run exactly as before, and nothing is sent anywhere until some other stream reads.

## Create a second project

`motion_log` lives in a project of its own, with its own dependencies, as it would if another team wrote it. Open a second terminal, go to the folder that holds `motion-watcher` (not into it), and create the project:

```sh
mkdir motion-log
cd motion-log
uv init --bare --python 3.12
uv python pin 3.12
uv add tatolab-stream
mkdir nodes
touch nodes/__init__.py
```

That's an ordinary uv project: a `pyproject.toml` that depends on `tatolab-stream`, a `.python-version` pinning Python 3.12, and a `.venv`. `tatolab new` would have written a camera stream you don't need here.

## Write the node

Create `nodes/motion_event_log.py`:

```python title="nodes/motion_event_log.py"
"""Writes each start and stop of motion to a JSON Lines file."""

import json
from dataclasses import dataclass
from datetime import datetime, timezone

from tatolab.stream import RuntimeContextFullAccess, RuntimeContextLimitedAccess, node


@dataclass
class MotionEventLogConfig:
    # The file to append to, relative to this project's folder.
    path: str = "motion-events.jsonl"


@node
class MotionEventLog:
    """Appends a line to a JSON Lines file each time motion starts or stops."""

    @node.input(delivery_profile="ordered")
    def motion(self) -> dict: ...

    def __init__(self, config: MotionEventLogConfig) -> None:
        self.path = config.path
        self.moving = False

    def setup(self, ctx: RuntimeContextFullAccess) -> None:
        self.events_file = open(self.path, "a", encoding="utf-8")

    def process(self, ctx: RuntimeContextLimitedAccess) -> None:
        report = ctx.inputs.read("motion")
        if report is None or report["moving"] == self.moving:
            return
        self.moving = report["moving"]
        event = {
            "time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "event": "motion started" if self.moving else "motion stopped",
            "changed": report["changed"],
        }
        self.events_file.write(json.dumps(event) + "\n")
        self.events_file.flush()

    def teardown(self, ctx: RuntimeContextFullAccess) -> None:
        self.events_file.close()
```

It reads the detector's reports, the same dicts `MotionDetector` writes, and writes a line only when `moving` changes:

- **`delivery_profile="ordered"`**, because a log mustn't skip a report: if motion starts and stops between two reads, `newest` could miss both. (`ordered` still drops bags if a reader falls far behind, and counts them, but a reader this light won't.)
- **`setup` and `teardown`** run once each, at the start and the end, which makes them the place to open and close the file. The node runs in its project's folder, so the relative path lands next to `pyproject.toml`.
- The time written is the wall clock, for people reading the file. The reports' own `timestamp_ns` is the machine's monotonic clock, which is better for comparing two events than for reading.

## Read from the other stream

Create `stream.py` in `motion-log`:

```python title="stream.py"
"""Keeps a log of the motion watcher's motion events."""

from tatolab.stream import StreamBuilder, stream

from nodes.motion_event_log import MotionEventLog


@stream
def motion_log(stream_builder: StreamBuilder) -> None:
    """Writes each start and stop of motion to motion-events.jsonl."""
    event_log = stream_builder.add(MotionEventLog)
    stream_builder.connect(
        stream_builder.remote_output("motion_watcher/detector/motion"),
        event_log.input("motion"),
    )
```

`stream_builder.remote_output(...)` names another stream's exposed output by its address, and you connect it like one of your own.

Run it, in the second terminal:

```sh
tatolab dev
```

Wave at the camera, then stop, and look at the file from a third terminal, or after stopping `tatolab dev`:

```sh
cat motion-events.jsonl
```

```txt title="Output"
{"time": "2026-10-11T14:03:22+00:00", "event": "motion started", "changed": 0.1391}
{"time": "2026-10-11T14:03:25+00:00", "event": "motion stopped", "changed": 0.0018}
```

Your times and numbers will differ.

## What you have now

```sh
tatolab streams
```

```txt title="Output"
NAME            STATE     NODES  PROJECT
motion_watcher  attached  3      /home/you/motion-watcher
motion_log      attached  1      /home/you/motion-log
```

Two streams, from two projects, in one runtime:

- **The reader owns the link.** `motion_log` pulls from `motion_watcher`; no stream can push into another's input.
- **Each runs on its own.** Stop `motion_log` with Ctrl-C and the motion watcher doesn't notice. Edit and save `motion_log`'s node and only `motion_log` reloads.
- **Order doesn't matter.** Start `motion_log` first and its link waits, shown as `awaiting_remote` in `tatolab graph`, until `motion_watcher` starts.
- **Only exposed outputs can be read this way.** A link to an output that isn't exposed waits, saying which port it needs and that its level is internal.

To read `motion_watcher` from your other machines, expose its output as public: the runtime's MoQ relay gives each exposed output a URL, and a public one is readable from your other machines. See [Across machines](https://tatolab.com/docs/share/across-machines).

## If something goes wrong

- **The link stays `awaiting_remote`.** Check that `motion_watcher` is running (`tatolab streams`), that the address in `remote_output` matches the stream, node and port exactly, and that `motion_watcher` exposes `detector.output("motion")`.
- **`… has no .venv/bin/python`.** Run `uv sync` in `motion-log`.
- **The file stays empty.** It only gets a line when motion starts or stops. Check that `motion_watcher` logs `motion started` when you move.

## Next

Two streams are running. Next you'll look inside them while they run: [Look inside](https://tatolab.com/docs/tutorial/look-inside).
