Skip to content

A robot in C

The C ABI is the boundary every non-Rust form factor sits on (ADR 0004). sdks/c/include/seyd.h is generated by cbindgen from packages/seyd-ffi and is checked in, so a C consumer needs no Rust toolchain once the library is built. Any language with a C FFI can use the header today; the Python SDK is one such consumer. C++ and ROS 2 wrappers are planned, not built.

Terminal window
cargo build -p seyd-ffi --release # target/release/libseyd.{dylib,so,a}
make -C sdks/c check # builds and runs the ABI conformance test
make -C sdks/c # builds every example into sdks/c/build/

Link against libseyd (cdylib) or libseyd.a (staticlib). The library pulls in the platform’s TLS, threading and network libraries, and the Makefile is the authoritative per-platform list:

CFLAGS += -std=c11 -Wall -Wextra -O2 -I include
LDFLAGS += -L $(TARGET) -lseyd
ifeq ($(UNAME),Darwin)
LDFLAGS += -framework CoreFoundation -framework Security -framework SystemConfiguration
else
LDFLAGS += -lpthread -ldl -lm
endif

At run time the dynamic library must be findable: the Makefile sets DYLD_LIBRARY_PATH or LD_LIBRARY_PATH to the target directory when it runs the check.

The header’s own summary, in order:

seyd_agent_create -> seyd_channel_add* -> seyd_agent_start
-> seyd_push_frame / seyd_push_message
-> seyd_agent_stop -> seyd_agent_destroy
  • seyd_agent_create takes a seyd_config and a seyd_callbacks and does no network work. Strings are borrowed for the call only; Seyd copies what it keeps.
  • seyd_channel_add is valid only before start. Channels are numbered from 1 in the order added, and that numbering is what the pilot sees.
  • seyd_agent_start binds, discovers candidates and announces. It blocks until the robot is announced, up to a second or two. Callbacks begin after it returns SEYD_OK.
  • seyd_agent_stop tears down sessions and blocks until the callback thread has finished, so no callback runs after it returns. seyd_agent_destroy stops if running and frees the handle.

Every fallible call returns a seyd_status; SEYD_OK is zero.

sdks/c/examples/sensor-robot.c announces itself, publishes a counter on a sensor channel at 10 Hz and prints whatever a pilot sends on the command channel. It is built by make -C sdks/c, so the page cannot show code that does not compile.

sdks/c/examples/sensor-robot.c
// Copyright 2026 Anton Gravestam
// SPDX-License-Identifier: Apache-2.0
/* sensor-robot.c — a complete Seyd robot in C, in one file.
*
* Announces itself to the signal cloud, publishes a counter on a sensor
* channel at 10 Hz, and prints whatever a pilot sends on the command channel.
* It is the C twin of sim/sensor-source.py, and the smallest honest answer to
* "what does integrating Seyd look like?".
*
* ./sensor-robot <robot_id> <signal_url> [credential_path]
*
* Add video by calling seyd_push_frame with encoded access units from
* whatever encoder the robot already has — Seyd never decodes or re-encodes.
*/
#include <signal.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "seyd.h"
static volatile atomic_bool running = true;
static void on_sigint(int _sig) {
(void)_sig;
atomic_store(&running, false);
}
/* Callbacks arrive on one Seyd thread and must not block: print and return. */
static void on_session_started(void *user, const char *session_id,
seyd_role role, const char *path_label) {
(void)user;
printf("session %s started (%s over %s)\n", session_id,
role == SEYD_ROLE_DRIVER ? "driver" : "observer", path_label);
}
static void on_session_ended(void *user, const char *session_id,
const char *reason) {
(void)user;
/* Park actuators here — this fires whether the pilot said goodbye or
* simply vanished. */
printf("session %s ended (%s)\n", session_id, reason);
}
static void on_command(void *user, uint8_t channel, const uint8_t *data,
size_t len) {
(void)user;
printf("command on channel %u: %.*s\n", channel, (int)len, (const char *)data);
}
static void on_requested_config(void *user, const char *json) {
(void)user;
/* A real robot reconfigures its encoder here. */
printf("requested config: %s\n", json);
}
static void on_signal_state(void *user, seyd_signal_state state,
const char *detail) {
(void)user;
const char *name = state == SEYD_SIGNAL_CONNECTED ? "connected"
: state == SEYD_SIGNAL_DISCONNECTED ? "disconnected"
: "denied";
printf("signal %s%s%s\n", name, detail ? ": " : "", detail ? detail : "");
}
static uint64_t now_us(void) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return (uint64_t)ts.tv_sec * 1000000u + (uint64_t)ts.tv_nsec / 1000u;
}
int main(int argc, char **argv) {
if (argc < 3) {
fprintf(stderr,
"usage: %s <robot_id> <signal_url> [credential_path]\n",
argv[0]);
return 2;
}
signal(SIGINT, on_sigint);
seyd_init_logging("info");
seyd_config cfg = {
.robot_id = argv[1],
.signal_url = argv[2],
.credential_path = argc > 3 ? argv[3] : "./robot.key",
.quic_port = 4433,
.ipv6 = true,
.port_mapping = true,
.qos_profile = "latency",
.max_sessions = 4,
};
seyd_callbacks cbs = {
.on_session_started = on_session_started,
.on_session_ended = on_session_ended,
.on_command = on_command,
.on_requested_config = on_requested_config,
.on_signal_state = on_signal_state,
};
seyd_agent *agent = NULL;
if (seyd_agent_create(&cfg, &cbs, &agent) != SEYD_OK) {
fprintf(stderr, "create: %s\n", seyd_last_error());
return 1;
}
uint8_t telemetry = 0, drive = 0;
seyd_channel_config sensor = {.kind = SEYD_CHANNEL_SENSOR,
.name = "telemetry"};
seyd_channel_config command = {.kind = SEYD_CHANNEL_COMMAND, .name = "drive"};
if (seyd_channel_add(agent, &sensor, &telemetry) != SEYD_OK ||
seyd_channel_add(agent, &command, &drive) != SEYD_OK) {
fprintf(stderr, "channel_add: %s\n", seyd_last_error());
seyd_agent_destroy(agent);
return 1;
}
/* Blocks while candidates are discovered and the robot is announced. */
if (seyd_agent_start(agent) != SEYD_OK) {
fprintf(stderr, "start: %s\n", seyd_last_error());
seyd_agent_destroy(agent);
return 1;
}
printf("robot %s online; pilots may connect\n", cfg.robot_id);
for (uint64_t tick = 0; atomic_load(&running); tick++) {
char json[128];
int n = snprintf(json, sizeof json,
"{\"seq\":%llu,\"t_us\":%llu}",
(unsigned long long)tick, (unsigned long long)now_us());
seyd_push_message(agent, telemetry, (const uint8_t *)json, (size_t)n);
struct timespec hz10 = {.tv_sec = 0, .tv_nsec = 100 * 1000 * 1000};
nanosleep(&hz10, NULL);
}
printf("\nstopping\n");
seyd_counters_t c = {0};
if (seyd_counters(agent, &c) == SEYD_OK) {
printf("sent %llu chunks, %llu bytes\n", (unsigned long long)c.chunks_sent,
(unsigned long long)c.bytes_sent);
}
seyd_agent_destroy(agent);
return 0;
}

Walking through it:

  • Callbacks come first. Each prints and returns; on_session_ended is where a real robot parks its actuators, on_requested_config where it reconfigures its encoder. on_signal_state reports connected, disconnected or denied, and the robot is unreachable while signaling is down.
  • seyd_init_logging("info") installs a stderr log filtered by a directive such as info or seyd_core=debug. Optional, at most once, before creating an agent.
  • seyd_config sets the robot id, the signal URL and the credential path from argv; a zero quic_port or max_sessions and a NULL qos_profile take the same defaults as seydd.toml (4433, 4, balanced).
  • seyd_channel_add twice: a telemetry sensor channel and a drive command channel. The id written to out_channel_id is what seyd_push_message takes.
  • seyd_agent_start, then the main loop pushes a JSON counter once every 100 ms until SIGINT.
  • seyd_counters on the way out snapshots the engine counters; seyd_agent_destroy releases everything.

Add a video channel with a WebCodecs codec string and the nominal frame rate, then hand it one encoded access unit per call:

uint8_t video;
seyd_channel_config vc = {.kind = SEYD_CHANNEL_VIDEO, .name = "main",
.codec = "avc1.42001f", .fps = 25};
seyd_channel_add(agent, &vc, &video);
seyd_agent_start(agent);
for (;;) seyd_push_frame(agent, video, au, au_len, is_idr, capture_ts_us);

seyd_push_frame never blocks. A frame that arrives while the previous one is still going out is dropped by admission control and counted in frames_dropped_backlog; keyframes are never dropped. capture_ts_us is the source’s capture clock and travels to the pilot, which paces presentation on it (ADR 0005), so pass the encoder’s timestamp rather than “now”. Seyd is handed encoded access units and relays them; it never decodes, re-encodes or inspects them, and whatever your encoder produced is what the pilot decodes. What the encoder must do is on The publisher contract and Encoder setup. Where the publisher can encode the same picture more than once, declare layers; see Simulcast.

Set on_requested_config and on_recovery_request. The first carries Seyd’s targets as the video-config JSON (maxBitrateKbps, latencyBudgetMs, maxGopMs, preferIntraRefresh, suggestedFps, reason); the second names the recovery point a pilot needs (ltr, intra_refresh or idr). Keyframes are on demand (ADR 0009): honour the request and treat maxGopMs as a long ceiling, not a cadence.

  • Callbacks run on one dedicated Seyd thread and must not block. A slow callback delays every later event. Queue the work and return. Pointers passed to a callback are valid only for the duration of the call.
  • seyd_push_* never blocks and may be called from any thread.
  • Check seyd_last_error() after a non-SEYD_OK status. It is thread-local and valid until that thread’s next failing call; never free it.
  • SEYD_ERR_STATE means a call in the wrong lifecycle state, such as seyd_channel_add after start. SEYD_ERR_BACKPRESSURE is reserved and not returned: the engine drops under pressure by design and reports it through the counters.
  • The ABI is versioned. Compare seyd_abi_version() with the SEYD_ABI_VERSION you compiled against. Additions are appended, never reordered, until the version is bumped.
Terminal window
make -C sdks/c check

examples/abi-smoke.c is the conformance test: header matches library, channel numbering, lifecycle enforcement, argument validation. No network. Run it whenever the library or the header changes.

The C ABI reference lists every status code, struct, callback and function, generated from the header.