# wf cloud — cloud worker lane on promo credits — design Status: owner decisions 2026-10-06 (design session). Tool code goes to `/projects/public/workflow` (public tool repo); this spec was originally developed locally, then a copy `docs/cloud-lane.md` in the tool repo becomes the live one (same as resource-ledger). ## 1. Goal 1. Use a promo cloud credit for worker tasks, so the weekly subscription limit goes to design/orchestration/local-only work. 2. No GitHub, no hosting: code goes up as a `claude --cloud` bundle upload from a local folder, results come back through the session transcript. Push stays local-only. 3. A local ledger keeps cloud spend under a cap; the orchestrator routes fitting work to the cloud only while the ledger is positive; otherwise everything runs locally as today. 4. Cloud work never touches TASKS.md / archive: bookkeeping stays local (`wf` is not in the VM). ## 2. Owner decisions (2026-10-06) | # | Decision | |---|----------| | C1 | No GitHub (no mirror, no GitHub App). Bundle upload only. | | C2 | Cloud ledger with budget set by owner (balance minus margin against overrun). | | C3 | The orchestrator may put fitting work in the cloud on its own while the ledger balance is positive; at ≤ 0 it runs locally, no question asked. | | C4 | Opus in the cloud is fine (most work is opus). | | C5 | Purpose-built snapshot repos (code + the resource files a task needs) are the unit sent up; no hosted git. | ## 3. Facts (measured 2026-10-06, CLI 2.1.290) | # | Fact | |---|------| | F1 | `claude --cloud ""` in a folder with no git remote uploads a bundle (history + tracked files; untracked and `.env`-like files left out; ≤ 100 MB, else branch-only, else squashed snapshot). It prints `Created cloud session: …`, `View: https://claude.ai/code/session_…`, `Resume with: claude --teleport session_…` and **exits** — scriptable. | | F2 | `--cloud` needs a TTY (`-p` refused; piped stdout refused). A pty (tmux, python `pty`) works. | | F3 | A never-seen folder shows the trust dialog first (blocks; "Yes" = Down, Enter). Pre-accepting it (`~/.claude.json` `projects[].hasTrustDialogAccepted = true`, atomic rewrite) works — verified 2026-10-06, no dialog. | | F4 | Cloud sessions run **Opus 5.5 at effort medium** by default (commit trailer, status line). | | F5 | The VM can't reach local networks behind a firewall → it can't push to local git. Custom allowlist = claude.ai environment settings (owner action). | | F6 | Large projects with native build tools may require environment setup; e.g., .NET SDK not in the base image → download from package repos (minutes, cached with environment setup script). | | F7 | `claude --teleport ` for a bundle session: validates, fetches logs, then **fails silently at "Checking out branch"** (branch only in the VM) — sometimes exits, sometimes still resumes the conversation. It never applies the branch. Teleporting a *running* session does not interrupt it (local copy shows "Interrupted"; cloud kept working). | | F8 | After a teleport resumes, `/export ` writes the rendered conversation (80-col wrapped; tool output truncated `… +N lines`; **assistant text complete**) with **no model call**. The local `.jsonl` transcript only appears after a local message (= a full-context model call, avoid). | | F9 | So the result channel is the session's **final assistant text**: a gzip+base64 patch between markers with a sha256, whitespace-stripped on read. A patch inside tool output is truncated and useless. | | F10 | `claude -p "" --cloud ` queues a follow-up into a running/idle session and exits (no TTY needed). | | F12 | The export contains the prompt too: marker parsing must take only assistant lines (`● WF-RESULT …` = bullet prefix) after the prompt, never a bare substring match. | | F13 | Environment configuration (owner, 2026-10-06): custom network allowlist for required services; environment variables for telemetry and timeouts; setup script for large dependencies. The exact setup depends on project needs and should be configured in claude.ai environment settings. | | F11 | Cloud sessions share the subscription rate limits only after the promo credit is used up; until then they draw the credit (promo terms). No CLI shows the balance: owner reads it on claude.ai. | Baseline local cost (`wf usage --report`, API-price $, 2026-10-06): opus worker ≈ $1.10–1.32 per done task (median $0.58–0.96, n=64), sonnet ≈ $0.14–0.30, orchestrator ≈ $0.25 per dispatched task. Transcript output counts are estimates. ## 4. Design ### 4.1 Unit of work: snapshot repo `wf cloud send ` builds `/out/cloud//` (git-ignored, deleted by `pull` after apply): - `git archive ` of the project → plain files; plus every path in workflow.toml `cloud_include` (git-ignored inputs, e.g. large data files), force-added; one commit "base ". - Packed size > 90 MB → refuse (`wf: snapshot MB > 90 MB; trim cloud_include`). - Never in the snapshot: `TASKS.md`/archive/`.wf`/`.worktrees`/`out/` except `cloud_include`. - Pre-accept the trust dialog for that path (F3), then run `claude --cloud ""` under a python pty with a timeout (5 min); parse `session_…` from the output. No session id → exit 1 with the last 5 output lines. ### 4.2 Prompt (template `templates/cloud-prompt.md`, filled by `send`) Fixed text (≈ 25 lines) + `wf show ` body + the project CLAUDE.md test recipe of the areas the task names (`wf ctx` areas section). Rules in the template: - `wf` is absent: ignore wf/TASKS/lane/gate rules in CLAUDE.md; never edit TASKS.md/archive. - Never ask; blocked → final text `WF-AWAITING: ` and stop. - Test red → implement → green; commit on `main` (snapshot repo) with one-line messages. - Network blocked for a needed source → say so in the report line, continue with what the repo has. - **Final message text** (not a tool call), exactly: ``` WF-RESULT done|awaiting|handback WF-REPORT <≤2-line archive entry> WF-USAGE in= cw= cr= out= model= (summed from its own transcript, script given in the template) WF-PATCH-BEGIN sha256= bytes= ..HEAD --stdout | gzip -9`> WF-PATCH-END ``` - Built (cloud send task): the template writes each marker line in brackets (`[WF-RESULT R]`) so no prompt line starts with a marker; `cloud.final_message(export)` = the F12 parser: lines of the last message starting `● WF-RESULT` (assistant bullet; the prompt renders as `❯ `/` `, never `● `), bullet/indent stripped, ends at the next non-indented line. `pull` parses fields from that list only. Snapshot record `.wf/cloud/.json`: id, sid, project, lane, master (sha for `git am`), base (snapshot commit = patch base), folder, sent, model, bytes. Ledger row reserved before `claude --cloud` (sid `pending:…`), removed on failure. ### 4.3 Return: `wf cloud pull | --all` - Teleport under a pty in the snapshot folder, `/export `, `/exit` (F7, F8); no model call. - Parse the export: last line starting `● WF-RESULT` (F12); none → `running` (exit 4, prints age since send). None, but the last assistant message (no non-empty prompt after it) ends in a bare `WF-PATCH-END` line (key words dropped) → handback `bad result: no WF-RESULT key`, patch kept when a `sha256=… bytes=…` header decodes. - `done`: join base64 between markers (strip whitespace and the 2-space indent), check sha256 and bytes, gunzip → patch. Apply in the task's lane worktree on branch `/` from the recorded base: `git am --3way`; files outside the project's tracked tree or in `cloud_include` → refuse. Then `quick_gate`; green → `wf done -m ""` + commit + `wf merge` (the `wf finish` path, run by `pull` itself, so a pull costs the orchestrator one Bash call). Red / am conflict → handback (below). - `awaiting` → `wf add -s awaiting ""` + `wf status blocked `. - `handback` / red gate / bad patch → `wf note "cloud : "`, `wf status clear`, task goes back to the local queue with `Recovery: cloud attempt — ` in its note (a local worker may take the patch from `out/cloud/.patch`, kept on handback). - Every pull that ends the session charges the ledger (4.4) and deletes the snapshot folder. - Archive (owner ruling 2026-10-06, built as part of cloud infrastructure): each pull that ends a session then archives it in the app (`POST https://api.anthropic.com/v1/code/sessions//archive`, the CLI's internal archiveRemoteSession, CLI 2.1.291; 200/409 ok). Failure → one `archive failed: …` line, pull exit unchanged; ledger row `archived: true`; `wf cloud archive |--ended` retries. - Built (cloud pull task): sections of the final message open at a key line, other lines continue it (export wraps); the patch header + base64 are joined with all whitespace removed (`sha256=HEXbytes=N` then base64: gzip base64 starts `H4sI`, never a digit). Refused paths: outside the tree / project folder, TASKS, archive, `.wf`, `.worktrees`, `out`, `cloud_include`. One handback note `Recovery: cloud attempt — [; patch …]`. Lost = no result (or no export) and > 24 h since send. Branch `/` already there / worktree_setup red → exit 1, session kept (pull again). `--export FILE` parses a given export (tests, manual). Exit 0 ended, 4 running. ### 4.4 Ledger: `~/.local/state/wf/cloud.json` (same state dir and lock style as `wf res`) ``` {"budget": 240.0, "spent": 0.0, "reserve_per_task": 4.0, "max_parallel": 3, "entries": [{"id", "project", "sid", "model", "sent", "state": "running|done|awaiting|handback|lost", "usd": null|float, "usd_source": "self|est|owner"}]} ``` - balance = budget − spent − reserve_per_task × running. `send` refuses (exit 3, one line) when balance < reserve_per_task or running ≥ max_parallel. - Charge on end: `WF-USAGE` × `usage.PRICES` (reuse `wflib/usage.py`), plus a 15 % overhead factor for what the session can't see (title generation, setup); missing WF-USAGE → charge reserve_per_task, `usd_source=est`. - `wf cloud ledger` prints budget/spent/balance/running; `--set-balance ` sets spent = budget − balance (owner reconcile, `usd_source=owner` row); `--budget N` changes the cap. - A running entry older than 24 h without `WF-RESULT` → `lost` (charged reserve), task back to local. ### 4.5 What fits the cloud (routing) Project opt-in: workflow.toml `cloud = true` (+ `cloud_include = [...]`, optional `cloud_note = "..."` appended to the prompt) only enables the lane; routing is per task (owner ruling 2026-10-06, amended: opus only). Whoever adds a task (design session, orchestrator, worker follow-ups) classifies it: `Cloud: yes|no` (`wf add --cloud` / `wf set --cloud`); yes = Done checkable from the repo alone (code, synthetic data, `cloud_include` data), no GUI/display, live game/server/capture, LAN, Wine or local installs, `wf`/`wf res` steps, owner; opus only, prefer larger (1h); unsure → no. Task fits when all hold: - project opted in; task runner-ready (Done + Model), not `Sessions: owner|solo`, no `Cloud: no` line; - effort ≤ `slice_above` (no slice jobs); Model opus (sonnet/haiku never, also with `Cloud: yes`: overhead ≈ their cost, §5); - `Cloud: yes` → fits (regex skipped); no Cloud line → body must not name `wf ` commands as steps (workflow-upgrade / bookkeeping tasks need `wf`), GUI, LAN hosts, `wf res`, or a live server — checked by a small regex list in `wflib/cloud.py`. `wf orch pick cloud` order (`cloud.pick_key`): `Cloud: yes` first, then prio, then effort desc (1h before <1h). A worker that finds a task unfit for the cloud adds `Cloud: no` (`wf set --cloud no`). ### 4.6 Orchestrator `/wf-orchestrate` and `wf batch` gain a virtual lane `cloud`: - `wf orch pick cloud` = first fitting task across the project's lanes, ledger allows → `wf cloud send`, claim `status progress cloud:`. Prints `stop lane cloud: ` otherwise. - The orchestrator calls `wf cloud pull --all` on each wake-up (no faster than every 10 min; a teleport takes ~30 s and no tokens). Results feed `wf orch post` like a worker report. - Local lanes skip tasks claimed `cloud:`; the cloud lane takes opus tasks only (the expensive ones locally), `Cloud: yes` first (§4.5). - Ledger ≤ reserve → the cloud lane stops; the local lanes run as today (C3). ## 5. Economics (measured, see §6) Value of local work = its API-price $ (what the weekly limit is spent on). Per opus task: | Item | $ | Source | |---|---|---| | Local opus worker avoided | 1.20 | Historical API-price per task (range 1.10–1.32, n=64) | | Local overhead of a cloud task | 0.10 | send + pull = 2 orchestrator calls at ~150k cached ctx (~0.05 each); poll wakes amortised ~0.03; gate = CPU only | | Failure (handback → local redo) | 0.24 | assumed p_fail 20 % × 1.20 | | **Local $ saved** | **0.86** | | | Cloud credit used | 1.40 | typical replay scenario with setup overhead | Net per opus task = 0.86 − v × 1.40: | Credit value v | Net per task | $240 credit → tasks / net | |---|---|---| | 0 % (use-or-lose, time-limited) | **+0.86** | ~150 tasks / **+$129 local** | | 50 % | +0.16 | ~150 / +$24 | | 100 % (as paid usage) | −0.54 | loss: only worth it when the local limit is hit | The analysis shows that using cloud credits for opus-only tasks is economical only when the credit value is low (time-limited or not yet consumed). Sonnet tasks: local $0.14–0.30 vs overhead + failure ≈ $0.15 → ≈ 0 net even at v = 0 → **cloud lane takes opus only** (sonnet only via `Cloud: yes`). Build cost: 6 slices worth of engineering. ## 6. Validation Cloud worker send/pull was validated with: - Manual pty driver tests against the real CLI on throwaway repos (no project content). - Round-trip end-to-end tests from send → export → pull with real snapshot repos. - Synthetic test fixtures with the same message shape as real cloud sessions (for unit tests in the tool repo). Key findings: - Marker parsing must handle export word wrapping (spaces, indents). - ANSI codes in the TUI output must be stripped before marker matching. - Trust dialog pre-acceptance prevents interactive blocks. - Export `/exit` may take multiple Enter presses to ensure the file is written.