silkd is the in-guest product daemon: a Rust binary baked into every
template image, listening on guest vsock port 2048 (SILKD_PORT overrides
the port) for the host only — a peer with any other vsock CID is refused —
and started at sysinit in parallel with boot. It is what actually
“runs something” inside a sandbox; sandboxd relays SDK connections to it
byte-for-byte. Reaching silkd is the
claim-readiness signal — a claim returns only once silkd answers.
Most users never speak the protocol directly — the Go SDK and Python SDK cover the full surface. This page is the wire reference for other clients and for debugging.
Newline-delimited JSON frames, RPCs back to back on one connection. A
request frame, tagged "op", opens an RPC; the server streams response
frames tagged "type" until a terminal one (exit, done, or error),
then reads the next request on the same connection. stdin, stdin_close,
data and data_end frames are input for the RPC in flight: after its
terminal frame they are dropped, and a request frame sent while a streaming
RPC still runs ends that RPC’s input and is served after it. A client that
closes after one RPC is served exactly as before; info reports proto: 2
on a daemon that keeps the connection. Binary payloads ride base64 in data
fields. Frames are capped at 8 MiB; requests carry "v": 1 and unknown
fields are ignored (the forward-compatibility story).
Sessions and detached processes are server-side state addressed by id — a
dropped connection loses nothing (attach resumes). A foreground exec is
bound to its connection, but silkd learns of a drop only when a write to the
client fails: a child that is producing output is killed and its pid freed, a
silent one runs to completion — which is why the SDKs’ Run/run answer a
canceled ctx or a timeout with a kill of the pid started reported. The other
connection-bound verbs are fs_watch, pty_open, lsp_request, and
port_forward.
The authoritative wire contract is the shared fixture corpus in
protocol/wire/fixtures/v1, round-tripped by the Rust, Go, and Python test
suites — a frame only one side can parse fails CI.
| group | ops | response flow |
|---|---|---|
| exec | exec {argv, cwd?, env?, user?, detach?, session?} |
started{pid} → stdout/stderr{data}… → exit{code}; the client may stream stdin{data} / stdin_close. detach returns after started; the process keeps a bounded output ring for later logs/attach and leaves the table 5 minutes after it exits, after which its pid answers not_found; not combinable with session. With session only argv is used — cwd, env, user and stdin frames are ignored (the session owns them, and its commands read /dev/null) — no started is sent, and stderr arrives merged into stdout. A child’s environment is cleared to PATH, TERM and the lane’s proxy variables, then the claim env, then env; HOME and USER are set only with user |
| procs | ps / kill {pid, signal?} / attach {pid} / logs {pid} |
ps answers one procs{procs} frame, kill a bare done; logs replays stdout/stderr{data}…, adds exit{code} once the process has exited, and closes with done; attach replays and follows stdout/stderr{data}… to exit{code}, or to done when the process is gone before its exit. Handles are guest pids; any connection can list, signal, replay, or re-attach live |
| sessions | session_create {id?, cwd?, env?} / session_list / session_rm {id} |
session_created{id} / sessions{sessions} / done. A session is a real persistent bash; exec with session runs inside it. Idle sessions are reaped after 30 minutes |
| fs | fs_write {path, mode?} (+data/data_end frames) / fs_read / fs_list / fs_stat / fs_mkdir {parents?} / fs_rm {recursive?} / fs_rename {from, to} |
fs_read streams data{data}… → done for a regular file and answers bad_request for a directory, device or FIFO, fs_list streams 4096-entry entries{entries} batches → done, fs_stat answers one stat{info}, and the mutating verbs terminate with done. Streaming runs both directions; write commits atomically via temp+rename and inherits an overwritten file’s mode |
| tree | fs_push {dest} (+tar as data frames) / fs_pull {path} |
whole trees as tar streams through the guest tar: fs_pull streams data{data}… → done, fs_push terminates with done |
| search | fs_find {path, pattern, glob?} → match{file, line, content}… → done / fs_replace {files, pattern, replacement} → replaced{file, replacements}… → done |
regex as data, no shell quoting; glob is anchored */? wildcards over file names; find skips binary and >8 MiB files, and replace skips the same 8 MiB bound with a zero count. One match whose frame would pass the 8 MiB cap ends the find with an error. Replace is atomic per file, not per list: it stops with an error at the first missing, unreadable or non-UTF-8 file, and the files before it stay rewritten |
| watch | fs_watch {path, recursive?} |
ready once armed (events after it are guaranteed captured), then event{kind, path} until the client disconnects; watcher or delivery-queue overflow errors arrive as a terminal error instead of silently losing events |
| pty | pty_open {cols, rows, cwd?, env?, user?} / pty_resize {pid, cols, rows} |
pty_open answers started{pid}, then a shell under a pseudo-terminal: output as stdout frames, input as stdin frames, exit{code} terminal; pty_resize answers done. PTYs register in the proc table like any exec |
| git | git_clone {url, path, branch?, depth?, auth?} / git_status {path} / git_add {path, files} / git_commit {path, message, author} / git_push {path, auth?} / git_pull {path, auth?} / git_branch {path, action, name?} |
git_status_result (porcelain v2), git_commit_result{hash}, git_branches{current, branches} for git_branch {action: "list"}; every other verb terminates with done. A status past about 1 MiB of entries carries truncated: true with the head of the list, so one frame never exceeds the cap. auth is injected as an in-memory header, never written to guest disk |
| port | port_forward {port} |
relays guest TCP 127.0.0.1:port over this connection: ready once connected, then data both ways. Each direction ends on its own: data_end half-closes the guest socket, done reports the guest closing its write side, and the RPC ends once both have. Works on both lanes — the no-network lane’s only way in |
| lsp | lsp_start {language, root?} / lsp_request {server_id} / lsp_stop {server_id} |
a broker for the language server a flavor image ships: lsp_start spawns the argv named in /etc/silkd/lsp.d/<language> (absent on the base image → not_found naming the flavor) and answers lsp_started{server_id}; lsp_request attaches the JSON-RPC byte stream (ready, then data both ways — silkd pipes bytes, it never parses LSP; data_end half-closes the server’s stdin) and the stream ending reaps the server (v1 single-shot); lsp_stop kills it early, and a server nothing attaches within 5 minutes is reaped |
| misc | info |
info{version, proto, uptime_secs, procs, sessions} — sandboxd uses it for readiness and the SDKs use proto to negotiate connection reuse; there is no public SDK method. Distinct from the control plane’s GET /v1/info, which reports node pools and claims |
A failed verb terminates with error {kind, message}:
| kind | meaning |
|---|---|
bad_request |
malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable), an exec or pty user the guest cannot resolve (unknown user, or look up user: <errno> when the name service itself fails) |
not_found |
unknown pid/session/path, a port_forward port nothing listens on, a language with no LSP manifest |
unimplemented |
verb unavailable on this lane — notably git clone/push/pull on the no-network lane, whose message points at fs.push |
internal |
everything else (other spawn failures, git errors, I/O) |
git clone/push/pull consult lane detection: an interface under
/sys/class/net counts only when it has a device backing (a virtio or
physical NIC). Name-based checks are not enough — the all-builtin sandbox
kernel auto-creates virtual tunnels (sit0 and friends) even on the no-NIC
lane. SILKD_NET=none|egress overrides the probe for tests and operators.
On every lane silkd also binds 127.0.0.1:3128 and relays each connection to
the host’s guarded-egress proxy over vsock (CID2:2049), and
127.0.0.1:1080 to its SOCKS5 door (CID2:2050) for clients whose protocol
has no HTTP-proxy form; when the host wired no policy, or the door-owning
policy (the pool’s, or the tenant’s on a claim outside any pool) did not opt
into the SOCKS5 door, the per-connection dial is refused, so the ports are
inert. Where an image aliases 169.254.169.254 on lo, silkd also serves the
sandbox’s instance-metadata document
there in the IMDSv2 shape.
Where nothing routes directly — the no-network lane, or a NIC whose host
verdict in /etc/silkd-lane reads relay because it is nft-locked — silkd
forwards the image-baked proxy variables (http_proxy and friends) into every
exec, so unconfigured clients use the relay without being told; a lane with its
own routed network never gets them.
The host writes a claim’s guest env entries
to /run/silkd.env (root, 0600) in systemd EnvironmentFile syntax, one
NAME="value" per line with \, ", ` and $ escaped. silkd reads it
at every exec, session and pty it spawns and applies it after the base
environment above, so a request’s own env still wins. A session’s shell
keeps the file as it was when the session started: a later change reaches new
execs, ptys and sessions only. A long-running unit
reads it with EnvironmentFile=-/run/silkd.env at start. The host writes the
file when a claim with guest entries is made, when its guest entries change,
and when the same env is sent again; a change to host-only entries never
touches it. A unit that must follow a change restarts on it, for example from
a .path unit. The file is in the guest’s memory, so it survives hibernate
and archive; a branch, fork child or promoted-template clone whose source had
guest entries gets its own file rewritten at claim. An
older silkd ignores the file; units that read it still see it.
exit is sent
and the rest is dropped. Detached observers ride a best-effort broadcastfs_push is network-failure atomic: the stream extracts into a staging
dir inside dest and merges (local renames) only after the tar terminates
cleanly, so a truncated stream or dropped connection leaves dest’s
contents untouched; dest itself and any missing parent are created before
the stream starts and stay behind, empty, on failure. The residual crash
window is the merge itself, microseconds