Skip to content

A robot in Rust

A Rust program can skip the C ABI and drive seyd_core::Agent directly. It is the same type seydd and seyd-ffi are built on, so a Rust host gets exactly the daemon’s behaviour with none of its plumbing.

Agent owns everything between a configuration and a running robot: sockets, candidate discovery, the certificate, the QUIC endpoint, the session engine, signaling, port-mapping lease renewal, certificate rotation and re-gathering on a network change. A host supplies media and consumes events, and owns only what differs by form factor. seydd owns TOML, RTSP/RTP inputs and UDP sinks; seyd-ffi owns C marshalling. If you find yourself writing lifecycle code in a host, it belongs in Agent; otherwise the form factors drift.

The API is small:

  • AgentConfig: the robot’s configuration, independent of where it came from. robot_id, signal_url, credential_path, quic_port, ipv6, port_mapping, host_override, qos_profile, max_sessions, channels, agent_version, recovery_ladder and relay. AgentConfig::default() carries the same defaults as seydd.toml.
  • ChannelSpec: one channel, with id, kind, name, codec, fps and optional simulcast layers. Ids are what the pilot sees and what pushes and events refer to.
  • Agent::start(cfg): binds, discovers, announces, and returns (Agent, Receiver<AgentEvent>) once pilots may connect. The maintenance loop runs in the background until stop() or until the receiver is dropped.
  • push_video(channel, VideoFrame) and push_video_layer: hand an encoded picture to the engine. VideoFrame is data, keyframe and capture_ts_us. Never blocks; a frame that finds the slot full is dropped by admission control, and keyframes never are.
  • push_message(channel, &[u8]): a message on a sensor channel.
  • AgentEvent: SessionStarted, SessionEnded, Command, RequestedConfig, RecoveryRequest, LayerChanged, SignalConnected, SignalDisconnected, SignalDenied and NatReport.
  • stop(): the maintenance loop exits, the endpoint closes and in-flight sessions are torn down. Agent is cheap to clone; every clone refers to the same agent.

Also on the handle: set_qos_profile, set_status, session_count, counters, profile and channels.

packages/seyd-core/examples/minimal_host.rs is the whole contract in one file: an AgentConfig from argv, a sensor channel and a command channel, start, a counter pushed once a second, every event printed, stop on Ctrl-C. It is a Cargo example, so cargo build -p seyd-core --examples compiles it and this page cannot show code that does not build.

packages/seyd-core/examples/minimal_host.rs
// Copyright 2026 Anton Gravestam
// SPDX-License-Identifier: Apache-2.0
//! minimal_host — the smallest host of [`seyd_core::Agent`].
//!
//! A *host* supplies media and consumes [`AgentEvent`]s; the agent owns
//! everything else (sockets, discovery, certificate, signaling, lease renewal,
//! cert rotation). This program is the whole contract in one file: build an
//! [`AgentConfig`], declare a sensor channel and a command channel, start,
//! push a counter once a second, print every event, stop on Ctrl-C.
//!
//! cargo run -p seyd-core --example minimal_host -- <robot_id> <signal_url> [credential_path]
//!
//! `seydd` is the reference host (packages/seydd/src/main.rs): the same calls,
//! plus TOML, RTSP/RTP inputs and UDP sinks. Add video with
//! [`Agent::push_video`], handing it the access units your encoder already
//! produces; Seyd never decodes or re-encodes.
use seyd_core::{Agent, AgentConfig, AgentEvent, ChannelKind, ChannelSpec};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: minimal_host <robot_id> <signal_url> [credential_path]");
std::process::exit(2);
}
// Channels are numbered from 1 in declaration order; that numbering is
// what the pilot sees and what `push_message` and `Command` refer to.
let telemetry = ChannelSpec {
id: 1,
kind: ChannelKind::Sensor,
name: "telemetry".into(),
codec: "json".into(),
fps: 0,
layers: Vec::new(),
max_bitrate_kbps: 0,
};
let drive = ChannelSpec {
id: 2,
kind: ChannelKind::Command,
name: "drive".into(),
codec: "json".into(),
fps: 0,
layers: Vec::new(),
max_bitrate_kbps: 0,
};
// Everything not set here keeps the same default as `seydd.toml`.
let cfg = AgentConfig {
robot_id: args[1].clone(),
signal_url: args[2].clone(),
credential_path: PathBuf::from(args.get(3).map(String::as_str).unwrap_or("./robot.key")),
qos_profile: "latency".into(),
channels: vec![telemetry.clone(), drive],
agent_version: "minimal_host/0.1".into(),
..AgentConfig::default()
};
// Binds, discovers candidates, announces to the cloud; returns once pilots
// may connect. The receiver is the host's side of the agent.
let (agent, mut events) = Agent::start(cfg).await?;
println!("robot {} online; pilots may connect", args[1]);
// A 1 Hz counter on the sensor channel. `push_message` never blocks, so
// this can run on any task or thread.
let sensor = agent.clone();
let ticker = tokio::spawn(async move {
let mut seq: u64 = 0;
loop {
let t_us = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0);
let msg = serde_json::json!({ "seq": seq, "t_us": t_us });
sensor.push_message(telemetry.id, msg.to_string().as_bytes());
seq += 1;
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
// The host's event loop. Nothing here is protocol: it is the robot's own
// policy about what a command means and what the publisher is told.
loop {
tokio::select! {
ev = events.recv() => {
let Some(ev) = ev else { break };
match ev {
AgentEvent::Command { channel, payload } => {
println!("command on channel {channel}: {}", String::from_utf8_lossy(&payload));
}
AgentEvent::SessionEnded { signal_id, reason } => {
// Park actuators here: this fires whether the pilot said
// goodbye or simply vanished.
println!("session {signal_id} ended ({reason}); {} left", agent.session_count());
}
other => println!("{other:?}"),
}
}
_ = tokio::signal::ctrl_c() => {
println!("stopping");
break;
}
}
}
ticker.abort();
agent.stop();
Ok(())
}

Run it against the deployed cloud with an enrolled robot id (see Enrolment and access):

Terminal window
cargo run -p seyd-core --example minimal_host -- <robot_id> wss://seyd-signal-flj7s44j4a-ew.a.run.app/ws ./robot.key

The example installs no log subscriber, so the agent’s own tracing is silent and only the events print. Add tracing_subscriber::fmt().init() to see what seydd logs.

packages/seydd/src/main.rs is the reference host. Read it next to the example and the split is plain:

  • It builds ChannelSpecs from the TOML, one per [[channel]], numbered from 1 in file order, and hands them to Agent::start inside an AgentConfig.
  • For each video channel it spawns one input task per simulcast layer and calls push_video_layer with every access unit; a single-input channel is layer 0. For each sensor channel it spawns a UDP listener and calls push_message per datagram.
  • Its event loop is daemon policy and nothing else: Command goes out as a UDP datagram to the channel’s output; RequestedConfig, RecoveryRequest and LayerChanged become publisher-control JSON; SessionStarted and SessionEnded become session messages. Signaling events are ignored, because the agent has already handled them.
  • Ctrl-C calls agent.stop().

Enrolment is the one thing the daemon does outside the agent today: seydd enrol posts the public key with a token, using seyd_signal_client::Identity, which is the same key file format every host reads.

The rules are the same as for every other form factor. Push whole encoded access units and pass the encoder’s capture_ts_us; Seyd never decodes or re-encodes. Do not block on the event receiver’s thread. Park actuators on SessionEnded, which fires whether the pilot said goodbye or vanished. Act on RequestedConfig and RecoveryRequest: keyframes are on demand (ADR 0009), and a publisher that ignores them leaves a joining pilot waiting up to maxGopMs. The details are on The publisher contract.

The Rust reference lists the crates, and the rustdoc for the agent is at seyd_core.