The daemon’s callable surface is HTTP, not MCP, gRPC, or JSON-RPC: there is no .proto file in the tree, and the only production router construction is the pair of Router::new() calls in agentd/src/routes.rs:48-49. The named unit a caller invokes is therefore a method-and-path pair, and this file has one H2 per pair, alphabetized on that string.
Twenty pairs exist, and the roster is closed by construction rather than by convention. One list, surface_docs(), is walked twice: once to build the router and once to serve GET /v1/schema (agentd/src/routes.rs:419-728, agentd/src/routes.rs:51-59, agentd/src/routes.rs:409-411). A path absent from that list is unroutable, and a path present in it with no arm in handler_for panics at startup rather than answering 404 to a documented route (agentd/src/routes.rs:140).
Auth is per endpoint and takes three values. Bearer endpoints sit behind auth::require_token, applied with route_layer so an unmatched path still falls through to the 404 fallback instead of being answered 401 (agentd/src/routes.rs:66-69). Open and PlatformHook endpoints go on an unguarded router (agentd/src/routes.rs:53-58). PlatformHook is unauthenticated because the platform holds no credential to present, and its request arrives over loopback indistinguishably from an in-VM process (agentd/src/routes.rs:39-42, agentd/src/routes.rs:168-172); the defense for /run is that it can succeed only once, not that the caller is identified. The auth middleware answers 503 when no token is installed at all, 503 when a token is presented while none is installed, and 401 when a presented token mismatches (agentd/src/auth.rs:69-80).
Every response on every endpoint carries the microvms-agentd-version header, stamped by a layer applied outside route_layer so it also covers the 401, 503, 413, and 404 that no handler produced (protocol/src/lib.rs:68, agentd/src/routes.rs:85). The protocol version is 1 and tracks the /v1/ namespace rather than the crate version (protocol/src/lib.rs:60).
Failing exec endpoints return ErrorBody { error, detail }, where error is one of a closed set of slugs a client branches on and detail is prose for a log (protocol/src/exec.rs:296-299, protocol/src/exec.rs:316-337). Failing fs endpoints answer text/plain instead, because their bodies are opaque byte streams and there is no typed body module for them (protocol/src/fs.rs:4-6).
One place where docs/PROTOCOL.md, the hand-written contract, disagrees with the source and with the generated docs/schema.json:
- It describes
POST HOOKS/resumeas signalling in-memory state loss (docs/PROTOCOL.md:19). The source records the opposite as a dated measurement and records that the state-loss claim was inferred rather than measured (agentd/src/routes.rs:294-308). This file follows the measurement.
Its route table used to omit GET /v1/schema and GET /v1/tcp; both rows are present now, beside the GET /v1/procs row added with them.
GET /v1/exec/{id}
Section titled “GET /v1/exec/{id}”pub async fn poll(State(state): State<AppState>, Path(id): Path<String>) -> Response {Polls one exec’s phase and, once the child has exited and before an ack, its captured output and exit status (agentd/src/routes.rs:532-533).
Auth: Bearer (agentd/src/routes.rs:531).
Input: the {id} path segment only, per the signature at agentd/src/exec.rs:422. No body, no query.
Output: application/json body PollResponse { exec_id: String, phase: Phase, result: Option<Outcome> }, where result is #[serde(flatten)] plus skip_serializing_if = "Option::is_none", so a running exec serializes as {"exec_id":"e1","phase":"running"} and an exited one inlines Outcome { exit_code: Option<i32>, signal: Option<i32>, stdout: String, stderr: String, truncated: bool, writers_may_be_alive: bool } at the top level (protocol/src/exec.rs:281-287, protocol/src/exec.rs:58-74). Phase is running | exited | acked (protocol/src/exec.rs:24-31).
Statuses: 200; 401; 503; 404 unknown_exec (agentd/src/schema.rs:419-429).
Strictly read-only — nothing in the handler may write to the registry or an entry, and the model/ crate asserts that against the transition function rather than against reachable states (agentd/src/exec.rs:417-421).
GET /v1/exec/{id}/stream
Section titled “GET /v1/exec/{id}/stream”pub async fn stream( State(state): State<AppState>, Path(id): Path<String>, query: Result<Query<StreamQuery>, QueryRejection>,) -> Response {Follows an exec’s output as Server-Sent Events, replaying from a byte offset and then tracking live output (agentd/src/routes.rs:551).
Auth: Bearer (agentd/src/routes.rs:550).
Input: the {id} path segment plus StreamQuery { offset: Option<u64> } as application/x-www-form-urlencoded query; absent offset means 0, that is everything still inside the replay window (protocol/src/exec.rs:186-191).
Output: text/event-stream carrying three typed events, all data: JSON — output = OutputEvent { offset: u64, stream: StreamKind, output: String } with output base64-encoded and stream one of stdout | stderr; gap = GapEvent { from: u64, to: u64 }; exit = ExitEvent { exit_code: Option<i32>, signal: Option<i32>, truncated: bool, writers_may_be_alive: bool, offset: u64 } (protocol/src/exec.rs:193-219, protocol/src/exec.rs:80-83, event names at protocol/src/exec.rs:307-309).
Statuses: 200; 400 malformed_request when offset is not a non-negative integer; 401; 503; 404 unknown_exec (agentd/src/schema.rs:431-446).
The next offset to resume from is a chunk’s offset plus the length of its decoded bytes. exit is terminal and is emitted before the body ends, so a body that closes without it means the connection failed rather than the command finishing — that distinction is why this is SSE and not a chunked byte stream (agentd/src/routes.rs:730-757). A gap reports bytes that are genuinely gone, because a client that cannot tell missing output from no output reads a truncated log as a complete one.
GET /v1/fs/file
Section titled “GET /v1/fs/file”pub async fn read_file(request: Request) -> Response {Reads one file, or a 1-based inclusive line range of it, streamed rather than buffered (agentd/src/routes.rs:645-650).
Auth: Bearer (agentd/src/routes.rs:644).
Input: FileReadQuery { path: String, start_line: Option<u64>, end_line: Option<u64> } as query string; path is required, both bounds are 1-based and inclusive, absent start_line means 1 and absent end_line means through EOF (protocol/src/fs.rs:39-57). The handler takes a bare axum::extract::Request and parses the query itself via file_read_query (agentd/src/fs.rs:1119), so the typed shape lives on the surface_docs() row rather than in the signature (agentd/src/routes.rs:639).
Output: application/octet-stream — the file’s bytes, or the requested window of them (agentd/src/routes.rs:640). No JSON envelope.
Statuses: 200; 400 for a missing path, a path naming a directory, a non-integer bound, start_line=0, or end_line before start_line; 401; 503; 404 only when the path is genuinely absent; 500 when the file cannot be opened or stat’ed (agentd/src/schema.rs:541-572).
An end_line past the last line reads through EOF with a 200 rather than a 416, because these are the AI SDK harness’s readTextFile semantics and this route is what that method is built on (protocol/src/fs.rs:34-37). This is the one place in the fs surface where a client’s FileNotFoundError is the right mapping of a 404; every protocol error above is a 400 for that reason (agentd/src/schema.rs:561-566, protocol/src/fs.rs:13-16). A range still streams — the read filters chunk by chunk and stops once the window closes, so nothing buffers a file to slice it (agentd/src/fs.rs:1112-1117).
GET /v1/fs/tar
Section titled “GET /v1/fs/tar”pub async fn read_tar(State(state): State<AppState>, request: Request) -> Response {Downloads the tree under ?path= as one uncompressed tar, streamed from a spool file (agentd/src/routes.rs:674-675).
Auth: Bearer (agentd/src/routes.rs:673).
Input: FsQuery { path: String, mode: Option<String> } as query string; only path is meaningful here, since mode is a property of a write (protocol/src/fs.rs:18-24). Parsed by fs_query inside the handler (agentd/src/fs.rs:1397).
Output: application/x-tar — uncompressed, streamed from a spool file (agentd/src/routes.rs:669, agentd/src/fs.rs:1446-1452).
Statuses: 200; 400 for a missing path or one that is not a directory; 401; 503; 404 when the directory is genuinely absent; 413 when the tree exceeds limits.max_tar_members or limits.max_tar_bytes, measured by a walk before anything is allocated; 500 when the tree cannot be walked or packed (agentd/src/schema.rs:594-625).
Symlinks are packed as symlinks rather than followed, which is the producing half of the contract PUT /v1/fs/tar implements on the consuming half; following them would silently change what a round trip means (agentd/src/fs.rs:1381-1384).
GET /v1/health
Section titled “GET /v1/health”async fn health(State(state): State<AppState>) -> Json<Health> {Reports liveness, daemon version, bootstrap state, disk headroom, identity-repair status, and whether any exec is still producing output (agentd/src/routes.rs:701-713).
Auth: Open — unauthenticated, and deliberately so, since bootstrapped is how a client learns whether the control API is open yet (agentd/src/routes.rs:700, agentd/src/schema.rs:653-662).
Input: none. No body, no query, no path parameters, per the signature at agentd/src/routes.rs:352.
Output: application/json body Health { version: Cow<'static, str>, bootstrapped: bool, disk: Option<DiskHealth>, identity_degraded: bool, identity_repaired: bool, busy: bool, execs: usize }, with DiskHealth { available_bytes: u64, reserve_bytes: u64, under_pressure: bool } (protocol/src/health.rs:11-118, protocol/src/health.rs:138-147).
Statuses: 200, always, bootstrapped or not (agentd/src/schema.rs:653-662).
disk: null is distinct from zero: unmeasurable free space is not a full disk, and a monitor conflating them would page on a missing statvfs (protocol/src/health.rs:31-33). busy and execs are the only two fields carrying #[serde(default)], because the daemon is baked into an image while a client is installed separately, so a required field would make a health call fail outright against an older daemon — turning a missing signal into an unreachable VM (protocol/src/health.rs:68-74). busy means producing, not unfinished: an exec waiting to be acked is not busy, so busy: false with a non-zero execs is a VM holding unacked output somebody still has to collect (protocol/src/health.rs:63-67). The field lives here rather than on a guest-callable keepalive route because the platform measures idleness at an endpoint proxy that terminates outside the guest, so in-guest traffic cannot reset the idle timer (agentd/src/routes.rs:335-351).
GET /v1/procs
Section titled “GET /v1/procs”pub async fn procs(State(state): State<AppState>) -> Response {Process accounting: every registered exec with its process group’s live pids, read from /proc inside the guest so the image needs no ps (agentd/src/exec.rs, procs; agentd/src/routes.rs, the /v1/procs row).
Auth: Bearer. The answer names every pid the daemon spawned, and a caller without the token has no business with it.
Input: none. No body, no query, no path parameters.
Output: application/json body ProcsResponse { procs: Vec<ProcGroup> } with ProcGroup { exec_id: String, pgid: Option<u32>, started_at: u64, child_exited: bool, reap: bool, pids: Vec<u32> } (protocol/src/exec.rs). One entry per registered exec in any phase, registry order. pgid is null when the child was reaped before Child::id() answered, and such an entry lists no pids. started_at is epoch seconds on the daemon’s clock, the same convention as a hook observation’s fired_at. pids are the live pids whose pgrp — field 2 after the closing ) of /proc/<pid>/stat — equals pgid; zombies (Z) are not live and are not listed.
Statuses: 200; 401; 503 (agentd/src/schema.rs, PROCS).
child_exited reads the write-once terminal marker rather than the polled result, for the reason activity gives: an ack takes the result, so a handler reading it would report every acked exec as running. The /proc walk runs on the blocking pool — one read_dir plus one small read per live pid — because it is synchronous filesystem I/O on a current-thread runtime. The row this route exists for is child_exited: true beside a non-empty pids: a command that finished while something it backgrounded did not (issue #157), whose exec_id is what POST /v1/exec/{id}/kill takes. reap echoes the start request’s reap_group_on_exit, the opt-in under which the daemon escalates the group itself when the child exits.
agentd/src/exec.rs
GET /v1/schema
Section titled “GET /v1/schema”async fn schema_route(State(state): State<AppState>) -> Json<serde_json::Value> {Serves the machine-readable wire contract: every route, shape, status code, and operative limit (agentd/src/routes.rs:723).
Auth: Open (agentd/src/routes.rs:722) — a client needs the contract before it holds a token, since the token arrives at the platform’s /run hook, and gating the document would make version negotiation impossible during exactly the window it matters (agentd/src/routes.rs:394-408).
Input: none, per the signature at agentd/src/routes.rs:409.
Output: the schema document, built by schema::document(state.config(), &surface_docs()) (agentd/src/routes.rs:410). Its top-level keys are $defs, $schema, auth, daemon_version, definition_collisions, generated_from, hook_prefix, limits, protocol_version, routes, title, unmatched_path, version_header, and the committed copy is docs/schema.json, regenerated and compared by the schema:check task (mise.toml:181-186) and by a test tier that also drives every documented route through the real router to confirm the daemon still answers it (agentd/tests/schema_artifact.rs:1-12).
Statuses: 200 (agentd/src/schema.rs:692-697).
Nothing here is secret. Every path, shape, and status code is in the published repository, and the limits are the operator’s own configuration; the one sensitive fact — whether a token is installed — lives on GET /v1/health instead (agentd/src/routes.rs:404-408). Note a divergence internal to the source: the surface_docs() row declares the response as octet_stream("this document"), so docs/schema.json publishes application/octet-stream, while the handler returns Json<serde_json::Value>, which axum serves as application/json (agentd/src/routes.rs:718 against agentd/src/routes.rs:409-411).
GET /v1/tcp
Section titled “GET /v1/tcp”pub async fn open( State(state): State<AppState>, Query(query): Query<TunnelQuery>, upgrade: WebSocketUpgrade,) -> Response {Upgrades to a WebSocket relayed to 127.0.0.1:<port> inside the guest, so a caller outside the VM can speak an arbitrary TCP protocol to a server inside it (agentd/src/tunnel.rs:126, agentd/src/routes.rs, the /v1/tcp row).
Auth: Bearer.
Input: TunnelQuery { port: u16, identity: bool } as query string (agentd/src/tunnel.rs). port is required; identity=1 asks for the Noise KK handshake against the per-VM key delivered in the launch payload before the dial.
Output: a 101 Switching Protocols and then binary WebSocket frames in both directions, each frame a byte range rather than a message — TCP has no message boundaries and neither does this. No JSON body on any path.
Statuses: 101; 400 malformed_request when ?port is absent or not a u16; 401; 503 (agentd/src/schema.rs, TUNNEL_OPEN). The real outcomes are close codes, because a WebSocket route leaves HTTP behind after its 101: 4502 nothing listening on the port, 4400 port 0, 4500 a mid-relay failure, 4401 this VM was launched without an identity seed, 4403 the handshake was refused.
The dial is loopback-only and never resolves a name — a relay that could reach another host would be an open proxy inside the VM reachable with the agent token. One connection per WebSocket, deliberately: multiplexing would reimplement per-stream ids, flow control, and close handshakes the platform already provides per connection. The dial happens after the upgrade so a refused dial arrives as a close code with a reason; on the endpoint path every WebSocket failure a caller can observe would otherwise be a 1006 with no reason. Rests on a measured platform property: binary frames survive a port-scoped token byte-exact (docs/PLATFORM.md, 2026-08-29).
POST /aws/lambda-microvms/runtime/v1/ready
Section titled “POST /aws/lambda-microvms/runtime/v1/ready”async fn ready_hook() -> StatusCode {Answers the platform’s image-build readiness probe (agentd/src/routes.rs:453-455).
Auth: PlatformHook (agentd/src/routes.rs:452) — unauthenticated; the prefix is fixed by the service and cannot be renamed or moved under /v1 (protocol/src/hook.rs:15, agentd/src/routes.rs:39-42).
Input: none. The handler takes no extractors at all, per the signature at agentd/src/routes.rs:270.
Output: a bare StatusCode, no body, per the signature at agentd/src/routes.rs:270.
Statuses: 200 only. The platform terminates the VM on any other status, so there is no failure a hook can usefully report (agentd/src/schema.rs:699-704).
Called during the image build, before any instance exists and therefore before any token has been delivered, so 200 is correct even with the control API closed: the question is whether the daemon started, not whether it is bootstrapped. Gating this on bootstrap state would fail every build (agentd/src/routes.rs:263-269).
POST /aws/lambda-microvms/runtime/v1/resume
Section titled “POST /aws/lambda-microvms/runtime/v1/resume”async fn resume_hook(State(state): State<AppState>) -> StatusCode {Acknowledges a resume from suspension, logging loudly if the bootstrap state did not survive (agentd/src/routes.rs:501-504).
Auth: PlatformHook (agentd/src/routes.rs:500).
Input: none beyond the injected AppState, per the signature at agentd/src/routes.rs:309.
Output: a bare StatusCode, no body, per the signature at agentd/src/routes.rs:309.
Statuses: 200 only (agentd/src/schema.rs:699-704).
Suspend is a freeze and restore, not a stop and start. Measured 2026-08-05 in us-east-1: the in-memory agent token, the filesystem, exec records, and even backgrounded processes all survive a suspend/resume cycle, and the endpoint URL is unchanged (agentd/src/routes.rs:293-308). What does not survive is the guest’s view of time, which the guest observes as a single jump, so any timeout, lease, or session held by a running command expires at once on resume (agentd/src/routes.rs:306-308). A resume that arrives without an installed token is logged as a warning naming the contradiction, not treated as routine (agentd/src/routes.rs:313-321). docs/PROTOCOL.md:19 still describes this hook as signalling in-memory state loss; the source records that claim as inferred rather than measured, and wrong (agentd/src/routes.rs:301-304).
POST /aws/lambda-microvms/runtime/v1/run
Section titled “POST /aws/lambda-microvms/runtime/v1/run”async fn run_hook( State(state): State<AppState>, body: Result<Json<RunHookEnvelope>, JsonRejection>,) -> Response {Installs the per-VM agent token once, plus the optional launch environment that becomes the base environment of every later exec (agentd/src/routes.rs:473-486).
Auth: PlatformHook (agentd/src/routes.rs:472) — unauthenticated because the platform has no credential to present; the defense is that this route can succeed only once (agentd/src/routes.rs:168-173).
Input: application/json body RunHookEnvelope { run_hook_payload: Option<String> }, serialized under the platform’s own camelCase key runHookPayload (protocol/src/hook.rs:27-30). The caller’s own JSON is one serde_json parse deeper inside that string, and parses to RunHook { agent_token: String, env: HashMap<String, String> } (protocol/src/hook.rs:46-72): {"runHookPayload": "{\"agent_token\": \"...\", \"env\": {\"KEY\": \"VALUE\"}}"}. agent_token is required and non-empty; env is optional, values must be strings, and unknown keys are ignored (protocol/src/hook.rs:145-189).
Output: a bare status, no body on success; on refusal, text/plain naming the problem (agentd/src/routes.rs:216).
Statuses: 200 on install or on replay of an identical token; 400 on a body that is not JSON, a missing runHookPayload, a payload that is not a JSON object, an absent or non-string or empty agent_token, a non-object env, or a non-string env value; 409 when a different token is already installed (agentd/src/schema.rs:706-735).
Bootstrap is one-shot, and an identical replay is 200 because the platform may retry its own hook and a 409 there would fail a launch that is fine (agentd/src/routes.rs:250-255). The refusal body names which key or shape was wrong and never quotes a value, which is why RunHookError is a typed enum rather than serde’s own message — serde quotes the value it rejected, and the value beside a bad env is a credential (protocol/src/hook.rs:74-99, protocol/src/hook.rs:142-144). An unknown key is ignored rather than refused, because any 400 here makes the platform terminate the VM before forwarding traffic, so a newer client’s unrecognised field must not kill the launch (protocol/src/hook.rs:134-140). The token never becomes part of the launch environment: AppState::bootstrap takes the two as separate arguments precisely so no code path can move one into the other (agentd/src/routes.rs:175-179, agentd/src/routes.rs:236). The token and the env share one 4096-byte platform budget measured in UTF-8 bytes, enforced client-side in microvms-core before the call (agentd/src/routes.rs:482-486).
POST /aws/lambda-microvms/runtime/v1/suspend
Section titled “POST /aws/lambda-microvms/runtime/v1/suspend”async fn suspend_hook() -> StatusCode {Acknowledges and logs an incoming suspend (agentd/src/routes.rs:494).
Auth: PlatformHook (agentd/src/routes.rs:493).
Input: none, per the signature at agentd/src/routes.rs:287.
Output: a bare StatusCode, no body, per the signature at agentd/src/routes.rs:287.
Statuses: 200 only (agentd/src/schema.rs:699-704).
POST /aws/lambda-microvms/runtime/v1/terminate
Section titled “POST /aws/lambda-microvms/runtime/v1/terminate”async fn terminate_hook() -> StatusCode {Acknowledges termination, which begins graceful shutdown with in-flight requests draining (agentd/src/routes.rs:511).
Auth: PlatformHook (agentd/src/routes.rs:510).
Input: none, per the signature at agentd/src/routes.rs:326.
Output: a bare StatusCode, no body, per the signature at agentd/src/routes.rs:326.
Statuses: 200 only (agentd/src/schema.rs:699-704).
The draining behavior is the surface_docs() row’s documented contract for this hook (agentd/src/routes.rs:507-513).
POST /aws/lambda-microvms/runtime/v1/validate
Section titled “POST /aws/lambda-microvms/runtime/v1/validate”async fn validate_hook() -> StatusCode {Answers the platform’s image-build validation probe (agentd/src/routes.rs:462-464).
Auth: PlatformHook (agentd/src/routes.rs:461).
Input: none, per the signature at agentd/src/routes.rs:281.
Output: a bare StatusCode, no body, per the signature at agentd/src/routes.rs:281.
Statuses: 200 only (agentd/src/schema.rs:699-704).
Like ready, this is an image-build hook rather than an instance hook: the build calls it to decide whether the snapshot it just produced is usable, so a daemon that omits it fails the build rather than the run — a confusing place to discover the omission (agentd/src/routes.rs:44-47, agentd/src/routes.rs:280).
POST /v1/exec/start
Section titled “POST /v1/exec/start”pub async fn start( State(state): State<AppState>, body: Result<Json<StartRequest>, JsonRejection>,) -> Response {Starts a command under a caller-minted exec_id, idempotently on that id (agentd/src/routes.rs:521-522).
Auth: Bearer (agentd/src/routes.rs:520).
Input: application/json body StartRequest { exec_id: String, command: Vec<String>, shell: bool, cwd: Option<String>, env: HashMap<String, String>, user: Option<u32>, group: Option<u32>, timeout_sec: Option<f64>, stdin: bool }. Every field after command carries #[serde(default)], so a request may be as small as {"exec_id":"e1","command":["true"]} (protocol/src/exec.rs:104-152).
Output: application/json body StartResponse { exec_id: String, phase: Phase } (protocol/src/exec.rs:221-224).
Statuses: 200 on start or on a retry of an already-started exec_id; 400 malformed_request when the body is invalid, exec_id is empty, timeout_sec is not a positive finite number, or command is empty with shell false; 401; 503; 413 when the body exceeds limits.max_body_bytes; 500 spawn_failed when the child cannot be spawned, deliberately not 404 (agentd/src/schema.rs:395-417).
command is argv when shell is false and a single script string when it is true (protocol/src/exec.rs:101-109). Omitting cwd means the child inherits the daemon’s own working directory, which is the image WORKDIR (protocol/src/exec.rs:112-114). stdin is opt-in rather than always-on: a child holding an open stdin pipe nobody will write to blocks forever the first time it reads, and /bin/sh, git, and any tool probing for input all behave differently against a pipe than against /dev/null (protocol/src/exec.rs:128-138). timeout_sec is validated before the child spawns, because the predecessor raised inside the waiter thread by which point the child was already running and became an orphan (protocol/src/exec.rs:123-125).
POST /v1/exec/{id}/ack
Section titled “POST /v1/exec/{id}/ack”pub async fn ack(State(state): State<AppState>, Path(id): Path<String>) -> Response {Releases an exited exec’s buffered output to the caller and starts its TTL collection clock (agentd/src/routes.rs:576-577).
Auth: Bearer (agentd/src/routes.rs:575).
Input: the {id} path segment only, per the signature at agentd/src/exec.rs:846. No body.
Output: application/json body PollResponse { exec_id: String, phase: Phase, result: Option<Outcome> } — the same shape GET /v1/exec/{id} returns (protocol/src/exec.rs:281-287, agentd/src/routes.rs:571).
Statuses: 200; 401; 503; 404 unknown_exec; 409 still_running; 409 already_acked (agentd/src/schema.rs:494-516).
This is the only way output leaves the daemon’s custody, and only acked entries are ever collected, so output nobody read is never destroyed (agentd/src/routes.rs:576-577, agentd/src/schema.rs:496-499). Acking a still-running exec is 409 rather than a silent success, because succeeding would drop output still being written — which is precisely what the Python predecessor’s unlink-on-exit did (agentd/src/exec.rs:841-845). A duplicate ack is 409 already_acked rather than a 200 with an empty body, since an empty 200 reads as “the command produced no output” (agentd/src/schema.rs:510-515).
POST /v1/exec/{id}/kill
Section titled “POST /v1/exec/{id}/kill”pub async fn kill(State(state): State<AppState>, Path(id): Path<String>) -> Response {Escalates SIGTERM then SIGKILL to the exec’s whole process group (agentd/src/routes.rs:587-589).
Auth: Bearer (agentd/src/routes.rs:586).
Input: the {id} path segment only, per the signature at agentd/src/exec.rs:920. No body.
Output: application/json body KillResponse { exec_id: String, killed: bool }, where killed: false with a 200 means the process group had already exited — which is the outcome a kill was asking for (protocol/src/exec.rs:235-240).
Statuses: 200; 401; 503; 404 unknown_exec (agentd/src/schema.rs:518-528).
The signal goes to the process group and not just the direct child, because a shell that backgrounded a server leaves the interesting process outside the child pid, and kill(child) returned success while the workload kept running (agentd/src/exec.rs:915-919). KillResponse is a named type rather than a serde_json::json! literal precisely so the published schema can describe the one field a client most needs to branch on (protocol/src/exec.rs:227-233).
POST /v1/exec/{id}/stdin
Section titled “POST /v1/exec/{id}/stdin”pub async fn write_stdin( State(state): State<AppState>, Path(id): Path<String>, body: Result<Json<StdinRequest>, JsonRejection>,) -> Response {Writes bytes to a running child’s stdin, or closes the pipe with an explicit EOF (agentd/src/routes.rs:562-566).
Auth: Bearer (agentd/src/routes.rs:561).
Input: the {id} path segment plus application/json body StdinRequest { data_b64: Option<String>, signal: Option<String> }; both fields are #[serde(default)] and meaningful together, since a final chunk plus EOF in one request is the common case for feeding a prompt. data_b64 is base64 because stdin is arbitrary bytes and a JSON string cannot carry non-UTF-8; signal accepts "eof" (protocol/src/exec.rs:154-170).
Output: application/json body StdinResponse { exec_id: String, written: usize, eof: bool }, with eof echoed back so a client confirms the pipe closed rather than inferring it (protocol/src/exec.rs:178-182, agentd/src/schema.rs:449-454).
Statuses: 200; 400 malformed_request; 401; 503; 404 unknown_exec; 408 stdin_write_timeout, retryable and some bytes may already have been written; 409 stdin_not_requested when the exec was started without stdin: true, fixable at start time hence 409 and not 400; 410 stdin_closed, since retrying will never succeed; 413 stdin_write_too_large against limits.max_stdin_write_bytes; 500 stdin_write_failed (agentd/src/schema.rs:448-492).
A separate endpoint from the output stream on purpose: multiplexing the write half onto the read connection would make a dropped attach also drop the ability to feed the process, so reconnecting becomes load-bearing for correctness rather than only for observation (agentd/src/exec.rs:691-696). EOF is explicit rather than inferred, because a child reading stdin cannot exit until the daemon drops its own handle (agentd/src/routes.rs:562-566).
PUT /v1/fs/file
Section titled “PUT /v1/fs/file”pub async fn write_file(State(state): State<AppState>, request: Request) -> Response {Writes one file at ?path=, streaming the body to disk rather than buffering it (agentd/src/routes.rs:661-663).
Auth: Bearer (agentd/src/routes.rs:660).
Input: FsQuery { path: String, mode: Option<String> } as query string, plus an application/octet-stream body of the file’s bytes. mode is an octal file mode carried as a string so 0644 and 644 both parse and neither reads as decimal 644 (protocol/src/fs.rs:18-24, agentd/src/routes.rs:655-656).
Output: no body; 204 on success (agentd/src/schema.rs:575).
Statuses: 204; 400 for a missing path or an invalid octal mode; 401; 503; 413 against limits.max_body_bytes, enforced on the wire; 507 when the target filesystem is under the configured disk reserve; 500 when the parent cannot be created or the write or chmod fails (agentd/src/schema.rs:574-592).
Deliberately not confined to a root: the same token authorizes exec, so a root prefix would add no security while breaking harnesses that write to home directories and /etc (agentd/src/routes.rs:661-663). The mode is parsed before a single byte lands, so a rejected mode leaves nothing behind (agentd/src/fs.rs:1216-1217). 507 rather than 500 for disk pressure, because a 500 is indistinguishable from a daemon defect and a client retries it — right for a defect, actively harmful for a full disk — and rather than 413, because the write is not too large for the protocol, only for the space left (agentd/src/schema.rs:379-387).
PUT /v1/fs/tar
Section titled “PUT /v1/fs/tar”pub async fn write_tar(State(state): State<AppState>, request: Request) -> Response {Uploads and extracts an uncompressed tar under ?path=, confined to that root (agentd/src/routes.rs:686-691).
Auth: Bearer (agentd/src/routes.rs:685).
Input: FsQuery { path: String, mode: Option<String> } as query string, plus an application/x-tar body spooled to an unlinked temp file rather than buffered (protocol/src/fs.rs:18-24, agentd/src/routes.rs:681). path must be absolute: a relative root would resolve against the daemon’s own working directory, which the caller cannot see (agentd/src/fs.rs:1465-1471).
Output: no body; 204 on success (agentd/src/schema.rs:628).
Statuses: 204; 400 for a missing or non-absolute path, a truncated body, or a member violating the extraction contract — an escaping path, an absolute or out-of-tree link target, or a device or fifo member, with the refused member’s name in the body; 401; 503; 413 against limits.max_body_bytes on the wire or limits.max_tar_members / limits.max_tar_bytes once decoded; 507 under disk pressure; 500 on a filesystem failure (agentd/src/schema.rs:627-651).
This is the one confined write path in the fs surface, and the reason is that member paths come from the archive rather than from the caller (agentd/src/fs.rs:1455-1458). Extraction mirrors the CPython tarfile data filter: in-tree symlinks preserved, absolute link targets refused, relative targets resolved lexically so a symlink written earlier in the same archive cannot redirect a later member (agentd/src/routes.rs:686-692). The confinement is held by four generated properties rather than by an enumeration of known-bad strings, the first being that nothing lands outside the root — asserted by walking the disk afterwards rather than by restating which members were refused (agentd/tests/proptest_tar.rs:1-20).
See also
Section titled “See also”- impact analysis — 11 shared source citations
- contract map — 10 shared source citations
- debugging guide — 7 shared source citations
- business logic — 6 shared source citations
- state machines — 5 shared source citations