Write a node
Everything a Python node can declare and do, with working examples of a filter, a logger, a timed source and a source driven by its own thread.
A node is a Python class decorated with @node. It declares ports, optionally takes settings, and does its work in a few methods the runtime calls. If you’d rather learn by building, the tutorial walks through a first node step by step.
The shape of a node
Section titled “The shape of a node”Here’s a complete node that appends every motion report it receives to a CSV file. It shows most of what a node can do:
"""Appends every motion report to a CSV file."""
import csvfrom dataclasses import dataclassfrom typing import Annotated, TypedDict
from tatolab.stream import RuntimeContextFullAccess, RuntimeContextLimitedAccess, log, node
class MotionReport(TypedDict): changed: float moving: bool timestamp_ns: int
@dataclassclass MotionCsvLoggerConfig: path: Annotated[str, "The CSV file to append to, relative to the project folder."] = "motion.csv"
@nodeclass MotionCsvLogger: """Appends every motion report it receives to a CSV file."""
@node.input(delivery_profile="ordered", description="Motion reports, in the order they were made") def motion(self) -> MotionReport: ...
def __init__(self, config: MotionCsvLoggerConfig) -> None: self.path = config.path
def setup(self, ctx: RuntimeContextFullAccess) -> None: self.file = open(self.path, "a", newline="") self.writer = csv.writer(self.file)
def process(self, ctx: RuntimeContextLimitedAccess) -> None: # An ordered input can have several reports waiting; take them all. while (report := ctx.inputs.read("motion", into=MotionReport)) is not None: self.writer.writerow([report["timestamp_ns"], report["changed"], report["moving"]]) self.file.flush()
def teardown(self, ctx: RuntimeContextFullAccess) -> None: self.file.close() log.info("motion log closed", path=self.path)Use it in a stream like any other node:
logger = stream_builder.add(MotionCsvLogger, config={"path": "motion.csv"})stream_builder.connect(detector.output("motion"), logger.input("motion"))Where nodes live
Section titled “Where nodes live”Put each node class in a module of its own, such as nodes/motion_csv_logger.py, and import it into stream.py. A node class can’t be defined in stream.py itself, or inside a function: every Python node runs in its own process, and that process imports the class by its module path (here, nodes.motion_csv_logger:MotionCsvLogger). The runtime refuses a class it couldn’t import that way, with a message saying how to fix it.
Your node’s process starts in the project folder, with your project’s .venv, so it can import anything you’ve installed with uv add and open files relative to the project.
Ports are declared by decorating methods. The methods are never called, so their body is ....
@node.input(delivery_profile="newest")def video(self) -> VideoFrame: ...
@node.output(description="Detections for each frame")def detections(self) -> dict: ...- The name is the method’s name, unless you pass one:
@node.input("video", ...). Every port on a node, input or output, needs its own name, so an effect can’t have both an input and an output calledvideo. A common pattern isvideoin andprocessed_videoout, or the scaffold’svideo_from_upstreamandvideo_to_downstream. - Names are made URL-safe: lowercased, with anything other than letters, digits,
-,.,_and~turned into-. A method calledVideodeclares the portvideo. - The return annotation documents what the port carries, for you and your editor. The runtime never checks it.
descriptionis optional. Agents read it in the node catalog.
Delivery profiles
Section titled “Delivery profiles”Every input must choose how it reads. There’s no default, because the right answer depends on what the node does:
newest |
ordered |
|
|---|---|---|
| What a read returns | The most recent bag; older ones are skipped | The next bag, in the order they were sent |
| Falling behind | Skips to the present | Drops bags if it falls far behind, and counts them |
| Typical nodes | Effects, detectors, windows, dashboards | Encoders, recorders, loggers, audio processing |
Neither profile ever slows the node that sends the bags. A reader that can’t keep up loses bags at its own port, and tatolab graph shows how many.
Settings
Section titled “Settings”A node’s settings are one class, named by the annotation on the config parameter of __init__. It can be a dataclass, a TypedDict or a pydantic model:
@dataclassclass ThermalAlarmConfig: threshold_celsius: Annotated[float, "Temperature that raises the alarm."] = 60.0 zone: Annotated[str, "Name of the area this camera watches."] = "line 1"- The runtime builds the class from the
configdict you pass tostream_builder.add. Fields you leave out take their defaults. - A dataclass refuses keys it doesn’t know, so a typo raises an error when the node starts instead of being ignored. A pydantic model ignores unknown keys unless you set
extra="forbid", and aTypedDicttakes whatever it’s given. Annotated[type, "description"]describes a setting. The runtime publishes each node’s settings, with their types, defaults and descriptions, in its node catalog, which is how an agent learns what it can configure.- A node whose
__init__takes onlyselfhas no settings, and refuses anyconfigyou pass it.
Settings are fixed for the life of the node. To change one, edit stream.py; tatolab dev reloads the stream when you save.
What the runtime calls
Section titled “What the runtime calls”All of these are optional; define the ones you need.
| Method | When it runs | Context |
|---|---|---|
__init__(self, config) |
Once, when the node’s process starts | none |
setup(self, ctx) |
Once, before any data flows. Load models, compile shaders, open files and devices here. | full |
start(self, ctx) |
manual nodes only: when the node should begin. Start your own thread here. |
full |
process(self, ctx) |
Whenever there’s work: new input, or the next tick of a continuous node |
limited |
stop(self, ctx) |
When the node stops. Stop what start started. |
full |
teardown(self, ctx) |
Once, at the end. Close files and release what setup acquired. |
full |
The full context (RuntimeContextFullAccess) can create GPU resources, through ctx.gpu_full_access. The limited one (RuntimeContextLimitedAccess), which process gets, can use GPU resources but not create them. That keeps allocations out of the per-frame path. Both carry:
ctx.inputsandctx.outputs: the node’s ports.ctx.config: the node’s settings, as a dict.ctx.time: the machine’s monotonic clock, in nanoseconds.ctx.node_id: the node’s ID, astatolab graphshows it.
Keep setup reasonably quick. If it has to download model weights, download them once beforehand, so a node doesn’t spend its start-up time on the network.
When process runs
Section titled “When process runs”@node takes an execution mode:
reactive(the default for a node with inputs):processruns when a bag arrives on any input.continuous:processruns once straight away, then everyinterval_msmilliseconds. If a run takes longer than the interval, the ticks it missed are skipped, not queued. Use it for sources that poll something, or produce on a schedule.manual: the runtime never callsprocess. The node drives itself, usually from a thread it starts instart. Use it for sources that are pushed data, like a socket or a device callback.
A node with no inputs is a source, and has to choose continuous or manual; there’d be nothing to react to.
You can also ask for a scheduling priority. scheduling="high" asks the operating system to raise the node’s priority, and "realtime" asks for real-time scheduling, for a node that must keep a steady pace, such as one producing audio. If the system refuses, the node runs at normal priority and the log says so. Most nodes need neither.
A timed source
Section titled “A timed source”This one polls a sensor’s JSON endpoint once a second:
"""Reads a JSON sensor over HTTP once a second."""
import jsonimport urllib.requestfrom dataclasses import dataclass
from tatolab.stream import RuntimeContextLimitedAccess, log, node
@dataclassclass HttpSensorConfig: url: str = "http://192.168.1.50/reading.json" timeout_seconds: float = 0.5
@node(execution="continuous", interval_ms=1000)class HttpSensor: """Polls a sensor's JSON endpoint once a second and publishes each reading."""
@node.output() def reading(self) -> dict: ...
def __init__(self, config: HttpSensorConfig) -> None: self.url = config.url self.timeout = config.timeout_seconds
def process(self, ctx: RuntimeContextLimitedAccess) -> None: try: with urllib.request.urlopen(self.url, timeout=self.timeout) as response: reading = json.load(response) except (OSError, ValueError) as error: log.warn("sensor unreachable", url=self.url, error=str(error)) return ctx.outputs.write("reading", {**reading, "timestamp_ns": ctx.time})A source with its own thread
Section titled “A source with its own thread”This one publishes every UDP datagram that arrives on a port. It’s manual: start hands the node’s outputs to a thread, and stop ends it.
"""Publishes UDP datagrams as they arrive."""
import socketimport threadingfrom dataclasses import dataclass
from tatolab.stream import LinkOutputDataWriter, RuntimeContextFullAccess, monotonic_now_ns, node
@dataclassclass UdpListenerConfig: host: str = "127.0.0.1" port: int = 9000
@node(execution="manual")class UdpListener: """Publishes every UDP datagram that arrives on a port."""
@node.output() def datagrams(self) -> dict: ...
def __init__(self, config: UdpListenerConfig) -> None: self.address = (config.host, config.port) self.stopping = threading.Event() self.thread: threading.Thread | None = None
def start(self, ctx: RuntimeContextFullAccess) -> None: outputs = ctx.outputs self.thread = threading.Thread(target=self.listen, args=(outputs,), daemon=True) self.thread.start()
def listen(self, outputs: LinkOutputDataWriter) -> None: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.bind(self.address) sock.settimeout(0.2) # wake up regularly to check whether to stop while not self.stopping.is_set(): try: data, sender = sock.recvfrom(65535) except TimeoutError: continue outputs.write( "datagrams", {"data": data, "sender": sender[0], "timestamp_ns": monotonic_now_ns()}, )
def stop(self, ctx: RuntimeContextFullAccess) -> None: self.stopping.set() if self.thread is not None: self.thread.join(timeout=1.0)Keep stop prompt: the runtime gives a node a few seconds to wind down, so join with a timeout, as here, rather than waiting forever.
Reading inputs
Section titled “Reading inputs”bag = ctx.inputs.read("motion") # a dict, or Nonereport = ctx.inputs.read("motion", into=MotionReport) # typedbag, timestamp_ns = ctx.inputs.read_with_timestamp("motion")readreturnsNonewhen there’s nothing new. Always check.into=sets how strict the read is. ATypedDictcosts nothing: you get the dict, with type hints. A dataclass or pydantic model checks the bag and raises if it doesn’t fit. UseVideoFrame,AudioBlock,EncodedVideoFrameandEncodedAudioPacketfor the runtime’s own bag types.- On an
orderedinput, several bags can be waiting. Read in a loop untilreadreturnsNone, as the CSV logger does.
Several links into one input
Section titled “Several links into one input”Any number of links can connect to one input. To tell them apart, read with the link’s name:
while (received := ctx.inputs.read_from_inbound_link("readings")) is not None: bag, link_name = received # "<sender's node ID>/<port>" self.latest[link_name] = bagctx.inputs.inbound_link_names("readings") lists the links in setup, before any data arrives. A link’s name contains the sender’s node ID, which changes each time the stream loads, so learn the names there rather than writing them into your code. Bags from one link arrive in that link’s order; bags from different links can interleave in any order, so compare their timestamps if order matters.
Writing outputs
Section titled “Writing outputs”ctx.outputs.write("detections", {"labels": ["person"], "count": 1}, timestamp_ns=frame.timestamp_ns)A bag is a dict whose values msgpack can encode: Python ints and floats, strings, booleans, None, lists, dicts and bytes. Numpy numbers need converting with int() or float(). For a numpy array, send array.tobytes() along with its shape and dtype, or array.tolist() if it’s small. Keep bags small; a video bag only names its frame, which stays on the GPU.
Every reader of the output gets the bag. If nothing is connected, the write goes nowhere, and that’s fine.
Every bag carries a timestamp. When you derive a bag from a frame or an audio block, pass the source’s stamp with timestamp_ns=, as above, so downstream nodes can line things up; it’s what read_with_timestamp returns. Leave it out and the stamp is the moment of the write.
Logging
Section titled “Logging”from tatolab.stream import log
log.info("person entered", zone="door", confidence=0.91)log.warn("camera is slow", fps=4)log.error("model failed to load", path=self.weights)Each record carries your message and fields, plus the node and stream it came from. You see them in the terminal running tatolab run or tatolab dev, and with tatolab logs. Anything a node prints with print() is captured too, but log keeps the fields separate and filterable.
Use ctx.time, or monotonic_now_ns() outside process, for anything time-based. It’s the same monotonic clock every node and built-in uses, so timestamps are comparable across the whole stream. To do something “five times a second”, compare ctx.time against the next due time, as the tutorial’s detector does. Don’t sleep in process: a continuous node with interval_ms is the way to run on a schedule.
Test your logic
Section titled “Test your logic”tatolab-stream imports without the runtime, so your node modules import in a plain test too. Keep the interesting logic in ordinary functions, and test those with pytest:
import numpy
def changed_fraction(previous: numpy.ndarray, current: numpy.ndarray, threshold: float = 25) -> float: """The share of pixels whose brightness moved by more than `threshold`.""" return float((numpy.abs(current - previous) > threshold).mean())import numpy
from nodes.motion_math import changed_fraction
def test_a_still_picture_has_not_changed(): frame = numpy.full((90, 160), 100.0) assert changed_fraction(frame, frame) == 0.0
def test_a_moved_block_is_counted(): before = numpy.full((90, 160), 100.0) after = before.copy() after[0:45, 0:80] = 200.0 assert changed_fraction(before, after) == 0.25Run them from the project folder with uv run --with pytest python -m pytest. (python -m puts the project folder on the import path, so from nodes… works; plain pytest can’t find nodes.) The node class stays a thin wrapper that reads, calls your functions, and writes.
Related
Section titled “Related”- GPU effects: nodes that work on pixels.
- Use your model: nodes that run inference.
- Python SDK reference: every class and method.