Documentation — Architecture
How it works

Architecture

You are about to run something that holds your API keys, spawns processes, talks to a language model, and stays up for weeks. Before you do, you want three answers without reading the source: what happens when the model wedges mid-turn, what survives when the process dies, and what third-party code you have just agreed to trust.

One idea shapes all three: hand-write the small, stable protocol layers, and make the unit of concurrency an OS process rather than an async task. Every other decision below is downstream of that.


The shape of the system

A running agentd is one supervisor process that owns all state, short-lived child processes that do the reasoning, and four network edges, all of them HTTP(S).

The supervisor runs no agent loop. It decides when things run, executes effects, and records every transition; reasoning happens inside children it can kill outright. (Two opt-in watchdogs — the goal judge and the ask_human auto-answer — do call the model, but on an executor thread, never on the loop.) That is the "two loops": a deterministic supervisor loop, and an agentic think-act-observe loop that lives in a child.

Children are flat — every turn worker and subagent is a direct child of the supervisor, however deep the logical agent tree goes. Depth and agent_path are bookkeeping the supervisor mints, never values a child asserts about itself. A child creates children only by calling back into the supervisor-owned subagent tool, the single chokepoint where depth, breadth and spawn-rate caps are enforced. A fork bomb comes back as a refused tool result, not a crash; the limiter is a lazy-refill token bucket sized by limits.subagents.rate, which defaults to 8/2s — a burst of 8, refilling four tokens a second.

ComponentLives inOwns
Reactorruntime/reactor.rsthe event loop; the only writer of durable state
Workflow engineengine/, runtime/steps.rsthe durable DAG: runs, steps, nested bodies
Tool registryregistry/tool definitions, grants, precedence internal > code > MCP
Durable statestate/mod.rsentity kinds, manifest, inbox, timers, checkpoint policy
Store adaptersstore/{file,mcp,http,memory}.rsthe four-operation contract
Supervisor treesupervisor/*.rsspawn, process groups, reaping, kill ladder, stuck detection
Turn worker, subagentruntime/worker.rs, subagent/one LLM turn; the nested agentic loop
A2A listenerruntime/a2a_server.rsconversations, durable tasks, the display-client feed
MCP clientcrates/mcp/src/client.rstool calls, resource reads, notifications
Intelligence clientintel/endpoint list, failover, three in-binary dialects
Observabilityobs/*.rsNDJSON logs, Prometheus text, OTLP export, probes

The crate split

Five publishable crates, and a conformance suite that deliberately links none of them.

CrateLibrarySizeOwns
agentd-netnet2,310 linesHTTP/1.1 + SSE client, TLS, SSRF classifier, X.509 extraction
agentd-mcpmcp6,567 linesMCP wire types, protocol eras, client, Streamable-HTTP server
agentd-instructioninstruction_core6,691 linesthe Instruction Specification reference implementation: parser, validator, §3.5 delivery, §7 verification
agentd-coreagentd~89,000 linesthe engine: loop, supervisor, workflows, registry, config, state
agentd-clibin agentd749 linesargv dispatch and exit codes, nothing else
agentd-conformanceblack-box checks that drive the real binary

Sizes are wc -l over each crate's src/, so the inline #[cfg(test)] modules count and the integration tests in tests/ do not. The engine's is rounded because it moves with every commit; the leaves are stable enough to state exactly.

The name mismatch is not aesthetic: agentd on crates.io belongs to an unrelated project, so the package is agentd-core with [lib] name = "agentd", and dependents rename it back so embedders still write use agentd::…. agentd-instruction carries the same package-vs-lib split for a different reason — the specification is instruction.md's, not ours, so a release must not stake a claim on the instruction-* namespace — and from 0.2.0 it is the one crate here under MIT OR Apache-2.0 rather than the AGPL, because a reference implementation the other implementations cannot link is a reference nobody uses.

Third-party surface sits in the leaves, and is optional above them. net holds one heavy end of the default build — rustls, webpki-roots and rustls-pemfile behind its tls feature, vsock behind vsock — and mcp carries the other: the official rmcp SDK and the async runtime it needs (tokio, futures, sse-stream, tokio-stream, thiserror, http). Beyond its own three (serde, serde_json, libc), the engine names fourteen further external crates, every one optional and every one off by default: ring for aauth, sign and oci, cel-interpreter for cel, and the A2A stack behind a2aa2a-rs, buffa, buffa-types, tokio, axum, tokio-rustls, hyper, hyper-util, tower, async-trait, tokio-stream and futures-util. net, mcp and instruction also contain zero unsafe; in the engine every unsafe block outside #[cfg(test)] is libc FFI: thirty blocks across thirteen files, almost all of them signal handling, spawn, reaping, kill and cgroup work, plus the inotify watch and the termios juggling in config/prompt.rs. (The test-only ones are std::env::set_var calls, which edition 2024 made unsafe.)

The CLI is a shell, and that is enforced by the mechanism that makes embedding work: subagents are the same binary re-exec'd via current_exe(), so an embedder building its own CLI must install the subagent re-exec dispatch as the first thing in main. Skip it and a spawn re-runs the embedder's CLI as a confused supervisor. Code tools register before that dispatch, because registration is how a tool exists in every re-exec'd process.


The single-writer loop

Runtime::run_loop is genuinely single-threaded. Each iteration drains every source, decides, checkpoints, and then blocks on exactly one wait.

"No async runtime" does not mean single-threaded overall — it means the writer is single-threaded. Reader threads, executor threads, the MCP notification pump and per-connection handlers all exist. The invariant is that none of them mutate durable state; they post events onto the std::sync::mpsc channels the loop drains.

The wait at phase 12 is next_wake().min(TICK), not TICK. A timer due in 3 ms fires in 3 ms; the 200 ms tick is a ceiling on how long the loop sleeps with nothing armed, not a scheduling granularity. Two details make that claim true rather than aspirational. Child frames ride the SAME channel the loop parks on, so a subagent's answer wakes the loop instead of waiting out the tick: a delegation round-trip measures ~18 ms, where parking until the next tick would add most of a 200 ms tick to every one. Each reap is re-queued once behind its child's already-flushed frames, so "exited without a result" cannot be a race. And after the scheduling pass, the loop re-schedules to a fixpoint while inline data steps (assign, map, template, switch…) keep completing synchronously, so a pure data pipeline advances at execution speed — a 200-step chain finishes in ~2.3 s in a debug build, against the ~42 s one step per tick would cost. A daemon built with the shipped feature set, idling on a schedule workflow, reports Threads: 1, VmRSS around 5.5 MiB, and one accumulated CPU jiffy per ~6 s.

Why not an async runtime

The argument against tokio is about correctness, not taste. The thing that needs cancelling when a turn goes wrong is a child process, and the only thing that reliably stops it is killpg(SIGKILL). Future-drop cannot do that. A shared work-stealing pool also reintroduces the failure mode this design exists to avoid — one stuck thing starving everything — and costs scores of crates.

The kernel enforces cancellation at three points. Each child gets its own process group via setpgid(0, 0) in pre_exec, so the ladder can target a whole subtree. Each child sets PR_SET_PDEATHSIG(SIGKILL) in its own main (it is cleared across execve), so a supervisor crash collapses the tree leaf-up. The supervisor sets PR_SET_CHILD_SUBREAPER, so orphaned grandchildren reparent to agentd rather than host init. The ladder is bounded and deepest-first: graceful cancel, killpg(SIGTERM) after a 5 s grace, killpg(SIGKILL) after a further 2 s, then waitpid.

Abandon, don't interrupt

The load-bearing invariant of thread-per-fd is that the supervisor never blocks on an untrusted source. It reaches every pipe only through the channel it recv_timeouts, and it unblocks a parked reader only by making the producer go away — closing its stdout, killing its process group — never by interrupting the read. So pipes have no read timeout, and adding one would be a bug: the deadline lives at the reactor's recv_timeout, and a second, racing notion of "stuck" is what you least want.

The scale envelope is bounded on purpose: roughly 8 MCP readers, up to 50 subagent readers, one intelligence connection and one signal reader — about 60 to 65 threads and 130 file descriptors, three orders of magnitude inside default Linux limits. You scale by running more instances.

Signals fit the same discipline. Handlers flip an AtomicBool and write one byte to a self-pipe made with raw libc::pipe. SA_RESTART is off, so blocked syscalls return EINTR; SIGPIPE is ignored, so writing to a dead child is an EPIPE you handle rather than a process death.


Where state lives, and when the store should be remote

agentd's durable state is a key-value store behind one small contract, and where that store lives is a deployment choice. A long-lived instance that names no store.kind gets the local file store — one file per key under a directory it holds an exclusive lock on — because an agent on a laptop or a VM should survive a restart without an external service standing by; a one-shot job may keep no state at all. Point store at a remote adapter the moment the process is disposable, which in a container it always is. Either way the same things are written: every unit of progress — an accepted message, a fired trigger, a workflow step, a turn, a subagent result, a memory write, a timer — goes to the store. The contract is four operations:

put(key, seq, envelope)  → Ok | Conflict{latest_seq} | Err(io)
get(key[, seq])          → Some(envelope) | None | Err(io)
list(prefix)             → [{key, seq}] | Unsupported | Err(io)
delete(key)              → Ok | Unsupported | Err(io)

Keys are <prefix>/<instance>/<kind>/<id> across twelve entity kinds — manifest, inbox, context, run, subagent, task, memory, artifact, timer, event, audit, cred. Values are versioned envelopes carrying seq, ts, the writing instance, and an optional hash binding a run to its definition.

put is a compare-and-set on seq, and a conflict is fatal. If another writer owns a key, the instance stops accepting work rather than racing. That is the split-brain guard, and what makes several replicas on one namespace safe.

Accept means durable. An inbound message or fired trigger is written to the inbox before it is acted on, and a SendMessage is acknowledged only after that write. On restore, undone inbox records are re-delivered with their original event id. You get exactly-once state transitions and at-least-once effects — the honest pairing, since agentd cannot make a remote tool call idempotent for you. It carries the key (_meta["agent/idempotency_key"]) so a well-behaved server can collapse the replay.

agentd links no database client and defines no schema beyond the envelope. Four adapters implement the contract. file writes one file per key under a locked local directory, and is the default for a long-lived instance; mcp maps the operations onto any MCP server's tools through JSON or CEL templates; http onto plain HTTP; memory in-process for tests. Even the file store keeps no local write-ahead log — one envelope per key, never a journal — which is what makes the remote answer the right one as soon as the box is disposable: a container's filesystem is not a durability boundary, and an evicted pod takes its disk with it. Making the store an outbound HTTP call to something that already has an operational story means agentd inherits that story instead of inventing a worse one.

Restore is explicit: read the manifest, get each indexed entity, verify definition hashes, rebuild registries, re-arm timers from absolute deadlines, re-open in-flight tasks, re-spawn subagents whose parent step is pending, re-deliver undone inbox events. Entities newer than the manifest win, because entities are written first; listed-but-missing entities are marked lost and audited rather than silently skipped. On a store error, store.on_error chooses halt — refuse intake, keep serving status, drain — or degrade, which retries with backoff and reports durability: degraded.


The four network edges

EdgeDirectionWire
IntelligenceoutboundHTTPS; OpenAI-compatible /chat/completions, plus Anthropic and Bedrock Converse dialects in-binary
MCP serversoutboundHTTPS Streamable HTTP: one POST per request, plus a lazily-opened GET SSE stream
State storeoutboundHTTPS, via the mcp or http adapter
A2A / operatorinboundHTTPS JSON-RPC — peers, TUI, web UI and operator admin all arrive here

There is no database protocol, no message-bus client, no local socket. The operator edge is the same listener as the peer edge: the TUI and web UI are thin display clients holding no truth of their own, subscribing to a feed and forwarding your intent back. Auth is the listener's — on a plaintext loopback listener with no principals a local client is the operator with zero setup; a remote client presents a bearer token or an mTLS identity and sees only what its role allows. The probe surface (/metrics, /healthz, /readyz, on a separate port when you set --metrics-addr) is read-only and off by default. A workflow with a webhook start node serves that same inbound edge on a port of its own — signed request in, durable run out.

Because every edge is the same wire, there is one transport abstraction:

pub trait Stream: Read + Write {}

rustls::StreamOwned is Read + Write, so TLS is not a branch in the HTTP code — it is a different value flowing through the same path. Client roots are the bundled webpki-roots, so a scratch container has trust anchors with no system CA bundle; extra anchors are process-wide and must be installed before the first outbound dial, because the default client config is built once and cached.

The inbound acceptor re-stats its PEM files at most once per second on accept and hot-swaps the config, so a cert rotation is picked up with no restart and no dropped listener; a failed reload keeps serving the last-good identity. Under mTLS a hand-rolled DER walk lifts subject CN and SANs from the verified leaf, so a SPIFFE spiffe:// URI SAN reaches principal matching.


What is hand-rolled, and why

Everything in this table is small, frozen, and only partly needed — which is the whole rule. A protocol with a living specification is none of those, which is why MCP and A2A are not in it.

LayerWhereInstead of
HTTP/1.1 client + SSE readernet/http.rs, 690 linesureq + url → IDNA → ICU
YAML subset readerinstruction/yaml.rs, 1,307 lines (re-exported as config::yaml)serde_yaml, itself unmaintained
JSON Schema subsetjsonschema.rs, 803 linesa schema crate and a regex engine
Crontriggers/timer.rscroner
Prometheus textobs/metrics.rsprometheus / metrics
OTLP exportobs/otel.rsopentelemetry + protobuf + gRPC
inotify config watchconfig/watch.rsnotify / inotify
NDJSON loggingobs/log.rs, 807 linestracing
SHA-256, HMAC, ULID, base64, SigV4, DER walk, token bucket, FNV-1avarioussix or seven crates

The reasoning is per-row but rhymes. Cron is five UTC fields as u64 bitsets, whose next_after steps one minute at a time bounded at four years so Feb-29 expressions terminate. OTLP goes over HTTP/JSON rather than gRPC because tonic would add a protobuf codegen and a gRPC stack on top of the tokio the MCP SDK already brings, for an export path the existing HTTP client already covers. tracing is declined because implicit async span context is moot in a processes-plus-threads design and the process tree already supplies correlation.

Two are worth more than a table row.

Prometheus exposition has structurally bounded cardinality. Label-bearing series are fixed-domain atomic arrays whose label set is known at compile time. An unbounded label — run_id, agent_id, agent_path — is impossible by construction, not by code review.

The ConfigMap watch watches the parent directory, not the file. A kubelet update writes a new timestamped directory and atomically renames the ..data symlink; the file inode is never written, so a watch on the file sees nothing. The watcher re-arms on IN_IGNORED — the subtle bit that makes a second update fire — and sets the same latch SIGHUP sets, so there is one reload code path.

The dependency ledger, stated honestly

Two things in agentd are emphatically not hand-rolled, and they are the two that talk to other people's software:

ImplementationWhy not ours
MCPrmcp — the official Rust SDKa live specification, and a misreading fails in the peer
A2Aa2a-rs — generated from the spec's protobufssame, and demonstrable: checking agentd's output against an independent reading of the spec turns up faults that are valid JSON

Both plug into agentd's own HTTP transport, so the credentials only agentd knows about — AAuth signatures, SigV4, mTLS identities, refreshed OAuth tokens — and the SSRF guard all still apply. The SDKs own the protocol; agentd owns the socket.

The resolved graph is wide, and it is counted in exactly one place: why-rust.md carries the per-build totals together with the cargo tree invocation that produces them and the convention for what is and is not counted. Those numbers are deliberately not repeated here — three counts written down in two files drift apart on the next dependency bump, and a reader is then holding two answers with no way to tell which one is stale.

A graph of that width is not one you can hold in your head, so agentd does not claim you can. What CI enforces instead is a property of the thing a user actually receives: the release binary must be a statically linked musl artifact that runs on scratch — about 8.5 MiB for the shipped feature set, with no shell, no libc and no package manager.

The build stays pure Rust. The SDKs pull rustls with its default features, which selects the C/assembly aws-lc-rs provider; because feature unification is additive, that one default would have imposed cmake and a C++ compiler on every build, however carefully our own manifests ask for ring. A vendored connectrpc with three corrected dependency entries removes it — see third_party/connectrpc/PATCH.md. CI asserts aws-lc stays out of the graph.

Two gates remain: the feature matrix compiles, clippies and tests 18 combinations including every shipped feature solo, because --all-features unification hides broken solo builds; and deny.toml bans wildcard versions, denies yanked crates, and carries a hand-maintained permissive-only licence allow-list.


Feature flags are the capability surface

Capability is decided at compile time. Of the sixteen features besides default, eight are literally empty arrays — they gate hand-rolled code, not dependencies.

FeatureAdds cratesGates
tls (default)rustls, ring, webpki-roots, …HTTPS in and out
a2aa2a-rs, axum, tokio, …the inbound A2A / interface listener
cronnonethe five-field UTC parser
oauthnoneOAuth 2.1 device, PKCE and client-credentials tokens
metricsnonethe atomic registry and Prometheus text
otelnoneOTLP-over-HTTP/JSON export
hot-reloadnoneSIGHUP validate-first quiesce-and-reapply
config-watchnone — chains hot-reloadthe inotify directory watch
workflownonenothing live — the durable DAG engine is unconditional
execnonethe guarded local command runner
aauthnone new — reuses ringEd25519 identity + RFC 9421 signing
signnone new — reuses ring§7 instruction signature verification
ocinone new — reuses ringoci:// instruction pulls, cosign verification
decryptnoneage v1 and JWE instruction envelopes
cel+22CEL predicates and expressions
internal-mocksnonethe mock LLM and MCP servers the tests drive

Read that table twice, because the naive model — "features control dependency cost" — mispredicts five of its rows.

aauth adds Ed25519 signing with zero new crates, reusing the ring that rustls already resolved — a capability that costs nothing to carry, and the same is true of the sign, oci and decrypt that ride on it. cel is the opposite trade and is carried anyway: 22 extra crates — an ANTLR runtime and a regex engine among them, a quarter again on top of the default tree — in exchange for expressions being available to every when, until and filter in a shipped binary.

exec costs nothing in dependencies and is still absent from every shipped binary. It is gated on posture: agentd's default position is that it runs no local code. Turning it on takes both the cargo feature and security.exec.enabled at run time, and even then the runner never uses a shell, enforces an argv[0] allow-list, confines the working directory, caps output and wall-clock, and passes a minimal environment.

Two mechanics follow from treating features as capability rather than cost. Call sites stay unconditional — there are no #[cfg] gates around record_* or span calls; the feature empties the function body, so the loop wires observability once. And CEL is fail-closed: the module is always compiled, only its internals gated, so a build without the feature rejects a graph using CEL at define time with a named error rather than mis-evaluating later.


The deployment shapes that fall out

Nothing above chooses a deployment shape. The daemon and the one-shot job are the same engine differing only in exit predicate: a workflow's start node decides when a run begins, lifecycle.run_until decides when the process stops.

$ agentd \
    --instruction "Summarize the open TODOs under /work and write SUMMARY.md" \
    --intelligence https://gw.example/v1 \
    --mcp fs=https://mcp-fs.internal/mcp \
    --max-steps 40 --deadline 600s

The same binary, woken by an MCP resource and keeping state across restarts:

config_version: "1"

agent:
  name: triage
  instruction: You triage incoming issues and write a one-paragraph summary.

intelligence:
  endpoints: https://api.openai.com/v1
  model: gpt-5.1
  token: "{{secret:OPENAI_API_KEY}}"

mcp:
  servers:
    - name: issues
      endpoint: https://mcp-issues.internal/mcp
    - name: state
      endpoint: https://mcp-state.internal/mcp

store:
  kind: mcp
  mcp:
    server: state

workflows:
  - name: triage
    steps:
      start:
        kind: subscribe
        server: issues
        uri: issues://open
        debounce_ms: 2000
      summarize:
        kind: agent
        depends_on: [start]
        instruction: Read the open issues and summarize what changed.
        servers: [issues]
      done:
        kind: finish
        depends_on: [summarize]
        output: "{{steps.summarize.output}}"

lifecycle:
  run_until: drained

agentd --validate-config -c triage.yaml loads, substitutes, types and validates the whole document and exits 0 or 2 — before any side effect. Precedence is built-in default, then files, then environment, then flags.

The exit-code table is a public API, meant to be read by a podFailurePolicy:

CodeMeaning
0success — one-shot completed, or a clean SIGTERM drain
1generic failure (retriable)
2config or usage error (non-retriable)
3partial result
4intelligence unreachable or auth failure after retries (retriable)
5semantic refusal — the task cannot be done (non-retriable)
6a required MCP server failed to connect or handshake (retriable)
7budget exceeded — steps, tokens, deadline, tree
124hard wall-clock deadline
137 / 143killed by SIGKILL / SIGTERM, set by the OS

Note 0 for a clean drain, not 143 — visible in the log as drain.start, drain.done, proc.exit with code: 0. The drain budget must be less than the pod's terminationGracePeriodSeconds — a number agentd cannot see, so nothing validates it for you, and it is the most common way to get this wrong. Liveness is the reactor heartbeat, deliberately: a wedged reactor reads unhealthy, while a healthy tree with one stuck subagent keeps reading healthy — the reactor is the thing detecting and killing that child, so it is still ticking.

The resulting footprint, on a stripped x86_64 glibc release build of 1.15.0 (opt-level = "z", LTO, panic = "abort", one codegen unit): 5,376,480 B (5.13 MiB) with --no-default-features, 6,365,288 B (6.07 MiB) with default tls, 10,235,536 B (9.76 MiB) with the shipped set a2a,metrics,cron,otel,hot-reload,config-watch,aauth,oauth,cel,sign,oci,decrypt. Release artifacts are cross-compiled static-musl for x86_64 and aarch64, plus a multi-arch, cosign-signed OCI image with an SPDX SBOM.

panic = "abort" is a design statement, not a size optimisation: a panicking supervisor should die loudly and let PR_SET_PDEATHSIG collapse its tree rather than limp on with corrupt state. The consequence is worth saying plainly — every unwrap on the supervisor path is a tree-wide availability decision, which is why mutex locks there recover from poisoning rather than aborting on it.


What this design will not do

  • Scale one instance to thousands of connections. The model targets roughly 60 threads. You scale out, not up.
  • Give you exactly-once effects. It gives exactly-once state transitions and carries an idempotency key; collapsing the replay is the remote server's job.
  • Survive a restart without a store. memory is for tests; there is no local write-ahead log.
  • Sandbox anything. The container, VM or enclave around agentd is the sandbox. Capability scoping is the granted tool subset, narrowing monotonically down the agent tree.
  • Run a local command in a shipped binary. The exec runner requires building from source, and then enabling security.exec.enabled as well.

Where to go next