Multi-host

Control rupu agents and workflows running on other machines from one central Control Plane — register remote hosts, launch work on the host you choose, and watch it stream back, all in a single browser tab.

What it is

Multi-host is a hub model. One central Control Plane federates out to a fleet of remote hosts, each running its own control plane (rupu cp serve). A Host is a top-level peer in rupu — it sits alongside your Projects, not underneath them. The machine the central CP runs on is itself just a host: host[0], "this host", always present and always first.

The remote is always the source of truth — the central CP keeps no authoritative copy of a host's run state, and tags every run with the host it came from. How it reaches that state depends on the transport: directly-reachable hosts (HTTP, SSH) are live-queried on demand — open a run list and the CP fans out to each host, merges the results, and proxies straight through to a run's detail / event stream / transcript; hosts that can't be reached inbound (tunnel, bucket) mirror their run artifacts back to the central CP as they change. Either way you get one merged fleet view, and no host's state is duplicated as a second source of truth.

No new inbound surface. The central CP is purely a client this slice — it adds no new way in. Each remote host's API is already token-gated by its own rupu cp serve, and that token is what the center authenticates with.
central control plane the hub / client host[0] (local) in-process host A rupu cp serve host B system ssh local HTTP SSH
One central control plane reaching each host through whichever transport fits — in-process for host[0], HTTP and SSH here, plus dial-home tunnel and dead-drop bucket for hosts it can't connect to directly.

Connection types

Every host is reached through a single transport port (HostConnector), so the Fleet surface, host attribution, launcher, and live streams work the same no matter how a host is wired up. Five transports ship today, covering hosts you can reach directly, hosts behind a NAT or firewall, and hosts you can't reach at all:

TransportHow the host is reachedHow to registerBest for
Local In-process — no network hop. This is host[0], "this host," always present. Built in (the local host). Nothing to register. The machine the central CP runs on.
HTTP (federation) The central CP is a client of the remote's rupu cp serve, over its token-gated HTTP API. rupu host add <name> --url https://… --token … A remote that can expose a reachable, TLS-fronted server.
SSH The CP connects out over SSH and runs rupu on the remote; auth is delegated to the system ssh. rupu host add <name> --ssh user@host A host you can already ssh to but that runs no server.
Tunnel (dial-home) The remote dials home over an outbound WebSocket; the CP reaches it via its node registry. Works behind NAT / firewalls. rupu node enroll <name>, then run rupu node on the box. A NAT'd / firewalled box that can't expose any inbound port.
Bucket (pull / dead-drop) The CP and the worker exchange jobs + results through an object store. Neither side connects to the other. rupu host add <name> --bucket s3://…, then run rupu node pull. Air-gapped / fully disconnected / batch hosts.
Where hosts persist. Every host is a TOML record under ~/.rupu/hosts/<id>.toml holding only its name and transport metadata. HTTP bearer tokens live in the system keychain, referenced by host id; tunnel hosts store only the SHA-256 hash of the enrollment token; SSH and bucket hosts store no secret at all (auth is delegated to the system ssh and the cloud credential chain).

Local — host[0]

The machine running the central CP is itself a host: the built-in local entry, always present and always first. It needs no registration and can't be removed. Single-host setups never touch any of the transports below — everything just runs on local.

HTTP federation

Federation has two sides. On the remote machine, run the control plane so it's reachable from the center and guarded by a token. Bind to an address other than loopback, and set --token so /api/* requires Authorization: Bearer <token>:

# on the REMOTE machine — serve the control plane, bound + token-guarded
$ rupu cp serve --bind 0.0.0.0:7878 --token "$RUPU_CP_TOKEN" --no-open

Put a TLS-terminating reverse proxy in front for a real https:// URL — remote base URLs are expected to be HTTPS so the token is protected in transit. Then, on the central machine, register that host with a display name, the remote's base --url, and the --token:

# on the CENTRAL machine — register the remote host
$ rupu host add prod --url https://host.example.com --token "$REMOTE_TOKEN"
host_01J9Z4W7Q0X8Y6V5K3M2N1P0R8

To keep the token out of your shell history and process list, read it from stdin instead with --token-stdin:

# pipe the token in on stdin (mutually exclusive with --token)
$ pass show rupu/prod | rupu host add prod --url https://host.example.com --token-stdin

SSH

The SSH transport targets a host that runs a full rupu install but exposes no rupu cp serve and runs no dial-home agent — yet you can already ssh to it. The central CP connects out over SSH: it dispatches a run with ssh host rupu workflow run … (detached on the remote so it survives the SSH session), then keeps a long-lived ssh … tail -f pump that mirrors the run's artifact files back into the central run store — so the run shows up in the same host-aware lists, detail, and live events as any other. SSH hosts are fully at parity with the other transports: they report the remote rupu version and list sessions, agent + autoflow runs, and events just like HTTP hosts do. Register it with the --ssh destination:

# register an SSH host — destination is user@host or a ~/.ssh/config alias
$ rupu host add build-box --ssh deploy@build.example.com --port 22 --identity ~/.ssh/id_ed25519
host_01J9ZB3KQ8M2T0V4W6X8Y1A3C5
No secret stored. Authentication is delegated entirely to the system ssh — ssh-agent, ~/.ssh/config, default keys. The host record keeps only the destination, optional --port, and optional --identity path. Because the destination can be a ~/.ssh/config alias, ProxyJump / ControlMaster / keys all come for free from your SSH config.

Transcripts stream back on demand. Every step, fan-out unit and agent run that executes on an SSH host — a whole workflow launched there, or a single host: / distribute: unit of a local workflow — shows its transcript in the central CP, live while it runs and complete afterwards. The mirror is lazy: no SSH session is opened for a transcript nobody is looking at. Opening one starts a single shared tail -F for that file, however many viewers have it open, and it is stopped when the last one leaves. When the run finishes, one batched pull copies every transcript (and the run's usage, coverage and netflow streams) to the coordinator, so they stay readable and counted in usage even after the host goes offline or is removed. A transcript the coordinator couldn't finish collecting is labelled partial, never silently cut short. Launches on the remote are detached with setsid where it exists, else nohup, so they survive the SSH session on Linux and macOS alike.

Tunnel (dial-home)

When a host is behind a NAT or firewall and can't expose any inbound port, invert the direction: a lightweight rupu node agent on the box dials out to the CP over a persistent WebSocket, executes dispatched runs locally, and streams their artifacts back. The CP reaches the node through its node registry — no inbound ports, no reachable server. Start on the central machine by enrolling the node, which mints a one-time token and prints the exact command to run on the box:

# on the CENTRAL machine — mint a node + one-time token
$ rupu node enroll build-box-01 --cp-url wss://cp.example.com
enrolled: build-box-01 (host_01J9ZC4M0N1P2Q3R4S5T6U7V8W)

⚠  token shown ONCE — copy it to the node now:

  rupu node --cp-url wss://cp.example.com --token <token> --node-id host_01J9ZC4M0N1P2Q3R4S5T6U7V8W

Copy the token to the remote box and run the node agent there, reading the token from stdin so it never lands in shell history. The agent authenticates, stays connected, and reconnects with backoff if the link drops:

# on the REMOTE box — dial home and stay connected
$ rupu node --cp-url wss://cp.example.com --token-stdin < token.txt
The plaintext token is shown once. Only its SHA-256 hash is stored in the tunnel host record — the CP verifies an inbound node connection against the hash without ever keeping the secret on disk. Tunnel-node runs are mirrored as first-class, host-attributed runs in the central store, so they observe and control exactly like local runs (launch, cancel, approve / reject).

Bucket (pull / dead-drop)

The most decoupled transport: for a host the CP can't reach at all and which can't reach the CP either. Both sides independently talk to a shared object-store bucket that acts as the gateway. The CP writes dispatched work into the bucket; the worker polls it, atomically claims a job, runs it locally, and writes results back; a CP-side poller reads those results and mirrors them in. Register the host on the central machine with a bucket URL and optional prefix:

# on the CENTRAL machine — register a bucket (dead-drop) host
$ rupu host add nightly --bucket s3://my-bucket --prefix rupu/host-1
host_01J9ZD5N1P2Q3R4S5T6U7V8W9X

On the worker box, run the pull agent against the same bucket and prefix. It claims jobs, runs them locally, and writes results back — no inbound or outbound connectivity to the CP required. By default it loops forever, polling on an interval; pass --once to drain whatever is queued a single time and exit (handy for cron / batch hosts):

# on the WORKER box — poll the bucket, claim + run jobs, write results back
$ rupu node pull --bucket s3://my-bucket --prefix rupu/host-1

# or drain once and exit (cron / batch)
$ rupu node pull --bucket s3://my-bucket --prefix rupu/host-1 --once
Object stores + no stored secrets. Bucket URLs may be s3://, gs://, or file:// (a shared filesystem on a local network). Credentials are resolved from the standard cloud credential chain / environment (AWS_*, GOOGLE_*) — rupu stores none. Control messages (cancel / approve / reject) are queued in the bucket and applied on the worker's next poll, since a dead-drop is inherently asynchronous.

Manage hosts

rupu host list shows every configured host. The built-in local host is always first and can't be removed — it's host[0], "this host." Each row is the host id, name, and a transport label (the base URL for HTTP, or ssh:… / tunnel:… / bucket:… for the others):

$ rupu host list
local                              local         local
host_01J9Z4W7Q0X8Y6V5K3M2N1P0R8   prod          https://host.example.com
host_01J9ZB3KQ8M2T0V4W6X8Y1A3C5   build-box     ssh:deploy@build.example.com:22
host_01J9ZC4M0N1P2Q3R4S5T6U7V8W   build-box-01  tunnel:host_01J9ZC4M0N1P2Q3R4S5T6U7V8W
host_01J9ZD5N1P2Q3R4S5T6U7V8W9X   nightly       bucket:s3://my-bucket

Remove a remote host by its id with rupu host remove. This deletes the TOML record and any keychain token; removing local is refused:

$ rupu host remove host_01J9Z4W7Q0X8Y6V5K3M2N1P0R8
removed host_01J9Z4W7Q0X8Y6V5K3M2N1P0R8

The same registry is exposed in the browser — adding and removing hosts in the Control Plane writes the exact same TOML records and keychain entries, so the CLI and the CP are always looking at one shared fleet. Removing a tunnel host from the control plane also closes its live node connection at once; the node's reconnect then fails enrollment.

In the Control Plane

With hosts registered, the central Control plane becomes fleet-aware. Everything you can do on the local host you can now do on a remote one — whichever transport reaches it.

Driving the fleet needs the full runtime. Controlling remote hosts requires the central side to run the full rupu cp serve runtime — that's what installs the host registry with its transport connectors and the mirror / pull workers. The read-only rupu cp can show hosts but cannot control them.

Distribute work across the fleet

Beyond running a whole workflow on one host, a single workflow run can place individual workflow steps — or spread one step's units — across the fleet. Three placement controls ship today, and they all work over every transport (HTTP / SSH / tunnel / bucket): fan-out units, per-step placement, and workspace sync so a remotely-placed step can read and write your real project files.

Fan-out units across hosts

A for_each step's units can be spread across the fleet. Add a distribute: block naming the hosts; rupu assigns the units round-robin, dispatches each to its host, attributes the unit's run to that host, and aggregates the results back into the step — partial failures honored per continue_on_error.

  - id: review_each
    agent: code-reviewer
    for_each: "{{ inputs.files }}"
    distribute:
      hosts: [gpu-box, build-box]
    prompt: "Review {{ item }}."

Per-step host placement

A single linear step (one with an agent and a prompt) can run on a named fleet host with a host: field. The whole step's agent runs on that host instead of locally, and its output feeds downstream steps exactly as a local step would. host: is linear-only — it's rejected on for_each, parallel, and panel steps (use distribute: to spread a for_each), and it must name a non-empty host. Absent, the step runs locally, exactly as before.

  - id: train
    agent: model-trainer
    host: gpu-box
    prompt: "Fine-tune on {{ inputs.dataset }}."

A placed unit (host: or a distribute: unit, including a fallback-host retry and a resumed run) runs under the run's engagement profile: the ids travel to the host as rupu run --engagement-profile and resolve against the host's own profiles, so install a custom profile there too. A host that can't honour it is refused, never run on the code path. A placed step's skills: / preload_skills: travel with the unit too — see Skills on placed units below.

Workspace sync for remote steps

By default a remotely-placed step is self-contained — it sees only its rendered prompt plus prior steps' string outputs, not your files. Add workspace: sync to a remote step (one with host: or distribute:) and the coordinator ships its file workspace to the host: the step's agent reads and writes the real project files, and the changed files are propagated back so downstream steps see them. This is how file-mutating steps can now run remotely.

The mechanism is auto git-or-tar: if the workspace is a git repo it syncs via git (patch / 3-way merge on the return path); otherwise it packs a gitignore-aware tar and returns a changed-files delta. Detection is automatic — no extra config, and no git/tar tooling needs to be installed on the host (rupu carries a vendored libgit2 and tar). It's opt-in and only valid on a remote step; on a purely-local step workspace: sync is rejected as a no-op.

  - id: refactor
    agent: refactorer
    host: gpu-box
    workspace: sync
    prompt: "Apply the migration across the crate."

See Workflows for the full step format.

Local concurrency and host memory

A wide fan-out or a split: graph can ask for far more concurrent agents than a machine can hold — each one keeps a conversation in memory and spawns tool subprocesses. Every rupu process therefore runs local agent jobs through an admission gate with two layers, whatever a workflow's max_parallel: or max_concurrency: says:

Key ([runtime])DefaultWhat it does
max_concurrent_jobsclamp(RAM_GB / 4, 4, 128)Hard ceiling on local agent jobs running at once. Env: RUPU_MAX_CONCURRENT_JOBS.
min_free_memory_mbmax(10% of RAM, 4096)A new job waits until this much memory is available, read from /proc/meminfo (Linux) or vm_stat (macOS). A job held 60 s goes ahead anyway, so a machine whose memory never recovers still makes progress. 0 turns the watchdog off. Env: RUPU_MIN_FREE_MEMORY_MB.

A throttled step gets a run warning, so a slow fan-out explains itself. Remote units are not counted against the coordinator's gate: they spend the remote host's memory, and that host's own gate bounds them.

What comes back from a placed unit

A placed unit runs as a standalone rupu run on its host, so what it records lands there first. Three streams bring it home when the unit ends: its coverage and findings, its netflow ledgers, and (on first view) the artifacts its findings reference. None of them can fail the unit; what could not be collected is reported, never silently missing.

Coverage and findings

A remote unit's coverage and findings come home through the run's coverage stream — every rupu run streams its runs, file touches, concern assertions, findings and engagement assets to $RUPU_HOME/runs/<run_id>/coverage.jsonl, which the host's connector delivers to the coordinator when the unit ends, success or failure, on every transport. The coordinator merges it under its own workspace's targets (re-keyed to the run's scope, de-duplicated, the unit's tag events appended to the workspace tag log by event id) before the unit's workspace: sync delta applies, and records the unit's artifacts as stored: external with the host's id. A unit placed on the coordinator's own local host shares its store, so its artifacts stay copied.

A finding's artifacts (proof-of-concept files, evidence-block files such as images) stay on the host that recorded them until someone opens them. The first preview or download of one (GET /api/findings/:id/artifacts/:sha256, the web finding page's artifact browser) pulls the blob from its host, capped at the recorded size and checked against the recorded size and SHA-256 before it enters the coordinator's store. Concurrent first downloads share one pull, and a recorded size over the coordinator's own [findings].artifact_max_bytes is refused without contacting the host. Anything that can't be had — the host unreachable or not registered, the node offline, the blob not in the host's store, the host too old to serve it, a failed size or hash check — is a 404 with {"unavailable": "<reason>"}; nothing is stored for a failed pull, and the next view tries again. How each transport delivers the blob is in the per-transport summary below.

A late or partial stream never fails the unit. If no stream arrived (the host predates coverage streaming, or it was lost in transport), it could not be collected or merged, or it may be incomplete (read while the unit may still have been running, or an SSH host's final copy did not arrive), the step gets a warning — on the run graph, in the event feed, in the CLI live view and in the completion summary — and the unit's synced workspace delta keeps its own copy of the coverage files as a fallback. Only a complete stream that merged every line replaces it.

Netflow

A placed unit's netflow ledgers, and those of every sub-agent and child beneath it, are teed on the host into one stream in the run's directory (runs/<id>/netflow.jsonl), which starts with a begin line even when the run made no network calls. When the unit ends the coordinator collects the stream and lands it: split per ledger id into its own ~/.rupu/netflow/<id>.jsonl, byte for byte, with a landing record (~/.rupu/runs/<id>/netflow.landing.json) that says what arrived — complete, partial with the reason, or unavailable for a host too old to stream or a stream that failed or was lost. A complete record is never downgraded. A run placed on a host whole lands the same way at its end.

TransportHow the netflow stream arrives
HTTPThe coordinator fetches GET /api/runs/:id/netflow/stream from a peer whose features list run.netflow_stream; a read after a poll error or at the wall timeout is partial.
SSHPulled with usage.jsonl and coverage.jsonl in one ssh round trip once the run is terminal on the host; a copy pulled before that is partial.
TunnelThe node sends it with the run's other mirrored files, but only to a control plane that advertises mirror.netflow.
BucketThe worker uploads it before it writes the run's finished marker.
LocalNothing to collect: the unit's ledgers are already in the coordinator's own netflow directories.

Nothing lands while the unit is still running. Once landed, the ledgers are read at run, project, global and agentiflow scope like any other, and a copy that is partial, unavailable or not collected at all is listed as incomplete at every scope, naming the host and the reason; the workflow run also gets a step warning. A short or missing copy is never shown as whole.

Skills on placed units

A placed unit does not need its skills installed on the host. The coordinator resolves them the way a local run does — the skills: / preloadSkills: of its own copy of the agent plus the step's skills: / preload_skills: (* means the coordinator's global layer) — and ships them. A step skill not installed on the coordinator fails the unit before dispatch, as it would a local run. If the coordinator has no copy of the agent, or its file does not load, only the step's skills ship, with a warning naming the file.

Each skill is packed as a tar named by its content hash; the coordinator asks the host which hashes it lacks and sends only those. The host checks every tar before keeping it (no absolute or .. paths, no symlink that leaves the skill, only files, directories and symlinks, and the unpacked tree must hash to its name) and keeps it in <RUPU_HOME>/cache/skills/<hash>/<name>/, which is never evicted. A shipped skill replaces the host's installed skill of the same layer and name, so the coordinator's version wins; a grant nobody shipped still resolves from what the host has installed, and fails closed if it has nothing. The unit's skill_index marks shipped skills (shipped), and rupu run --continue on the host runs with the same shipped skills.

TransportHow skills ship
HTTPThe peer serves POST /api/skills/bundle/missing and PUT /api/skills/bundle/:hash.
SSHTwo commands per host — rupu __skills missing and rupu __skills receive — not one per skill.
TunnelThe coordinator sends SkillsOffer (layer, name, hash per skill); the node answers SkillsNeed with what its cache lacks, receives each archive as SkillChunks, verifies and stores them, and replies SkillsStored. At most 4 live offers per connection, 256 refs per offer; an offer idle for 120 seconds fails, and the first problem in an offer fails that offer and stores nothing, without closing the connection.
BucketEach missing skill is put as skills/<hash>.tar; the worker downloads and verifies what its cache lacks before it starts the job. A missing or corrupt object fails the job, naming layer:name; a manifest of more than 256 skills fails it too; a transient bucket error leaves the job pending for the next tick.
LocalShips nothing.
Limits and older hosts. A skill tree may be at most 32 MiB and one unit's bundle at most 128 MiB; the coordinator refuses a unit over either limit before dispatch, naming the skill and the limit. Within one coordinator process, a host that confirmed a hash is not asked again, and the units of a fan-out bound for one host share one transfer. A host that does not advertise agent.skill_bundle is sent the unit by name, as before, and the run gets a warning saying the unit will use the host's installed copies: over SSH and HTTP that downgrade is remembered for the life of the coordinator process, while a tunnel node or bucket worker is re-checked on every unit, so an upgraded one starts receiving skills without a restart. Every coordinator-side skills failure for a placed unit starts with host <id>:. Only the unit's own skills ship: children it launches on the host, a rupu coverage rerun, and an agent whose grants the coordinator did not ship still resolve from the host's installed layers — and a skill's scripts still need their own tools (python3, say) on the host.

Messaging on placed units

Placed units are not in the coordinator's message space: each runs as a standalone rupu run on its host, with a space of its own there and the default messaging tools. A workflow's messaging: key still reaches them — false is sent as rupu run --no-messaging, and true or { tools: [...] } as one rupu run --messaging-tool per tool, so the unit gets the same list on its host. A placed unit of a workflow launched with messaging off has it off too. A host whose rupu predates the flags — it doesn't advertise agent.no_messaging or agent.messaging_tools — is refused the unit rather than run it with messaging on.

Operator messages to a remote run go through the same GET/POST /api/{runs,sessions,agentiflows}/:id/messages routes as a local one. HTTP and SSH hosts answer when their rupu advertises run.messages; tunnel and bucket hosts answer 501.

Per-transport summary

What each transport carries for a placed unit, from the sections above. A dash means the transport has no path for it today.

HTTPSSHTunnelBucket
Coverage + findings stream GET /api/runs/:id/coverage (feature run.coverage_stream) Tailed live; fetched complete in the terminal pull Mirrored over the tunnel with the run's other files Numbered coverage.NNNN.jsonl results, read in order
Artifact pull (first view) GET /api/findings/artifacts/:sha256 (findings.artifact_blob) One SSH call per pull running rupu __findings artifact Streamed over the tunnel (findings.artifact_pull; node must be online) Read from artifacts/<sha256>, uploaded by the worker before it marks the run finished
Netflow landing GET /api/runs/:id/netflow/stream (run.netflow_stream) Terminal ledger pull Sent to a CP advertising mirror.netflow Uploaded before the finished marker
Skills shipped /api/skills/bundle/* (agent.skill_bundle) rupu __skills missing / receive (agent.skill_bundle) SkillsOffer frames (agent.skill_bundle) skills/<hash>.tar (agent.skill_bundle)
Messaging switch --no-messaging / --messaging-tool on every transport; a host without agent.no_messaging / agent.messaging_tools is refused the unit
Operator messages With run.messages With run.messages — (501) — (501)
Resume a paused run The host's own resume worker workflow resume --if-unfinished (workflow.resume_if_unfinished) — —

Feature advertisement

Hosts in a fleet don't all run the same rupu. Rather than guess from version numbers, each host advertises the features it supports, and the coordinator only asks for what's advertised: HTTP hosts list them in /api/host/info, tunnel nodes send them in their hello, bucket workers write a marker file in the bucket, and an SSH host answers the hidden rupu __features (an older one prints nothing, which reads as no features).

FeatureLets the coordinator…
agent.findings_profileLaunch a unit with a step's findings_profile. A host without it refuses the launch rather than run the unit under a different profile.
agent.engagement_profileLaunch a unit under the run's engagement profile. An HTTP, tunnel or bucket host without it refuses the launch; an SSH remote too old for the flag rejects it, so the launch fails either way.
agent.skillsLaunch a unit, or a run from the launcher, with --skill / --preload-skill entries. A host without it is refused the launch.
agent.skill_bundleShip the unit's skills to the host. A host without it gets the unit by name and runs it on its installed copies, with a warning on the run (a downgrade, not a refusal).
session.skillsStart a session there with skills. An older host is refused before anything is sent.
agent.no_messaging / agent.messaging_toolsLaunch a unit with its workflow's messaging: false or messaging: { tools }. A host without the flag is refused the unit rather than run it with messaging on.
run.messagesRead and send operator messages for a run on an HTTP or SSH host. Tunnel and bucket hosts answer 501.
run.coverage_streamRead a remote unit's coverage stream (HTTP).
run.netflow_streamFetch a remote unit's netflow stream from GET /api/runs/:id/netflow/stream (HTTP). Without it the landing is recorded unavailable.
mirror.netflowThe reverse direction: a tunnel node sends its netflow stream only to a control plane whose welcome advertises this.
findings.artifact_blob / findings.artifact_pullPull a finding's artifact from the host (HTTP / tunnel).
workflow.resume_if_unfinishedResume a run with the guard that refuses a run that finished since the request.
run.listTell a failing run listing on a current host (an error worth showing) from an old host that can't list runs at all.

The rule is never to drop a request silently: a feature the peer didn't advertise is refused with an explanation, or the request falls back to the older form and says so — in the log, or as a warning on the run. Over SSH the launch command itself checks the remote's rupu __features for what the launch needs, so a remote that can't honour it prints a marker instead of launching.

Choosing a transport

All five transports satisfy the same port, so pick by how the host can be reached on the network — not by what features you want:

Still coming

All five transports above are shipped today, as are the three placement controls in Distribute work across the fleet (fan-out units, per-step host:, and workspace: sync). The architecture was built around one transport port (HostConnector) so the remaining work slots in without disturbing the surfaces above. These are genuinely not done yet:

Coming laterWhat it adds
Auto-placement Capability-based scheduling — picking the host for a run automatically from its advertised backends and capabilities.
Resume on tunnel and bucket hosts A resume path for paused runs on the two transports that don't have one today.
Operator messages on tunnel and bucket hosts Reading and sending messages to a run there; both answer 501 today.
Remote netflow, live A placed unit's flows land when it ends; a running unit's show only through the transcript merge.
Remote sessions over the tunnel Interactive, long-lived sessions dispatched to a tunnel node (a persistent session worker + reconnect-stable mapping). Today's tunnel covers workflow / agent runs and approve / reject; sessions are deferred.
mTLS hardening Mutual-TLS node enrollment (cert CN = node id) on top of today's token-hash auth — the frame envelope already carries an auth block so it slots in with no protocol change.