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
- Use a promo cloud credit for worker tasks, so the weekly subscription limit goes to design/orchestration/local-only work.
- No GitHub, no hosting: code goes up as a
claude --cloudbundle upload from a local folder, results come back through the session transcript. Push stays local-only. - 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.
- Cloud work never touches TASKS.md / archive: bookkeeping stays local (
wfis 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. |
<a id="facts"></a>
3. Facts (measured 2026-10-06, CLI 2.1.290)
| # | Fact |
|---|---|
| F1 | claude --cloud "<prompt>" 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[<path>].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 <sid> 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 <file> 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 "<msg>" --cloud <sid> 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
<a id="snapshot"></a>
4.1 Unit of work: snapshot repo
wf cloud send <id> builds <project>/out/cloud/<id>/ (git-ignored, deleted by pull after apply):
- git archive <master HEAD> 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 <sha>".
- Packed size > 90 MB → refuse (wf: snapshot <n> 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 "<prompt>" 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.
<a id="prompt"></a>
4.2 Prompt (template templates/cloud-prompt.md, filled by send)
Fixed text (≈ 25 lines) + wf show <id> 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: <one-line question> 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=<n> cw=<n> cr=<n> out=<n> model=<id> (summed from its own transcript, script given in the template)
WF-PATCH-BEGIN sha256=<hex of the gzip> bytes=<n>
<base64 of `git format-patch --binary <base>..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/<id>.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.
<a id="pull"></a>
4.3 Return: wf cloud pull <id> | --all
- Teleport under a pty in the snapshot folder,
/export <tmp>,/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 bareWF-PATCH-ENDline (key words dropped) → handbackbad result: no WF-RESULT key, patch kept when asha256=… 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<lane>/<id>from the recorded base:git am --3way; files outside the project's tracked tree or incloud_include→ refuse. Thenquick_gate; green →wf done <id> -m "<WF-REPORT>"+ commit +wf merge(thewf finishpath, run bypullitself, so a pull costs the orchestrator one Bash call). Red / am conflict → handback (below).awaiting→wf add -s awaiting "<q>"+wf status <id> blocked <a-id>.handback/ red gate / bad patch →wf note <id> "cloud <sid>: <why>",wf status <id> clear, task goes back to the local queue withRecovery: cloud attempt <sid> — <why>in its note (a local worker may take the patch fromout/cloud/<id>.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/<sid>/archive, the CLI's internal archiveRemoteSession, CLI 2.1.291; 200/409 ok). Failure → onearchive <sid> failed: …line, pull exit unchanged; ledger rowarchived: true;wf cloud archive <sid>|--endedretries. - 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=Nthen base64: gzip base64 startsH4sI, never a digit). Refused paths: outside the tree / project folder, TASKS, archive,.wf,.worktrees,out,cloud_include. One handback noteRecovery: cloud attempt <sid> — <why>[; patch …]. Lost = no result (or no export) and > 24 h since send. Branch<lane>/<id>already there / worktree_setup red → exit 1, session kept (pull again).--export FILEparses a given export (tests, manual). Exit 0 ended, 4 running.
<a id="ledger"></a>
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.
sendrefuses (exit 3, one line) when balance < reserve_per_task or running ≥ max_parallel. - Charge on end:
WF-USAGE×usage.PRICES(reusewflib/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 ledgerprints budget/spent/balance/running;--set-balance <usd from claude.ai>sets spent = budget − balance (owner reconcile,usd_source=ownerrow);--budget Nchanges the cap.- A running entry older than 24 h without
WF-RESULT→lost(charged reserve), task back to local.
<a id="fit"></a>
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 <id> --cloud no).
<a id="orch"></a>
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:<sid>. Prints stop lane cloud: <ledger|none fit|max parallel> 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.