Skip to main content
Releases, breaking changes, and notable improvements to the Python runtime partners use to build and ship models on Reactor.
reactor-runtime 3.6.0 · Multi-GPU inference · The end of a recording

New

  • Run a model on several GPUs with DistributedRunner. Import it from reactor_runtime.distributed. The runner starts one process per rank and constructs your worker class in each one. One generate(input) call reaches every rank and returns rank 0’s result. A model that splits a step gathers the pieces onto rank 0 inside generate(), because the runner assembles nothing itself. Your application starts the runner in its own load() and calls it from generate(), and keeps the client’s commands and tracks. Give world_size the GPU count you ask for in reactor.yaml.
  • Write each worker as a DistributedWorker. The base class declares rank, world_size, and device, and the runner sets all three before it calls load(). Your subclass writes load(), generate(), and reset(). When every rank raises the same type of error, the call raises rank 0’s exception and the group stays usable. A crash, a timeout, or ranks that disagree raise WorkerCrashed, WorkerTimeout, or RankDesync. The runner then refuses further calls, so shut it down and construct a new one. See Multi-GPU inference.

Fixed

  • A recording keeps the frames from the end of a session. The recorder closed its encoder as soon as the session stopped, while frames it had already taken were still queued for that encoder. Those frames never reached the file, so the recording and any clip over the end of the session both stopped early. The recorder now waits up to a second for the queue to reach the encoder, and closes the encoder after that. An encoder still behind after the wait loses the rest of the queue. The runtime logs a warning with the number of frames left in it.
reactor-runtime 3.5.0 · ReactorApp · The step loop

New

  • ReactorApp is the class you subclass, and generate() is the method you write. The runtime drives the class one step at a time: each step calls process_input(), then generate(input), then process_output(outcome), and emits the Output the last one returns. generate() runs one step of inference, synchronously, and returns the result or raises. The other two have defaults, so a model that returns an Output from generate() writes nothing else. Override process_input() to refuse a step with ApplicationError("reason") or to shape what the model gets; override process_output() to map a result onto tracks, send messages, or recover from an error. StepOutcome carries what generate() did: result, error, and elapsed. A raise out of process_output() ends the session with an error; the default re-raises. See The Step Loop.
  • Declare what a client can set as state:. Annotate an InputState subclass on the app and every public field becomes a set_<field> command, validated from its InputField constraints and documented from its description. The runtime builds self.state from the field defaults when a session starts, and process_input() reads it there on every step; the hook takes no parameters. A field with a leading underscore is session scratch with no command. A hand-written @event of the same name replaces a generated setter. See Managing State.
  • Handlers run between steps. Every @event handler and lifecycle hook waits for the step in flight to finish before it runs, so a step reads one consistent state and a handler can call into the model safely. When the last client leaves or the session ends, the loop stops at the next step boundary and resets the input buffers.
  • Playout follows the model’s speed unless you pin it. A class that declares fps plays out at that rate. One that does not plays each step’s frames at the rate the step was produced, from the runtime’s own measurement of generate(), so a hand-passed compute_time is no longer needed.
  • ReactorModel is now ReactorApp, and Input is now MediaInput. Both old names still import and resolve to the same classes, with a DeprecationWarning. A model with a hand-written run() keeps working unchanged: overriding run() replaces the step loop and only the step loop, and the three hooks are then never called. The wire, the schema, and the client SDKs do not change.
  • ReactorPipeline and Idle are deprecated. Both still import from reactor_runtime and resolve to themselves, so a model on the generator pattern keeps running, but each import now raises a DeprecationWarning and writes one warning to the log. The replacement is a port, not an import edit: subclass ReactorApp and write generate(), and refuse a step by raising ApplicationError from process_input() where inference() yielded Idle. All four deprecated names are removed in the next major.
  • The runtime’s examples are split into an application half and a model half. examples/starter is the spinning logo reactor init scaffolds, written as two halves; the logo image is its weights, read from the workspace’s weights/ directory, and it runs on a CPU. examples/echo loops the client’s webcam and microphone back through one of seven video effects. examples/waypoint drives a world model from an uploaded seed frame and keyboard and mouse controls. In each, <name>.py is the ReactorApp and <name>_model.py is a plain class that imports nothing from the runtime. examples/brightness is gone; starter takes its place. See Application and Model.
reactor-runtime 3.4.0 · Faster connection setup

New

  • A connection is live sooner. The runtime answers the two WARP opt-ins, a pair of IETF drafts that shorten the WebRTC handshake. One carries the DTLS handshake inside the ICE checks, and the other lets the data channel skip a round trip of its own setup. Both are on by default, and you do not have to change your model. A client that does not ask for them gets the ordinary handshake, so what you save depends on the SDK at the other end. runtime_webrtc_connect_seconds reports the client’s wait until the wire is live.
reactor-runtime 3.3.2 · Metrics for a live wire

New

  • GET /metrics now reports how a live wire carries your media. The runtime samples each WebRTC connection and adds the readings to the Prometheus output it already serves. Per track, it counts the packets, bytes, and video frames that went each way. It also records the loss the receiver reports on what you sent, and the jitter on what you receive. The track label is the name you declared on your Output or Input class. Per connection, it records the bandwidth congestion control believes the path will carry, and two round trips. The ICE checks measure one and the receiver measures the other on the media itself, so a gap between them is a queue the checks do not pass through.
  • The runtime also counts the media it dropped or invented before it reached the wire. runtime_media_dropped_frames_total and runtime_media_dropped_samples_total count the frames and audio samples it discarded. runtime_media_silence_frames_total counts the audio frames it sent as silence because your model produced none. None of this appears in the transport readings, so read these counters beside them to tell a slow model from a poor network. Two histograms time the handshake as well. runtime_webrtc_negotiation_seconds measures the answer the runtime builds, and runtime_webrtc_connect_seconds measures the client’s wait until the wire is live.
reactor-runtime 3.3.1 · Uploads inside a list

Fixed

  • A handler that declares a list of uploads now receives the files. The runtime resolved an UploadedFile parameter, but not one nested inside a list, a dict, or a dataclass field. Each of those reached the handler as the client’s raw reference, a mapping with an upload_id key. The handler then failed when it read .data. The runtime now follows the command’s declared types and fetches every upload it finds, at any depth. It reads the declaration, not the values. A mapping of your own that carries an upload_id key still arrives as the client sent it.
reactor-runtime 3.3.0 · Per-session recording ids · Caller-supplied ICE

New

  • The runtime fixes a session’s recording id when it accepts the start. A session_id in the start parameters becomes the session’s own id. The runtime stores and addresses the recording under that id, and every log record the session writes carries it. Clips and logs then match the id the caller knows the session by. A start that names no session_id gets a fresh id, so two sessions in one runtime process never write into the same recording directory. A rejected start leaves a live session’s id alone. The transport session id does not change.
  • A caller can supply a connection’s ICE credentials and port range. Beside ice_servers, an offer to a connection’s sdp_params takes two more optional fields. ice_credentials is a ufrag and pwd pair, and port_range is an inclusive [min, max]. Leave both out and the media engine makes its own credentials and uses the configured port range. That is the usual case. They exist for a deployment that puts a relaying layer in front of the runtime. That layer has to know a connection’s ICE credentials and media address before the connection exists. A single-port range pins the connection to one port. port_range replaces the configured range. It does not narrow it, so WEBRTC_PORT_RANGE does not bound what a caller asks for. Ask for a port another live connection already holds and the runtime refuses with 409, instead of a negotiation that quietly fails later.
reactor-runtime 3.2.7 · Recording a batch's audio

Fixed

  • A recording keeps every sample of a batch’s audio. The recorder’s audio buffer held one second of samples, whatever the size of the batch handed to it. A batch that carried more than a second lost its oldest samples. The recorder then paired the audio that was left with the batch’s first frames and filled the rest with silence. A clip lost most of its audio, and what remained ran ahead of the picture. Live playout kept every sample throughout. The buffer is now never smaller than the batch it takes, so every sample reaches the recording.
reactor-runtime 3.2.6 · Traceable logs · Full-rate video

New

  • Write structured logs with get_logger(). Import it from reactor_runtime. Pass context as keyword arguments: logger.info("scene changed", prompt=self.prompt). A record prints as the message and then its key=value tokens. When REACTOR_LOG_FORMAT=json is set, each record prints as one JSON object per line.
  • Every record names the session and the phase it was written in. While a session is live, every record carries its id as session_id. One filter then finds everything that run logged. The phase arrives at two levels of detail: state, the session’s own word for it, and runtime_state, a coarser one (loading, available, serving, terminated). The runtime stamps a record where it is written, not where it is made. A plain logging.getLogger(__name__) and the libraries your model imports carry the same fields, so you never thread a session id through your own calls.

Fixed

  • Video above 960x540 no longer stops at 2.5 Mbps. Each outbound video track ran on the encoder ceiling the transport derives from the frame size, which is 2500 kbps for a frame that large. A 720p or 4K stream held that rate on a fast link, and the picture stayed soft. The runtime now gives each video track a ceiling of 10 Mbps, the limit the connection’s bandwidth estimate already had. Set WEBRTC_SENDER_MAX_KBPS to choose a different one.
reactor-runtime 3.2.5 · Nested recorder block · Every ICE candidate
Rolls up 3.2.4 and 3.2.5.

New

  • The recorder’s settings nest under runtime:. The runtime reads the recording: block as runtime.recording, beside runtime.import and runtime.config. A top-level recording: block still works, so an existing manifest needs no change. Declare the block in one place. A manifest that carries both takes the nested block whole. It reads nothing from the top-level one, so an enabled: true left up there leaves the recorder off. See Session Recording.

Fixed

  • The end-of-candidates marker no longer fails a client’s ICE request. A client marks the end of its trickle-ICE candidates with an empty candidate string (RFC 8838). The runtime rejected that marker. The request that carried it failed, and the runtime dropped any candidate sent with it. The connection then had fewer network paths to try. The runtime now accepts the marker and ignores it.
reactor-runtime 3.2.3 · Sender capture time · Recording a batch
Rolls up 3.2.1 through 3.2.3.

New

  • Read when the sender captured a frame. InputFrame.capture_time_us holds the capture time the sender declared for that frame, in microseconds, or None for a frame that carried no stamp. A sender that declares nothing gets a clock reading from its transport, so most frames carry a stamp. A client that stamps several tracks from one clock reading puts that value on all of them, so a multi-camera capture reads as one moment. Unlike pts, the stamp reads another machine’s clock: two stamps from one sender differ by that sender’s own timing. Subtract a stamp from your own clock, and you mostly measure the offset between two clocks that drift apart. See Video & Audio Tracks.

Fixed

  • A recording keeps every frame of a batch. The recorder’s feed queue held four frames, whatever the size of the batch handed to it. Anything larger waited on the encoder, and the frames past that wait never reached the recording. You saw the loss in the log as a full feed queue, and live playout kept every frame throughout. The queue is now never smaller than the batch it takes, so a whole batch fits.
reactor-runtime 3.2.0 · Opt-in moderation · Audio on the wall clock

Breaking

  • Marking a field for moderation is opt-in. InputField(moderate=True) marks a field whose text a deployment should have moderated, and the default is now False. Earlier releases marked every field unless it opted out, so a model that wants a field moderated has to say so. Mark only the fields a client writes free text into — a mark on an enum, a number, or a file changes nothing, since only free text is eligible — and only on a command that arrives occasionally, like a prompt or a script. A check runs one command at a time and admits anything still waiting after two seconds, so marking a per-frame command neither gets it moderated nor leaves room for the prompts that matter. The runtime moderates nothing itself: the mark is a preference a deployment reads off the published schema, where every field now states it either way.

New

  • A model can send more than one audio track. Outbound audio is keyed by track name, the way video always was, so two Audio fields on one Output reach the client as two tracks. Declaring two previously concatenated them into one buffer that played out through whichever track negotiated last, at twice the rate, with nothing rejecting or logging it.
  • Clients are told why a session ended. A session the platform ends can carry a human-readable reason, delivered to every connected client before its connection closes, in place of a bare disconnect. Nothing in the model changes.

Fixed

  • Audio no longer drifts against video. An outbound audio track’s clock advances only with the samples pushed onto it, so a tick the model did not fill was time the stream never accounted for — and because the packets either side of it stayed contiguous, the client read the whole stream as arriving late, grew its jitter buffer, and time-stretched audio to refill it. That stretching was the artefact and the swinging buffer was the drift. The runtime now pushes a frame on every tick, sending silence for one the model did not fill, and warns with the track’s name when it has had to manufacture a meaningful share of the last second.
  • Pausing an audio track stops the audio. A paused track is skipped before anything is read from it, so a client that pauses stops receiving packets instead of roughly fifty a second of digital silence — and the pause no longer counts against the under-production warning, which had been blaming the model for audio the client declined.
reactor-runtime 3.1.2 · Per-frame metadata
Rolls up 3.1.0 through 3.1.2. The two patch releases pin the transport dependency and change nothing you write.

New

  • Send metadata with a frame. Wrap a track’s payload in TrackPayload to tag what it emits: TrackPayload(frame, metadata={"seed": seed}). A mapping travels as JSON and bytes travel as they are, and a batch carries either one value for the whole batch or one per frame. A bare array still works everywhere it did.
  • Read the metadata a client attached. InputFrame.metadata holds the bytes the sender sent with that frame, or None when it sent none. Decoding them is the model’s business — the transport treats them as opaque. See Video & Audio Tracks.
reactor-runtime 3.0.2 · Output backpressure · The playout handle

Breaking

  • emit() waits for downstream room. A model that generates faster than its playout rate is throttled to that rate rather than piling up latency, so a hand-rolled rate limiter is now redundant and should come out. The wait runs off the model loop, so commands and lifecycle hooks keep dispatching while it holds. Pass drop=True on a producer that would rather skip a frame than wait.
  • output is the playout handle, not an annotation. Remove output: MyOutput from your model class — outbound tracks register when the Output subclass is defined, and the name now belongs to self.output. A class that re-annotates it contradicts the real attribute.
  • buffer_size must be positive when declared. Zero or less fails at startup instead of silently falling back to the runtime default.

New

  • self.output controls playout. self.output.fps = n re-paces frames that are already queued rather than waiting for the next emit, so a speed command takes effect immediately. self.output.flush() drops what is queued and cuts the client to black, which is what a scene reset wants so none of the old content plays afterwards. Both fan out to every connection, including ones that join later. See The Run Loop.
  • buffer_size bounds buffered latency. It declares how many frames may sit between the model and each client, and is never applied below one emitted chunk, so a batching model always fits a whole batch.
  • A quiet wire on underrun. When no frame is ready for a tick nothing is sent and the client holds what it has, instead of the stream spending bandwidth repeating it. One black frame marks each boundary — a connection opening, or a flush.
reactor-runtime 3.0.1 · Command failures · Recording in process

Breaking

  • An @event return annotation must name one message type, or nothing. A handler annotates a single ModelMessage subclass for a typed reply, or None for a bodyless acknowledgement. A union — Reply | None included — is now rejected when the class is declared, because the schema publishes one response shape and a client generated from it would expect no body and receive one. Annotate the message and raise CommandError for the failure case. See Events & Messages.

New

  • Command handlers can fail out loud. raise CommandError(code, message) answers the calling client with a failure it can branch on, correlated with its command, so an awaiting caller rejects with a reason instead of hanging. Any other exception answers with internal_error and keeps its detail in the log.
  • UploadedFile.size reports the byte length of an upload.
  • Recording encodes in process. No external encoder binary is involved. A finished recording stays fetchable for five minutes after its session ends and is then deleted; the clip endpoints answer 410 Gone past that. recording_dir (or REACTOR_RECORDINGS_DIR) chooses where chunks are written. See Session Recording.
  • Render a schema without serving one. python -m reactor_runtime.schema prints the OpenAPI contract of the model in a directory, so a build step can publish it without booting the runtime. The schema is titled with the name the model publishes.
  • GET /metrics serves the process’s metrics in Prometheus text format.
reactor-runtime 3.0.0 · New authoring surface
A rebuilt runtime with a smaller, sharper authoring surface. Every change below is source-level and mechanical; the shape of a model — declare tracks, load(), run(), emit — is unchanged.

Breaking

  • Import from reactor_runtime directly. Everything author-facing is exported from the top-level package. Replace from reactor_runtime.interface import ... with from reactor_runtime import ....
  • load() receives a path, not a dict. The signature is now load(self, config_path: Path | None), and the runtime no longer parses the file. Read it however you like: yaml.safe_load(config_path.read_text()) if config_path else {}.
  • output_buffer is now self.output. There is no single buffer any more — each connection paces its own playback — so the handle is named for what it does. output_buffer.flush() becomes self.output.flush() and output_buffer.set_fps(n) becomes self.output.fps = n, both fanning out to every connection. buffer_size and emit(drop=...) keep their meaning. See The Run Loop.
  • Drop the output: MyOutput class annotation. Outbound tracks register when the Output subclass is defined, so the annotation was never read — and the name now belongs to the playout handle, so leaving it in contradicts the real attribute. Delete the line; nothing replaces it.
  • Output and Input subclasses drop @dataclass. Declare the track annotations and nothing else. Output.__init__ now validates that you supplied exactly the declared tracks, so a missing track fails at the emit() call rather than silently streaming nothing.
  • @event(dedupe=True) is removed. Every command is delivered. A handler that must collapse a burst should track the latest value itself.
  • runtime.weights_path is no longer read by the runtime. get_weights_path() resolves $REACTOR_WEIGHTS_PATH, falling back to ~/.cache/reactor_registry. reactor run still reads runtime.weights_path to decide what to mount and sets the variable for you, so most workspaces need no change. See Weights.
  • serve has no subcommands. The entry point is python -m reactor_runtime.serve, run from the directory holding reactor.yaml. Fetch a model’s schema from GET /schema on the running server instead of serve schema.
  • Removed from the public API: get_profiler(), ReactorConfig, ReactorCore, and the Event / Connected / Disconnected classes. Use the @event, @connected, and @disconnected decorators.
  • Python 3.12 or newer is required.
  • No media libraries on the host. The runtime carries its own WebRTC stack as a wheel, so a model image needs nothing from the system package manager and a workspace Dockerfile can start from a plain Python base. PREFERRED_TRANSPORT and the GST_-prefixed variables are gone with it.

New

  • Session lifecycle hooks. @session_started and @session_ended bracket the session as a whole and fire once each, so a model can tell a new viewer joining apart from the session itself beginning. Note that a session end tears its connections down wholesale without firing the per-client @disconnected hooks. See Sessions & Clients.
  • Command replies. An @event handler that returns a ModelMessage sends it as that command’s correlated reply, so a client awaiting the command resolves with the state that actually took effect — useful when a value is clamped or a default is resolved server-side.
  • Read order per track. Reads take a mode: ReadMode.LATEST returns the newest frames and clears the backlog, ReadMode.FIFO consumes in arrival order. Video wants the former, audio the latter. See Video & Audio Tracks.
  • Rate from measured throughput. Passing compute_time to emit() plays the chunk back at the rate you actually produced it, so a model that warms up or slows under load stays in sync without touching fps.
  • Defaults validated at import. An InputField default that violates its own constraints now fails when the class is defined rather than on the first request, and mutable defaults are rejected outright.
reactor-runtime 2.9.4 · High-res codecs · Network resilience
Rolls up the patch releases since 2.8.0.

New

  • Clients are told when the network is struggling. The runtime periodically sends a mediaStats message on the data channel carrying an aggregate video quality score (0–10), so frontends can show a “connection is unstable” notice when reduced stream quality comes from network conditions rather than the model. Already surfaced by the JS SDK.

Fixed

  • H.264 and H.265 now stream above 720p. The senders pinned the level offered by the client onto the encoder, and browser offers advertise a ~720p level — so any higher-resolution model produced zero video on those codecs while the session otherwise looked healthy. The encoder now derives a level that fits the actual resolution (only the negotiated profile is enforced), and 2K+ output flows on both codecs, verified against real Chrome (H.264) and Safari (H.265) clients.
  • Video survives low-MTU networks. RTP packets are now capped at 1200 bytes, matching mainstream WebRTC stacks. Sessions on VPNs, WireGuard/Tailscale tunnels, PPPoE ISPs, and similar paths previously connected fine (data channel, audio) but silently black-holed every full-size video packet. Overridable per deployment with GST_RTP_PAYLOAD_MTU.
  • Every frame width works over WebRTC. Widths that are not a multiple of 4 no longer come in as a grayscale, sheared smear or get dropped by the encoder on the way out — the transport now honours GStreamer’s row padding in both directions. Widths that already worked keep the exact same fast path.
  • Native macOS runs. The GStreamer send pipeline no longer fails to build on Homebrew PyGObject, so running the runtime directly on a Mac (outside reactor run) works for local development.
reactor-runtime 2.8.0 · Multi-client sessions

Breaking

  • The aiortc WebRTC fallback has been removed. Running the runtime directly on a host now requires a working GStreamer installation. Stay inside the container with reactor run, which bundles GStreamer and every other dependency automatically.

New

  • Multiple clients can share one session. @connected and @disconnected now run once per client as each one joins and leaves, and self.connected stays set while at least one client is connected. Single-client models keep working unchanged: self.send() still reaches the one client connected.
  • Per-client messaging with ClientInfo. Any handler (@event, @connected, @disconnected, @file_uploaded) can accept a client: ClientInfo parameter for the client that triggered it. Call client.send() to reply to that single client or self.send() to broadcast to everyone, and store the handle to message a client later. Runtime responses (requestClip, requestRecording, schema) now address only the requesting client instead of broadcasting. See Events and Messages.
reactor-runtime 2.7.0 · Session recording

New

  • Session recording, configured from reactor.yaml. Models can now record every session continuously and let clients request snap clips or full recordings of the live stream. Flip recording.enabled: true in reactor.yaml and the runtime hooks the same buffer that feeds the wire, encodes fMP4 chunks in the background, and exposes a requestClip / requestRecording API to clients. Already wired in the JS SDK and the Demo Frontend’s Capture panel. See the new Session Recording page for the full configuration reference, encoder knobs (chunk_seconds, crf, target_width, …), and multi-track disambiguation.
reactor-runtime 2.6.0 · Host CLI · Docker

Breaking

  • Host CLI is now the Go reactor binary. The runtime no longer publishes a console script. Replace pip install reactor-runtime + reactor-runtime init|run with the Go reactor CLI; the runtime itself ships only inside reactor-runtime-base.
  • Workspace runtime version is now pinned in the Dockerfile. reactor init substitutes a fully-qualified FROM reactortechnologies/reactor-runtime-base:<X.Y.Z-N> line. Existing workspaces using ARG RUNTIME_VERSION need a one-line edit; everything else keeps working unchanged.
  • reactor run no longer accepts build-time flags. Build-phase concerns (-f, --no-cache, --build-secret) moved to reactor build; run-phase concerns (--port, --gpus, --tty, -e, --env-file) stay on reactor run. Both share the same image tag, so reactor build && reactor run always boots the freshly built image.

New

  • Scaffolded workspaces double as plain Docker projects. reactor init emits ENTRYPOINT [..., "python", "-m", "reactor_runtime.serve"] and CMD ["run"], so docker build && docker run -p 8080:8080 . works without the reactor CLI installed. reactor run itself remains opinionated about the run subcommand.
reactor-runtime 2.5.0 · CLI · reactor.yaml · Weights

Breaking

  • CLI renamed: reactor → reactor-runtime. The bundled console script is now reactor-runtime; update reactor run|init|schema call sites accordingly. When the Go reactor CLI is installed, reactor run keeps working by delegating to reactor-runtime run under the hood.

New

  • Modern nested shape for reactor.yaml. Identity and runtime entrypoint are now split under model: and runtime: sections. The legacy flat shape keeps working but logs a one-shot deprecation warning per process; new scaffolds emit the modern shape. See Model Anatomy.
  • get_weights_path() helper. New import from reactor_runtime returns a pathlib.Path to the resolved weights root, with resolution order $REACTOR_WEIGHTS_PATH → runtime.weights_path → ~/.cache/reactor_registry. See Weights.
  • runtime.weights_path in reactor.yaml. Optional field for committing a workspace-relative weights root (the env var still wins, so production deployments override committed values).
  • reactor-runtime init improvements. Name argument is optional (scaffolds into the current empty folder when omitted), model name is substituted into reactor.yaml automatically, a Dockerfile is part of the scaffold (python:3.12-slim + GStreamer + uv), and requirements.txt ships reactor-runtime so the workspace .venv has the import target available immediately.

Improved

  • Multi-line ModelMessage docstrings render correctly. reactor-runtime schema and downstream SDK / docs generation now preserve the full docstring of each ModelMessage subclass. Wrapped one-sentence summaries are no longer truncated at the first newline, and undocumented @dataclass messages no longer leak their constructor signature into the description.