Documentation — Why Rust
How it works

Why Rust

agentd is a supervisor process that holds an LLM's work in a tree of child processes it can always kill. It sits idle for days, wakes on a timer or an event, spawns a child to run a model turn, and holds the credentials for every endpoint it dials. Long-lived, idle-cheap, spawn-heavy, credential-holding — those four properties pick the language and the dependency graph before any argument about syntax.

This is not a claim that Rust is fast: the workload is I/O- and syscall-bound, and the release profile trades CPU for size (opt-level = "z"). It is a claim about what the language makes affordable — writing the parts that are small and frozen by hand, on std and libc, while what ships stays one static binary with nothing else in the image.

What the runtime has to do

It must not leak. A process that wakes five times a second and lives for weeks turns a small per-iteration leak into an OOM kill.

It must start instantly. The same binary is re-exec'd through current_exe() for every subagent and every turn worker, so start-up is paid per node in the process tree, not once per deployment.

It must be auditable. The supervisor holds API keys and brokers whatever a model asks for; every crate linked into it sits inside that trust boundary. That argues for a small dependency graph wherever the thing being written is small, frozen, and only partly needed. The next section is where that argument runs out.

It must be able to stop a model. The cancel that matters is not dropping a future. A wedged turn is a child process with an open socket to a provider; the only thing that reliably ends it is killpg on its process group.

Go would satisfy the first three about as well — one static binary, fast start, a scheduler comfortable at these thread counts. What Rust adds is that std plus libc is enough to write HTTP/1.1, cron, DER parsing and inotify by hand, without giving up memory safety.

Two protocols we do not implement

MCP and A2A are both small enough to hand-write, and a hand-written subset is auditable in a way a dependency tree is not. agentd does not hand-write them anyway, for a reason that outweighs both.

A protocol you implement from your own reading of the specification fails in one particular way: silently, in the peer. The tests you write encode the same reading as the code, so they agree with it. What finally disagrees is somebody else's client, in production, and what it reports is nothing — a message that never arrived, a task that never looked finished. That failure mode is demonstrable, not hypothetical: handing agentd's A2A responses to an independent implementation of the same spec turns up faults of exactly this shape — every one of them valid JSON that agentd's own tests accept. (a2a.md lists them; crates/a2a-oracle is the harness.)

So MCP is rmcp, the official Rust SDK, and A2A is a2a-rs, an implementation generated from the specification's protocol buffers. They own the handshakes, the typed request and response shapes, capability negotiation, the streaming rules, the error codes and the version tables. agentd tracks the specifications by upgrading a dependency rather than by re-reading a document.

What agentd owns is the part a protocol crate has no opinion about: the socket. Both SDKs plug into agentd's own HTTP transport, so everything that hangs off a dial applies under them unchanged — an AAuth request signature with its challenge/re-sign loop, an AWS SigV4 signature per request, an mTLS client identity, a refreshed OAuth token, the SSRF guard on every dial. rmcp's StreamableHttpClient and a2a-rs's ports are both traits; that seam is the whole integration.

What that costs, and what it does not

BuildExternal crates
--no-default-features75
default (tls + MCP)88
shipped release feature set (adds A2A)156
… plus CEL179

What CI gates is not that count but what actually reaches a user: the release binary is a statically linked musl artifact that runs on scratch — about 8.5 MiB, a 3.6 MiB download, no shell, no libc, no package manager. A wide build-time dependency graph and a narrow shipped attack surface are separate questions, and it is the second one that is enforced.

The build must also stay free of a C toolchain, and one transitive default is enough to lose it. connectrpc, underneath a2a-rs, asks for rustls with default features, which selects the C/assembly aws-lc-rs provider — and because Cargo unifies features additively, that single default applies to the entire graph no matter how carefully every other crate asks for ring. The symptom is not a compile error but a cross build that hangs for tens of minutes looking for a compiler. Three corrected dependency entries in a vendored copy (third_party/connectrpc/PATCH.md) keep it out, and CI fails the build if aws-lc reappears anywhere in the tree.

Quarantine is a habit rather than a rule: third-party code reaches the engine through crates/net and crates/mcp, and deny.toml denies wildcards and yanked crates against a hand-maintained permissive-only licence allow-list. What the habit does not buy is a dependency graph you can hold in your head. That is the trade — protocol implementations two independent readings agree on, paid for in crates nobody here has read line by line — and it is worth describing as a trade rather than as a win.

What memory safety buys, and what it does not

Hand-writing parsers is the risky half of this design, so be precise about the risk Rust removes. It removes one bug class: a length error in the DER walk or the chunked-body decoder is a wrong answer or a panic, not a heap overflow. Under panic = "abort" that panic kills the supervisor loudly and collapses its children behind it via PR_SET_PDEATHSIG, rather than limping on with corrupt state.

It removes nothing else. Memory safety does not make a subset parser correct, stop prompt injection, authenticate a peer, or bound what a model asks a tool to do — security.md covers the machinery that does. A memory-safe SSRF is still an SSRF, which is why the egress classifier is composed explicitly at the one call site where a model supplies a URL. The parsers carry their bounds by hand: bodies cap at 8 MiB, and caller-supplied headers are scanned for CR/LF at the framing layer, closing header injection once.

The hand-rolled ledger

Hand-rolledWhereWhat a dependency would have cost
HTTP/1.1 client + SSE readernet/src/http.rs, 690 linesurequrl → IDNA → ICU
X.509 field extractionnet/src/x509.rs, 304 linesx509-parser
YAML subset readerconfig/yaml.rs, 1,307 linesserde_yaml (unmaintained)
JSON Schema subset (2020-12)jsonschema.rs, 803 linesa validator and a regex engine
5-field UTC crontriggers/timer.rs, 216 linescroner / cron
Prometheus exposition textobs/metrics.rsprometheus / metrics
OTLP export over HTTP/JSONobs/otel.rsopentelemetry + protobuf + tonic → tokio
NDJSON loggerobs/log.rstracing + a subscriber stack
ConfigMap watchconfig/watch.rs, raw libc inotifynotify / inotify
SHA-256, HMAC, AWS SigV4sha.rs, auth/aws.rssha2 + hmac, an AWS SDK
Token bucket, ULID, base64, FNV-1atens of lines eachgovernor, ulid, base64

The rule is visible in the table: hand-roll where the specification is small, frozen, and only partly needed. HTTP/1.1 request framing does not change; a cron expression is five fields. What you write is a subset, and the subset is the point — the JSON Schema validator carries no regex engine because its schemas do not need one. And net::http is generic over Read + Write, so rustls::StreamOwned drops into the same request path with no branch.

Two things are deliberately not in that table, and the reason is the same one: you hand-roll where the specification is small, frozen, and only partly needed — and neither TLS nor a wire protocol with a live specification is any of those. TLS is rustls; MCP and A2A are their SDKs. ring ships prebuilt assembly with a small cc step and needs no external toolchain, so the build stays pure Rust end to end.

No async runtime

The runtime is a single-writer reactor: one thread drains its channels, fires timers, dispatches turns, checkpoints, then blocks on exactly one recv_timeout. Durable state is mutated there and nowhere else.

"No async runtime" does not mean single-threaded. There is a reader thread per child, executor threads for blocking MCP and HTTP work, a thread per served connection, and a background SSE pump. The invariant is narrower and stronger: none of them mutate state. They post events; one thread decides.

The load-bearing property is abandon, don't interrupt. The supervisor reaches every pipe through an mpsc it recv_timeouts, and unblocks a parked reader only by making the producer go away. Pipes therefore carry no read timeout: one would create a second, racing notion of "stuck".

Cancellation is enforced by the kernel at three points. Each child gets its own process group (setpgid in pre_exec), sets PR_SET_PDEATHSIG(SIGKILL) after exec, and the supervisor sets PR_SET_CHILD_SUBREAPER so orphaned grandchildren reparent to agentd rather than host init. Teardown walks the tree deepest-first through a bounded ladder: cancel → killpg(SIGTERM)killpg(SIGKILL) → reap. This is the argument against an async runtime that is not about taste: future-drop cannot end a child process, and the child process is the thing that needs ending.

Blocking I/O buys the mundane: stack traces that mean something, a debugger showing a call stack instead of an executor frame, no function colouring, no starvation of a shared pool.

It gives up as much. Thread-per-fd does not scale to thousands of connections: the envelope is ~60–65 threads and ~130 descriptors, far inside Linux limits and nowhere near C10K. A slow DNS resolution occupies a thread for its duration. And the loop is not a pure event-driven park: it waits min(next_wake, TICK), so a timer due in 3 ms fires in 3 ms, but the floor is a 200 ms poll rather than an epoll sleep.

The numbers, and where they come from

The artifact that ships is a static-PIE musl build (LTO, stripped, opt-level = "z") of the release feature set:

amd64arm64
binary8,890,184 B (8.48 MiB)6,591,640 B (6.29 MiB)
.tar.gz download3,805,611 B (3.63 MiB)3,502,552 B (3.34 MiB)

That is the whole image too: FROM scratch plus this file.

Every figure on this page is only as good as its provenance, which is why each one names what it was measured on. A published release asset is not automatically more authoritative than a local build: if it was cut before the code you are describing, it documents software nobody is running. Record what a number was built from, or it is not a number.

An idle daemon running a schedule workflow, on a release build with a file store: Threads: 1, VmRSS 5.5 MiB, and 1 CPU tick over 6 seconds at CLK_TCK=100 — under 0.2% of a core, despite the 200 ms tick.

Spawn cost is easily misquoted. A loop of 100 agentd --version invocations costs ~2.7 ms each on this build, against a /bin/true floor of ~0.93 ms — roughly 1.8 ms marginal, most of it dynamic linking. Sub-millisecond start-up figures refer to the shipped static-PIE musl artifact, which has no ld.so; not the same measurement.

Where the choice hurts

Compile times. lto = true, codegen-units = 1 and opt-level = "z" are right for a shipped appliance and wrong for a fast edit loop. CI compounds it: 17 feature rows, two crates each, clippy and tests — because --all-features unification hides a build that is broken on its own.

You own the hand-rolled code forever. Roughly 93,000 lines of source across the workspace, 81,000 of them in the engine. The YAML reader is a subset; the cron parser is 5-field UTC only and finds the next fire by stepping a minute at a time for up to four years. Each is a spec revision you will handle yourself, and a bug nobody else reports.

unsafe is quarantined, not absent, and panic = "abort" is unforgiving. Zero unsafe in net and mcp; 60 blocks in the engine, half of them inside #[cfg(test)] and mostly env-var juggling (edition 2024 made set_var unsafe), the other 30 libc FFI across thirteen files. A panic in the supervisor path takes the whole tree down by design, which makes every unwrap an availability decision.

The learning curve is real. Edition 2024, a rust-version floor of 1.96 — set by the protocol SDKs, not by us, which is one of the ordinary costs of depending on somebody else's crate — a transport generic over Read + Write, raw signal handling: working here demands comfort with async-signal-safety rules, not only the borrow checker.

The moat is not a religion, and it has a deliberate exit hatch: each protocol's wire types sit behind serde in one module — wire/intel.rs for intelligence, mcp::wire for MCP — precisely so the codec could be swapped for a lighter encoder mechanically, if proc-macro compile weight ever has to go. The reactor those modules feed is mapped module by module in architecture.md.