# 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.