Get started
Runners plan
Status
Proposal, revision 1. Implementation in progress (autonomous build started 2026-08-08; this section tracks live status — update it in every PR that advances a milestone).
| # | Milestone | Status | PRs |
|---|---|---|---|
| 0 | This plan | in review | — |
| 1a | Naming: session copy revert | not started | — |
| 1b | Naming: devices consolidation | not started | — |
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
| 2 | openclaw resume + web Continue in terminal |
not started | — |
| 3 | oc-pair:// one-paste pairing |
not started | — |
| 4 | Picker + enrichment + projects read model | not started | — |
| 5 | Device runners | not started | — |
| 6 | Stop-and-continue moves | not started | — |
| 7 | Deletions (ssh sandbox, openshell, exec-host clones) | not started | — |
Proposal history: direction agreed 2026-08-08 after a
code-evidence investigation (three deep-reads of the worker, exec, and node
stacks), an industry survey (Amp runners/orbs, Cursor 3 location picker,
Claude Code teleport, Codex cloud, VS Code tunnels, Tailscale auth keys), and
three adversarial reviews whose kill-verdicts are folded in below as explicit
non-goals. Builds directly on the shipped cloud-workers architecture
(docs/plan/cloud-workers.md, docs/gateway/cloud-workers.md); it does not
replace it.
Problem
OpenClaw has three disconnected answers to "where does work run":
- Nodes receive forwarded
exec host=nodecalls only; the turn loop never leaves the gateway. A user's always-on Mac Studio is less capable as a session host than a throwaway AWS lease. - Cloud workers host full sessions, with a durable placement state machine, but only against ephemeral provider leases.
- The ssh sandbox backend is a third remote-execution path (gateway-held SSH credentials, per-tool remoting) that duplicates the shape cloud workers superseded.
The UI mirrors the fragmentation: placement is chosen once in the new-session
popover from a flat list mixing three ontologies (gateway, exec nodes, cloud
profiles), then becomes invisible and immutable. Placement config is spread
across tools.exec.*, agents.entries.*.tools.exec.node,
agents.defaults.sandbox.*, gateway.nodes.*, and cloudWorkers.profiles.
Vocabulary drifted: the Control UI says "thread" (July 2026 copy rename, PRs
110933/110973) while the CLI, protocol, stores, and docs say "session";
paired hardware is "nodes" in routes/i18n and "devices" in paths/labels.
Model and vocabulary
Session gateway-owned: transcript, identity, placement, managed worktree. Clients (web, TUI, macOS app, channels) attach to sessions, never to runners. One noun, everywhere: session.Runner anything that can host a session's turn loop: - the gateway itself (the runner you get for free) - a paired device (via a node-backed worker provider; see below) - a cloud box (existing crabbox worker provider)Isolation a property OF the runner, not a place: cloud box -> the machine is the boundary gateway/device -> none | docker | podman (existing sandbox)Device paired hardware (today's "nodes"). Devices contribute capabilities (camera, canvas, exec) as peripherals; a device becomes a runner only through the worker admission path.Project repo identity: normalized remote.origin.url, with the existing 16-char repo fingerprint as the no-remote fallback. Derived, never registered.Checkout project × runner = { runnerId, path } — where a project physically exists. Cloud runners have none; they materialize a fresh checkout per session.Folder the non-git escape hatch: a plain path on one runner (today's browse flow, unchanged).Turn one prompt-to-response work attempt inside a session (matches ACP and the worker protocol).Naming rulings (operator-decided 2026-08-08):
- session is the only product noun for a conversation. The Control-UI "thread" copy is reverted (i18n + test literals; technical identifiers never changed). Industry: 9–2 for session among agent products; ACP says session; "thread" collides with Discord/Slack/Telegram sub-thread transport concepts.
- devices is the user-facing word for paired hardware; "nodes" remains
protocol/internal vocabulary only. The route/i18n debt (
nodesroute id,/settings/devicespath,nodes.*i18n keys) consolidates on devices. - New CLI ergonomics ship as verbs (
openclaw resume), never a second noun command next toopenclaw sessions. - "runner" is an internal/docs concept; UI copy says "Runs on …".
VISION.md gains one paragraph: the gateway is the coordinator and the default runner; every other machine — yours or leased — can be a runner; clients attach to sessions, so where a session runs never changes how you talk to it.
What the adversarial reviews killed (now non-goals)
- No Places registry.
environments.list(src/gateway/server-methods/environments.ts:143-157) already returns the merged read model: gateway entry, node catalog (paired + live presence), worker environments, cloud profiles. A persisted registry would duplicate presence facts; a renamed RPC is a second path. We enrichEnvironmentSummaryadditively instead. - No turn loops on the node role. The node protocol was already rejected
as a loop transport (cloud-workers.md §4): a connected node can emit
arbitrary node events, so its capability ceiling is not an ingress boundary.
Worker ingress stays a closed three-method allowlist
(
packages/gateway-protocol/src/schema/worker-admission.ts:32-34) with minted per-dispatch credentials and exact bundle-hash admission (src/gateway/worker-environments/admission.ts:80-104). Devices become runners only by runningopenclaw workerunder that admission. - No dispatch into a live checkout. Workspace sync requires exclusive
ownership of the remote dir (wiped every sync,
workspace-sync-setup-script.ts:29); reconcile treats divergence from the base manifest as worker output. Device runners use the same private per-session dir under$HOME/.openclaw-worker/that the qa-lab static-ssh provider proves today. - No folding of
exec host=node. Per-call exec routing is ~5k LOC of four-layer fail-closed approval machinery (gateway TOCTOU re-checks, node policy floor,systemRunPlanhash binding revalidated on the node, node-local re-evaluation). It serves a different product (one command in a different policy domain) and stays untouched. - No sandbox-as-a-place row. Sandbox is per-agent isolation config with no per-session override surface; a picker row would silently do nothing for unconfigured agents.
- No fake mobility verbs.
sessions.dispatchacceptslocal|reclaimedplacements and cloud profiles only (sessions-dispatch.ts:166-176); there is no pause and no machine-to-machine move. The UI shows only what the backend does: display + reclaim now; move-as-stop-and-continue after device runners ship. - No exec pre-approval in pairing links. The one-paste flow may
pre-approve presence-only scopes;
system.runand folder sync always pass the existing pending-approval or SSH-verify gate (src/gateway/node-pairing-ssh-verify.ts). - No live migration, no multi-gateway federation, no phones as runners.
Components
1. Session continuation ergonomics (independent, ships first)
Already true by construction: the transcript and placement live on the
gateway, inference originates from the gateway in every placement, and the
TUI is a full gateway client (openclaw tui --session <key>, Ctrl+P picker,
last-session resume — src/tui/tui-last-session.ts). Start a session on the
web running in the cloud; the TUI attaches and turns route to the worker.
Delta is ergonomics only:
openclaw resume [query]— fuzzy-match recent sessions across agents by name/key; no query opens a picker; resolves totui --session <key>.- Web UI "Continue in terminal" on session rows: shows the exact command
(
openclaw resume <key>), mirroring the terminal-resume affordance the Codex/Claude session catalogs already have. - No new protocol surface;
sessions.listalready carries what the resolver needs.
2. One-paste device pairing (independent)
Reuse the shipped setup-code flow: PairingSetupPayload = { url, urls?, bootstrapToken } base64url blob (src/pairing/setup-code.ts:40-44,406-410),
10-minute single-use bootstrap token, bootstrapProfile: "node"
(src/shared/device-bootstrap-profile.ts:61-94), minting RPC
device.pair.setupCode (src/gateway/server-methods/device-pair-setup.ts).
Gaps to close:
oc-pair://<setupCode>scheme wrapper (payload unchanged).openclaw node run --pair <code|url>redeem path: decode blob, configure host/port/token, connect (today only--host/--port/--tls-fingerprintflags exist,src/node-host/runner.ts:27-37).- Add the TLS fingerprint to
PairingSetupPayload(node host already accepts a pin; the blob cannot carry it). - Expose the
nodebootstrap profile in the Control UI pairing dialog (RPC-only today,ui/src/lib/device-pair-setup.ts). - Tailscale-style key split, stated in docs: the pairing token is short-lived and one-shot; the resulting device credential is long-lived; revoking one never revokes the other.
Exec/scope escalation is unchanged: first system.run request lands in
pending approval or auto-approves via SSH-verify.
3. Device runners (the core)
A device runner is the existing worker stack pointed at a persistent machine. Evidence that the stack is ready:
- Provider contract is tiny and SSH-generic
(
src/plugins/capability-provider.types.ts:97-114):provision → {leaseId, ssh},inspect,destroy. The qa-lab static-ssh provider (extensions/qa-lab/src/static-ssh-worker-provider.ts:70-91) already wraps a persistent host with a no-op destroy, and sync/reconcile work unmodified because the remote workspace is a private per-session mirror. - Admission, placement state machine, SQLite stores, transcript CAS,
inference proxy, and the
openclaw workerruntime need essentially no changes; admission is credential-based, not transport-based. - The seam is
WorkerTunnelHandle(src/gateway/worker-environments/tunnel-contract.ts:74, 85 lines): workspace command execution + sync + quiesce behind one handle, currently SSH-only (worker-turn-launcher.ts:337-344,workspace-sync-scripts.ts).
Work items:
deviceworker provider:provisionmaps a profile to an existing paired, connected device;destroyreleases the logical lease. Config:cloudWorkers.profiles.<id> = { provider: "device", settings: { device: "<id-or-name>" } }(bikeshed: rename the config block torunners.profileswith a doctor migration — decide at review).- Tunnel variant: either (a) SSH to the device like any worker (device
runs sshd; simplest, reuses everything), or (b) a
WorkerTunnelHandleimplementation that multiplexes workspace commands and the worker socket over the device's existing gateway connection. Ship (a) first; (b) is an optimization decided by review. - Pinned runtime with consent: the gateway pushes its content-hashed
bundle (existing bootstrap,
bootstrap.ts:26-104) into$HOME/.openclaw-worker/on the device. Installing a runtime on a personal machine requires a one-time per-device operator approval, surfaced in the pairing/approval UI. Exact-version admission stays; version skew is solved by reinstalling the bundle, never by relaxing the check. - Offline/drain semantics (the one genuinely new subsystem): personal
machines sleep and cannot be destroyed. New placement handling for
runner-offline: heartbeat loss marks the placement with a recorded, operator-visible reason (Product Doctrine: no silent non-outcome); staged results are preserved (existing fence machinery); the session offers "continue on gateway" (reclaim) or "wait for device". Reuse the wake-nudge subsystem (src/gateway/node-wake-state.ts) where the device has a wake channel. - Isolation on device runners: optional worker-in-docker on the device, same sandbox axis as gateway-local sessions. Cloud runners keep full-permission-within-the-box (the machine is the boundary).
3b. Projects (derived read model)
OpenClaw already computes project identity twice without naming it: the
worktree service derives originUrl + a 16-char repo fingerprint
(src/agents/worktrees/service.ts:199-205), and the sessions catalog groups
Codex/Claude rows by project folder, folding .claude/worktrees/<name> into
its origin repo. This component promotes that to a first-class read model —
derived, never registered, same pattern as environments.list:
projects.listread model (computed on demand, no new store): group known checkouts by repo fingerprint →{ name, originUrl, checkouts: [{runnerId, path}], lastUsedAt }. Sources: session rows (execCwd/execNode), the managed-worktree registry, and device-advertised workdirs (below). "GitHub-ness" is just the originUrl host shown as a subtitle; no forge integration required to model it.- Device checkout advertisement: the gateway cannot group cross-runner
checkouts today because it never learns a device checkout's origin. Device
runner enablement (component 3) adds
{path, originUrl}pairs to the device handshake — the Amp host+workdir idea landing in the right seam. Small, additive, and only sent for paths the operator enabled. - Picker flow: project first (chip ⌃J), then the Where chip narrows to
"where does this project exist" — checkout paths as row subtitles; runners
without a checkout are listed honestly ("no checkout · clones from origin
on first session"); cloud is always eligible (fresh clone). Recents group
by project instead of deduping raw
(folder, node)pairs (ui/src/pages/new-session/recent-places.ts). "No project" keeps the existing per-runner folder browser as the escape hatch. - Forge integration is a later, separable phase: repo lists from GitHub, clone-a-repo-you've-never-touched, PR status on session rows. The derived model needs none of it; registration-style project creation (the cloud-only-product pattern) is explicitly rejected — projects appear because you worked on them.
4. UI convergence
Design rule (operator-decided, 2026-08-08): normal state is silent; only exceptions speak. No online dots, no persistent/disposable/peripheral labels, no status pills — being listed in the picker already means usable, and the operator knows what their own devices are. Status text appears only for exceptions ("offline · 2h", the runner-offline banner) or facts the operator cannot infer (provisioning time, "runs in docker"). Capability chips stay: they are structured facts, not status. Placement on a running session is quiet text ("on aws"), not a badged widget — the activity spinner already carries liveness.
- Enrich
EnvironmentSummaryadditively (protocol, no migration):trust: "persistent" | "disposable",sessionHost: boolean,platform, and for profiles a provider-suppliedclasslabel. No pricing fields until a provider actually supplies prices. - Where picker regrouped (
ui/src/pages/new-session/place-picker.ts): sections "This gateway" / "Your devices" (session-capable, connected devices only — phones and offline devices stay hidden by gating) / "Cloud". Folder and destination stay orthogonal. Copy: "Runs on {place}". - Placement chip on the session header: shows current placement and state; menu offers exactly reclaim ("Bring home") for cloud placements today, plus stop-and-continue moves once device runners ship. Reuses the placement subscription the sidebar badges already consume.
- Devices page: fold live-sessions-per-device into the existing
ui/src/pages/nodes/surface (renamed to devices end-to-end). No new top-level nav item; the picker's "Connect a device…" foot links here. - Naming wave (one PR, early, before new copy lands): revert thread → session in Control UI copy; consolidate nodes → devices in route id, i18n keys, and labels. Route aliases per the UI's existing alias mechanism.
5. Deletions and dedup (each gated on its replacement)
| Target | Size | Gate |
|---|---|---|
ssh sandbox backend + remote-fs bridge (src/agents/sandbox/ssh*.ts) |
~2.35k LOC | device runners cover the "tools on my server" case |
openshell overlap (extensions/openshell) |
~3.4k LOC | verify real usage first; same SSH-transport shape |
exec-host structural clones (bash-tools.exec-host-gateway.ts vs exec-host-node*.ts: allowlist eval, auto-review, timeout-fallback, follow-up delivery; node host re-clones the analysis a third time) |
~3k of ~5k | extract one shared approval state machine; node plan-binding stays |
node-pairing.ts facade over device-pairing.ts + migration shims |
medium | finish the merge; one vocabulary |
UI placement watchers (cloud-recovery-state.ts + sessions-page reconcile loops) |
medium | one placement-watching controller |
Net production LOC across the whole plan is targeted negative: components 1–2 are small additions; component 3 is mostly a provider plugin + one tunnel variant against reused machinery; component 5 deletes more than 3 adds.
Prior art (what we copy, what we skip)
- Amp agents-anywhere: runners as first-class picker entries; identity =
host + workdir with optional pinned name → we key on device id and
advertise workdirs. Amp leaves offline-runner behavior undocumented; our
runner-offlinerecorded state is the deliberate improvement. - Tailscale auth keys: one-shot short-lived pairing key vs long-lived device credential, separate revocation → copied in component 2.
- Claude Code teleport: continuation re-materializes state because their cloud session lives elsewhere; OpenClaw's gateway-owned sessions make continuation attach-only — simpler, no state movement. Their fork-not-move semantics inform our stop-and-continue framing.
- Cursor 3 location picker: Local/Worktree/Cloud/SSH in one dropdown validates the single-picker UX; their live cloud-handoff shipped buggy — we do not attempt live moves.
- devcontainer.json: if/when repo-owned environment setup lands for worker profiles, adopt the spec rather than inventing a format (Cursor's proprietary environment.json accrued debt; Gitpod migrated to the spec).
Milestones
Independently mergeable PR series, roughly in order; 1–3 can interleave.
- Naming wave: session copy revert + devices consolidation (UI/i18n/tests only; no protocol or CLI changes).
- Continuation ergonomics:
openclaw resume, web "Continue in terminal". - Pairing:
oc-pair://,node run --pair, TLS pin in payload, node profile in the pairing UI. - Picker + enrichment: additive
EnvironmentSummaryfields, regrouped Where picker, placement chip (display + reclaim),projects.listread model + project-first picker flow (gateway-side checkouts only until 5 adds device advertisement). - Device runners: device worker provider (SSH transport first), pinned
bundle install with per-device consent, checkout advertisement
(
{path, originUrl}in the enablement handshake),runner-offlineplacement semantics with recorded reasons, optional worker-in-docker isolation. Fault-injection tests (device sleep mid-turn, gateway restart with offline device, credential expiry) gate exit — same bar cloud workers set. - Stop-and-continue moves (chip verb "Move to…"): drain + reclaim + re-dispatch to another runner, reusing the migration barrier.
- Deletions: ssh sandbox backend, openshell overlap, exec-host clone extraction, node/device pairing merge — each in its own PR with proof the replacement covers it.
Open questions
- Config naming: keep
cloudWorkers.profiles(compat) or migrate torunners.profilesvia doctor in milestone 5? - Device-runner transport (a) sshd vs (b) multiplexed gateway connection: ship (a) first; is (b) worth the protocol surface at all?
- Should
openclaw resumealso start the gateway/TUI in local mode when no gateway is reachable, or fail with guidance? - Repo-owned setup contract (devcontainer.json) for worker profiles: this plan or a follow-up?
- Forge integration (GitHub repo lists, clone-anywhere, PR status on session rows): explicitly out of this plan; follow-up once the derived project model has usage.
- Project naming collision:
openclaw fleetand multi-tenant docs use "project" loosely in places — sweep during the naming wave to keep "project" exclusively for repo identity.