Skill v1.0.1
currentAutomated scan100/100+3 new
name: workflow description: Orchestrate a MULTI-PHASE, dependent, or resumable run over many provider subagents from a JS script, off the main context (cc-fleet workflow). Use for fan-out→barrier→synthesis, per-item pipelines, loop-until-dry, or a run that must survive a kill and --resume from its journal. NOT a flat fan-out of independent tasks (that is /cc-fleet:subagent — cheaper, no script); NOT interactive collaboration (that is /cc-fleet:team); NOT trivial single-shot work for the main session. metadata: version: "1.0.1"
workflow — multi-phase JS orchestration over provider subagents
Wrong lane? A flat one-shot fan-out of independent tasks → /cc-fleet:subagent; interactive collaboration you message back and forth → /cc-fleet:team; arbitration in cc-fleet-shared/routing.md.
When this skill cites cc-fleet-shared/<file>.md, OPEN it with the Read tool at ../cc-fleet-shared/<file>.md relative to this SKILL.md — the cited content is load-bearing, not optional background.
Execution environment — check before running anything. Confirm your shell tool executes on the host where cc-fleet is installed. In sandboxed or remote agent sessions, a tool named Bash may run on an isolated machine with a different filesystem, PATH, processes, and tmux server —command not found, a healthy-lookingdoctorwhose leaves can't reach your files, or a wrong working directory should prompt you to verify whether you are in a sandbox shell, not conclude that cc-fleet is broken. If so, route commands through a host-executing bridge tool (for example, desktop-commander) and pass host paths for any files you reference; do not retry the same Bash call expecting different results. If no host-executing tool is available, stop and explain that cc-fleet must run on its installation host.
A workflow is a JavaScript script that fans out provider cc-fleet subagent leaves and runs in a cc-fleet process, OFF the main session's context. You write the script; cc-fleet workflow run executes it. The orchestration plan lives in script variables (CPU, ~0 of your tokens) — you are invoked only when authoring the script, not on every scheduling decision. The API mirrors the native Claude Code Workflow tool — write the script exactly as you would a native workflow; the only addition is the provider option on agent().
When to use it
- Multi-phase or dynamic orchestration over many provider subagents: fan-out + barrier, per-item pipeline, loop-until-dry, branch-on-result, with a board run-tree.
- A single flat batch of independent one-shots is not a workflow — that's /cc-fleet:subagent. Don't write a script for it.
The provider ask ladder (ask at most once per task)
- The user named a provider or model → use it.
- Else run
cc-fleet default --json: if it returns a provider (source "configured" or "auto"), use it and STATE it in your kickoff line (e.g. "using glm (default)"). - Else (several providers, none default) ask the user ONCE which to use — list the enabled providers from
cc-fleet list --json(name + default_model + the one-line note in cc-fleet-shared/providers.md). After they pick, runcc-fleet default <chosen>so you never ask again. (cc-fleet default <p>is user-layer; only run it to FILL a blank default, never with --force.) - A mid-task provider failure (insufficient balance / rate limit / auth) → STOP, tell the user what happened, propose the next provider, and WAIT for their confirmation. Never switch providers silently.
STOP freezes the affected provider operation and any unapproved provider switch, not independent authorized work. The error table's single rate-limit retry remains allowed; do not retry uncertain writes before establishing their prior result. Main-session handling is allowed only where the workflow already permits it and the user has not required that provider. Changing a provider or the saved default still requires the corresponding explicit authorization.
Model tier within a provider: fan-out / leaf work → omit --model (or --model fast); judge / synthesis / sustained work → --model strong. The provider's roster decides the actual model — see cc-fleet-shared/providers.md.
In a script, agent()'s opts.provider is optional: omitted, the leaf uses the run's default provider, resolved ONCE at launch and recorded with the run — so --resume stays stable even if the default changes later. A script meant to be shared or reproducible should still pin provider explicitly.
The script API (mirrors the native Workflow tool)
const meta = {name, description, whenToUse?, model?, phases?: [{title, detail?}]}— a top-level pure literal (no calls/vars/spreads; the nativeexport const metaform is also accepted).name+descriptionare required;modelis the default for agents that omit it. Read statically before the run → the board shows the named, phase-skeletoned run immediately.agent(prompt, opts) → Promise<string|object>— runs ONE provider subagent leaf.opts.provideris optional (omitted → the run's default provider, above);provider: "claude"runs the officialclaudeCLI on the user's OWN Claude Code login (subscription OAuth) instead of a configured provider — a literalmodelid (fable/opus/sonnet/a full id, omitted → claude's login default, typically the costliest tier so name one), no roster keywords, no key material. The rest are optional:model,schema,label,phase,timeout(seconds),max_budget_usd,max_turns,isolation: "worktree",profile("slim" default / "slim-ro" / "full"),tools,skills,mcp. An unknown option key throws (typos fail loudly). On a leaf failure the promise rejects — an un-caught top-levelawait agent()aborts the run; insideparallel/pipelinea failed element degrades tonull. Leaf failures classify like subagent failures — dispatch table in "Leaf failures" below.- `schema` (a plain object) goes to the claude child via
--json-schema: claude injects a forcedStructuredOutputtool and enforces that it is CALLED (the native mechanism — no JSON instruction is added to the prompt); the promise resolves with the parsed structured payload. The three rules: - a validation failure — or a result envelope without a structured payload — FAILS the leaf; there is NO automatic retry;
- the forced
StructuredOutputcall costs turns — give a schema'd leafmax_turns≥ 3 (a budget of 1 starves it); - needs claude ≥ 2.1.88 (the slim-profile floor); an older claude fails the leaf with a classified usage error.
Client-side validation backstops with a recursive JSON-Schema subset: type (object/array/string/number/integer/boolean/null; integer accepts 5.0), required, nested properties, array items, scalar enum, string pattern (RE2 best-effort — the wire enforces the authoritative ECMA regex) / format (email/uri/uuid/date/date-time), additionalProperties, allOf/anyOf/oneOf, and intra-document $ref (#/… pointers; an external URI is unsupported and fails).
- `isolation: "worktree"` runs the leaf with cwd = a fresh git worktree, so parallel file-editing leaves don't collide (requires a git repo). When the leaf ends, a clean worktree is removed; one with changes or new commits is first snapshotted to the branch
cc-fleet/wf-<job>-a<n>in your repo (list them withgit branch --list 'cc-fleet/wf-*'; the engine log —workflow watch— has akeptline naming it), then removed. If the snapshot fails, the directory stays where it is with a.cc-fleet-keepmarker — save the work, then delete the directory yourself. Ifworkflow watchshowsUNPROTECTEDfor it, the marker could not be written and nothing shields that directory from cleanup: copy it out by hand before anyworkflow restart,rmorprune, or a new run in this repo. The worktree starts atHEAD, so uncommitted changes in your checkout are not visible to the leaf, and its edits reach your checkout only when you merge or cherry-pick thecc-fleet/wf-*branches; cc-fleet never deletes them (git branch -Donce merged). A whole-runworkflow stopor an engine crash can cut a leaf off before its snapshot finishes (astop --leafhold saves normally): the next--resume/restartorworkflow rm/prunerescues that work tocc-fleet/wf-salvage-<run>-<8 chars>without logging it, and a later run's startup sweep in the same repo rescues it with anisolation worktree salvagedline in that run's events. A resume or restart also clears the oldkeptlines — after a stop, find the work withgit branch --list 'cc-fleet/wf-*', notworkflow watch. - `profile`:
"slim"(the default; write-capable) /"slim-ro"(read-only research) /"full"(ONLY to compare against a full session or diagnose a suspected slim regression). Writes files →slim, read-only →slim-ro.tools,skills(defaulttrue) andmcprefine a slim leaf, are rejected withprofile: "full", andtoolsREPLACES the whole set, never appends. Tool whitelists / per-profilemcpdefaults / the pre-2.1.88 fail-open downgrade: cc-fleet-shared/providers.md. The run journal folds the effective profile + tools, so a--resumere-runs a leaf whose shape changed. - Background = an unawaited promise. There is no
run_in_background/wait(): start a leaf withconst p = agent(...), keep working,await plater (Promise.allfor a batch). Every leaf — awaited or not — is pool-bounded, journaled at completion, and the run only finalizes after all of them settle. A leaf that rejects with nobody ever handling it fails the run (a silently dropped failure is still a failure); fire-and-forget tolerance is an explicitp.catch(() => null). parallel(thunks) → Promise<array>— run each 0-arg thunk concurrently; BARRIER (settles once all finish),nullwhere an element failed:await parallel([() => agent("a", {provider: "glm"}), () => agent("b", {provider: "glm"})]). Concurrent execs stay ~pool size even for a huge list (excess queues).pipeline(items, ...stages) → Promise<array>— push each item through all stages independently with NO inter-stage barrier (item A can be in stage 3 while B is in stage 1). Each stage is(prev, item, index) => …(sync or async; its return value is awaited). A failing stage drops that item tonulland skips its remaining stages. DEFAULT to `pipeline` over `parallel` — only useparallelwhen a stage genuinely needs ALL prior results together.workflow(path, args?) → Promise— run another.jsinline on the same engine (shared pool/journal/budget), one level deep only; resolves with the child's top-levelreturnvalue. A relativepathresolves against the run's directory (whereworkflow runwas launched, kept for--resume/restart), not the calling script's directory — use an absolute path when the scripts live elsewhere. The child script is read and checked only whenworkflow()reaches it, not at launch.budget— two parallel cap surfaces. USD:budget.total(the--budget-usdcap in USD, ornull),budget.spent(),budget.remaining()(Infinitywhen uncapped) — USD floats (an Anthropic list-price estimate). Tokens:budget.tokens_total(the--budget-tokenscap, ornull),budget.tokens_spent(),budget.tokens_remaining()— ints (input+output, cache-read excluded).agent()throws when the next leaf does not fit: each call reserves $1.00 (or its largermax_budget_usd) and 50,000 tokens as soon as it is made — before it waits for a slot — until the leaf is charged its real cost, and is refused when spent + held reservations + its own would pass either cap (a leaf replayed from the journal on--resumereserves nothing). A refused call never waits (a lower--max-concurrencydoes not help): a cap under $1.00 / 50,000 tokens runs no leaf, a fan-out of N leaves needs about N × $1.00 of room (the rest come backnullfromparallel(), each refusal logged inworkflow watch), and awhile (budget.remaining() > N)loop scales depth to the cap only with N ≥ one reservation (remaining()does not subtract reservations). (Native'sbudget.totalis a token target; here it is USD —--budget-usdis the cross-provider cap since providers price tokens differently — and tokens are the separatetokens_*surface.) Aprovider: "claude"leaf spends the lead session's own subscription window, not a metered provider — use it for one or two synthesis / judgement nodes, never a wide fan-out. Its usage still flows into the run's token / USD surfaces, but the USD is claude's notional list-price (a subscription is not metered per token);max_budget_usd/--budget-usdstill gate against that notional figure.phase(title, detail?)— name the current phase (tags subsequent agents lacking an explicitphase; the detail shows on the board row).log(msg)— a narrator line (board live log + stderr);console.log/info/warn/error/debugalias onto it (non-strings render as JSON, Errors by message).args— the parsed--args-json '<json>'value (or theworkflow(child, args)value);undefinedwhen none was given.
What a workflow script can NOT use (determinism — the journal depends on it)
Date/Math.random()throw;eval/Function/ dynamic code are removed; there is nosetTimeout/require/fs/ ESMimport— pass timestamps or randomness in viaargs.- Plain script statements only (the body runs inside an async wrapper, so top-level
awaitandreturnwork); async generators (async function*) are not supported.
Running it
cc-fleet workflow run audit.js # detached; prints ONLY the bare run id ("$RUN" below)cc-fleet workflow status "$RUN" --json # manifest + every tagged leaf (run→phase→agent)cc-fleet workflow list --json # all runs, newest firstcc-fleet workflow stop "$RUN" # reap a running run (engine + in-flight leaves)cc-fleet workflow stop "$RUN" --leaf <job|label> # hold ONE agent in place (run keeps going); --phase <title> holds a phasecc-fleet workflow restart "$RUN" --leaf <job|label> # re-run a held/running agent in place; --phase <title> a phase;# on a FINISHED run: keyed re-run (whole run, --leaf, or --phase)cc-fleet workflow wait "$RUN" --timeout 3m --json # block silently until the run settles ("Waiting on a run" below)# or watch the board's Dynamic Workflows view: live log, token/cost columns, prompt/answer drill-in.# x/r there are level-scoped: run row = the run, Phases pane = the phase, agent pane = the leaf# (a held agent shows ▶ until you restart it). --foreground runs inline (debug).# `held` in status output = parked by the control plane: an operator paused it (board# x, stop --leaf/--phase) or a restart was refused (budget gate); a restart in flight# may show it briefly. Not an error/retry/backoff — the run waits on it indefinitely.# If held persists across polls, resume it with restart --leaf/--phase or tell the# user it is parked; never wait it out.# --max-concurrency N overrides the default pool (min(16, cores-2));# --budget-usd N caps total spend (each leaf holds max($1, its max_budget_usd) of it from its# agent() call, queued or running, until it finishes);# --no-persist-io disables the prompt/answer drill-in.
The run is detached so it outlives this call and your session stays responsive. A non-zero exit from workflow run means no run started and no id was printed; stderr says why — e.g. a script or meta error, or engine failed to start: <cause> when the detached engine died before registering. Do not arm wait then (wait exits 2 on an empty or unknown id).
Waiting on a run: arm wait in a backgrounded Bash (push, not poll)
Right after launching, arm the notifier — a backgrounded Bash whose EXIT is your wake-up. Put the printed run id into it literally: a separate Bash call does not inherit $RUN:
cc-fleet workflow run audit.js# a separate Bash call with run_in_background=true; the harness wakes you when it exits:cc-fleet workflow wait <run-id> --timeout 3m --json
End your turn and keep working — never spawn an agent (or loop yourself) to poll a run. On the wake, dispatch on the envelope's wait_outcome (+ exit code); exit 2 has none (ok:false) — an IO error or an empty or unknown run id: check the id against workflow list --json, don't re-arm blindly:
- `terminal` (exit 0 done/stopped · 1 failed) — fetch the detail with
workflow status "$RUN" --json(it carriesrun_errorand the per-leaf list; the wait envelope deliberately doesn't) and report. - `engine_gone` (1) — the engine died without finalizing; propose
cc-fleet workflow run <script> --resume "$RUN"(the journal replays the finished leaves). - `parked` (3) — every remaining leaf is held. FIRST re-check
workflow status: leaves running/queued again means it was a transient (the engine was between leaves) — re-arm silently. Still parked → name the envelope'sheldleaves to the user and proposerestart --leaf; never wait it out. - `timeout` (124) — a heartbeat, not a verdict. Compare
counts/spent_*with the previous snapshot: progress → one short progress line and re-arm with a longer window; zero delta → inspect (workflow status; is one long leaf still inside its owntimeout?) and escalate only on a real anomaly, else re-arm.
Window sizing: make the FIRST window short (2–3m — a provider auth/balance failure surfaces on the first leaf call), then 10–15m per re-arm. One wait per run; they are independent. After a session restart, re-arm every running run from workflow list --json.
For a human live view: cc-fleet workflow watch "$RUN" streams the run's events as text (in a terminal, or a backgrounded shell → the /tasks panel) and cc-fleet watch streams the whole fleet; the board's Dynamic Workflows view has the rich drill-in. Both print only canonical status — never a provider reply.
Leaf failures — dispatch on error_code (do not parse prose)
A failed leaf's error_code is in workflow status --json (jobs[]) and in the rejection that reaches the script. Same vocabulary as a one-shot subagent (the full table with context lives in /cc-fleet:subagent); the dispatch:
error_code | What you do | |
|---|---|---|
INSUFFICIENT_BALANCE / KEY_INVALID / RATE_LIMITED | STOP — provider ask ladder, step 4 (never switch silently). KEY_INVALID → the user rotates the key (a codex provider has no key: the user runs cc-fleet codex login --credential <its secret_ref>; protocol codex-oauth and secret_ref in cc-fleet list --json); RATE_LIMITED → brief wait, one retry. | |
NO_DEFAULT_PROVIDER / DEFAULT_PROVIDER_DISABLED / DEFAULT_PROVIDER_UNKNOWN / DEFAULT_PROVIDER_RESERVED | No usable default for a provider-less agent() (RESERVED = default_provider hand-set to claude, explicit-only — the user unsets/re-pins) — apply the provider ask ladder, then re-run. | |
MODEL_NOT_FOUND | Give the failing leaf a slot as its model (default / strong / fast; see cc-fleet models <provider> --json) — also when it inherited the id from meta.model. Change meta.model only when every leaf inheriting it was rejected: it re-keys all of them, so --resume re-runs the finished ones. Then cc-fleet workflow run <script> --resume "$RUN" (restart re-runs the saved, unedited script). If the user named that model, tell them it was rejected and confirm the substitute before retrying. If a configured slot itself is rejected, the user fixes it with cc-fleet edit <provider> --default-model/--strong-model/--fast-model <id> (or the TUI); cc-fleet refresh changes nothing a leaf uses. | |
SUBAGENT_TIMEOUT | Raise the leaf's timeout or split the task; a leaf with no timeout defaults to 300s. | |
SUBAGENT_OUTPUT_TOO_LARGE | The leaf's output exceeded the byte cap — have it write to a file and answer concisely; a blind retry overflows again. | |
SUBAGENT_STOPPED | An operator stopped it (stop --leaf / run stop) — terminal, NOT a failure; never auto-retry. | |
SUBAGENT_MAX_TURNS | A leaf hit the --max-turns cap — raise its max_turns in the script (a research / multi-file leaf needs ~1 turn per file read or command: give it 30–50, or omit the cap), then cc-fleet workflow run <script> --resume "$RUN"; restart --leaf re-runs the saved script with the old cap. | |
SUBAGENT_FAILED / PROVIDER_API_ERROR | Inspect (workflow status); restart --leaf once, or propose a provider switch (ask first). A schema leaf with missing or invalid structured output fails here too, and a restart re-runs the same spec: fix its schema, prompt or max_turns, then cc-fleet workflow run <script> --resume "$RUN". A provider: "claude" leaf on a logged-out machine fails here (the error preview names the login problem, no dedicated code) — tell the user to log in to Claude Code interactively. A codex / openai-* leaf with leaf did not complete and no leaf launch line in cc-fleet workflow watch "$RUN" --timeout 5s never started: its proxy (or its isolation: "worktree" worktree) could not be set up, and only the script's rejection names the cause (run_error when uncaught; parallel / pipeline swallow it) — for codex proxy unavailable: … follow the CODEX_PROXY_UNAVAILABLE row instead of restarting. | |
PROVIDER_UNREACHABLE | claude reported a transport failure with no HTTP status (connection refused or reset, DNS, TLS, timed out) — the provider is down, or a codex / openai-* loopback proxy died mid-leaf. cc-fleet doctor, then restart --leaf once; if it persists, tell the user and propose a provider switch (ask first). | |
FINGERPRINT_STALE | The claude binary was not found (the code keeps its old name) — the user installs/fixes Claude Code or PATH; cc-fleet doctor confirms. | |
CODEX_PROXY_UNAVAILABLE / CODEX_CLOUDFLARE_BLOCKED | The loopback proxy could not start: cc-fleet codex-proxy status, then the user frees the port in the provider's base_url or fixes the cause the error names — a missing codex login does not cause this (it fails on the request as KEY_INVALID). A Cloudflare block → switch network, don't rotate credentials. | |
UNKNOWN_PROVIDER / PROVIDER_DISABLED / CONFIG_LOAD_FAILED | Config problem — cc-fleet list --json, cc-fleet add / edit --enable; CONFIG_LOAD_FAILED → cc-fleet doctor. | |
PROVIDER_RESERVED | A providers.toml row is named claude (reserved for the native leaf) — the user renames or removes it. | |
SUBAGENT_BAD_ARGS | Bad leaf options — fix the script, re-run. |
Resume (content-hash journal)
Each run records a content-hash journal of its completed leaves. Re-run the same script under an existing run id to replay:
cc-fleet workflow run audit.js --resume "$RUN" # journaled leaves return cached (no provider exec); only un-run leaves run
A leaf is keyed by its determinant (provider + model + prompt + schema + isolation + slim shape), so an unchanged re-run is ~100% cache hits, a leaf whose prompt you edited (and anything downstream of its output) re-runs, and a run that was killed resumes by replaying what finished before the kill. The determinism lockdown makes this exact: with no clock/PRNG, the same script+args produce the same keys. A failed leaf is never journaled, so resume re-runs it.
--resume and restart run the remaining leaves in the directory the run was first launched from (recorded with the run), whatever your cwd; only the script path you pass to --resume is resolved from your cwd. If that directory is gone, they fail with run directory <dir> no longer exists — tell the user to restore it or start a fresh run from the project directory. --resume checks the script before touching the run (parse, compile, meta): a broken one is refused (exit 1, the error on stderr) and the run and its saved script — the one restart runs — stay as they were; fix the script and re-issue the same command.
Non-goals (state plainly, don't oversell)
- No pause. A running
claude -pcan't be cleanly suspended; useworkflow stop(reaps the run) +run --resume(cheap restart via the journal) instead. - Client-side `schema` validation is a JSON-Schema subset — the list above, not the full spec (an external
$refURI is unsupported and fails; an unknownformatis an annotation, not enforced). claude enforces thatStructuredOutputis called; this backstop checks what it was filled with, and a failure is terminal (no retry). - Key-safety is unchanged: the provider key flows only via
apiKeyHelper; prompts go to the leaf via stdin, never argv; the journal/events/board carry no key.
Worked example — research sweep (fan-out → pipeline → loop)
const meta = {name: "api audit",description: "map endpoints, draft checks, then probe for gaps",phases: [{title: "map"}, {title: "build"}, {title: "probe"}],};phase("map");const maps = (await parallel(args.map((m) => () => agent("List exported endpoints in module " + m,{provider: "deepseek", label: "map:" + m})))).filter(Boolean); // e.g. --args-json '["auth","billing","users"]'phase("build");// pipeline (no barrier): each map flows straight into its own checklist draftconst checklists = await pipeline(maps,(prev, item, i) => agent("Draft an audit checklist for these endpoints:\n" + prev,{provider: "glm", label: "build:" + i}),);phase("probe");const gaps = [];while (gaps.length < 10) { // loop-until-dry (the runtime hard-caps 1000 leaves/run)const g = await agent("Given these checklists, name ONE uncovered risk, or reply NONE:\n"+ checklists.join("\n"), {provider: "kimi"});if (g.trim() === "NONE") break;gaps.push(g);}// one final synthesis node on your OWN subscription — a single judgement leaf, not a fan-outconst verdict = await agent("Rank these gaps by severity and name the top three:\n"+ gaps.join("\n"), {provider: "claude", model: "opus", label: "verdict"});log(`done: ${maps.length} maps, ${checklists.length} checklists, ${gaps.length} gaps`);return { maps, checklists, gaps, verdict };
One run, three phases, a barriered fan-out, a no-barrier pipeline, a bounded loop-until-dry, and a single claude synthesis node — all sequenced by the script in a cc-fleet process, off your context. The script's top-level return value is NOT persisted or retrievable — to read the run's output, fetch a labeled leaf's answer with cc-fleet workflow result "$RUN" --label verdict --json.
Anti-patterns
- A script for a single flat independent batch → /cc-fleet:subagent.
- A long-lived collaborator you message back and forth → /cc-fleet:team.
Date.now()/setTimeout— unavailable (determinism); pass timestamps viaargs.- Trusting
schemaas deep validation, or treating a plainagent()result as JSON withoutschema. - Unbounded ambition: the runtime hard-caps 1000
agent()calls/run, pools concurrency atmin(16, cores-2), and caps a singleparallel/pipelinelist at 100,000 elements. - Switching providers silently after a balance / rate-limit / auth failure → stop, tell the user, wait for their pick (provider ask ladder, step 4).