cyberine
UsageRun recipes

Fleet policy file — cyberine fleet (T422, T425, T426)

fleet.json is an optional provider policy file that declares which workers are active and which model handles each task kind. It externalizes the routing that is otherwise hardcoded across worker profiles and the router. Two locations are supported; the project file takes full precedence over the global one:

  • project: .cyberine/fleet.json (cwd)
  • global: ~/.cyberine/fleet.json

Generate fleet.json from built-in profiles

cyberine fleet init              # generate fleet.json from WORKER_PROFILES + key availability
cyberine fleet init --force      # overwrite an existing fleet.json
cyberine fleet init --merge      # add only missing providers (never touch existing entries)
cyberine fleet init --seats-only            # every built-in provider disabled, one claude-<seat> entry per declared seat
cyberine fleet init --seats-only --force    # overwrite an existing fleet.json with the same

init reads which API keys are present (isConfigured()), marks workers as enabled accordingly, sets default: true for the curated cheap build fleet, and pre-fills routing to reproduce today's default behavior (deepseek for qa/code-small, kimi/deepseek reasoning for code-large).

--seats-only skips all of that: it reads the GLOBAL seats map ($CYBERINE_HOME/fleet.json, kgjhf below) and refuses — naming that path and how to declare a seat — when none is declared yet. Otherwise it writes the same full built-in provider list init writes today, but every one of them enabled: false, default: false, plus one claude-<seat> entry per declared seat (kind: "cli-agent", workerId: "claude", enabled: true, default: true, seat: "<name>", models copied from the generated claude entry). It writes to whichever file plain init would (project .cyberine/fleet.json when that directory exists, else the global file) and refuses an existing file without --force, same as plain init. Prints the path written and the enabled claude-<seat> entries. Pair it with cyberine init --yes and cyberine fleet doctor to turn a repo into a seats-only project in one go — see the cyberine-run skill's init verb below.

View fleet status

cyberine fleet list              # table: PROVIDER TRANSPORT KIND KEY ENABLED DEFAULT BILLING MODELS ROUTES + routing summary
cyberine fleet status            # alias for list

The TRANSPORT column says how the provider runs: cli spawns a headless CLI agent (kind: "cli-agent"), api calls a provider API from Cyberine's own tool loop (kind: "key-loop"). It is derived from kind, so it cannot disagree with it.

The KEY column shows yes (key present), login (CLI agent logged in), NO KEY (enabled but no key — doctor will report this), or - (not available).

The ENABLED column shows yes, no (turned off by hand), or auto-off (turned off by Cyberine itself after a billing or auth refusal — see "Auto-disable a worker that refused for billing or auth" below). An auto-off entry also gets a detail block under the table naming the refusal class, the evidence, how long ago it happened and the exact re-enable command.

Enable / disable providers

cyberine fleet enable <provider>    # set enabled: true, write + re-validate
cyberine fleet disable <provider>   # set enabled: false, write + re-validate

Provider names match the keys in fleet.json (e.g. deepseek, command-code, command-code-api). An old name from before kv9y5 (e.g. commandcode) is accepted too and resolves to its new name; see the next section.

Provider names say CLI or API (kv9y5)

A provider key in fleet.json names its transport, so a pin, a warning or a fleet list row reads the same without knowing a convention:

  • a CLI provider is named after the CLI product: claude, codex, muse, gemini, command-code;
  • an API provider whose vendor also has a CLI provider in the fleet ends in -api, with any qualifier after it: command-code-api, command-code-api-free, claude-api-gateway;
  • an API-only vendor with no CLI twin keeps its bare name for now: deepseek, kimi, mimo, xai, groq, openrouter, ...

Renamed keys (worker ids are unchanged, so run history, cooldowns, routing.steps pins and board manifests keep working):

Old keyNew keyWorker idCLI twin
commandcodecommand-code-apiloop-commandcode-buildcommand-code
commandcode-freecommand-code-api-freeloop-commandcode-free-buildcommand-code
muse-loopmuse-apiloop-muse-buildmuse
gemini-loopgemini-apiloop-gemini-buildgemini
claude-nativeclaude-api (direct Anthropic Messages API)loop-claude-native-buildclaude
claude-loopclaude-api-gateway (Claude through the LiteLLM gateway)loop-claude-buildclaude
openaiopenai-api (direct OpenAI API)loop-openai-buildcodex
gptopenai-api-gateway (OpenAI through the LiteLLM gateway)loop-gpt-buildcodex

Run recipe:

cyberine fleet init                        # writes only the new names (so do --seats-only and --merge)
cyberine fleet doctor                      # one WARN per old key, naming the exact new key
cyberine fleet enable commandcode          # accepted; writes "command-code-api" and drops "commandcode"
# enabled command-code-api
# "commandcode" is the old name of "command-code-api"; fleet.json now stores it as "command-code-api"

An old key keeps loading forever: the loader reads it as the new key (a "commandcode:<alias>" routing target too) and dispatch is unchanged. It is renamed only when the entry binds the worker id it was generated for, so a hand-named "gpt" bound to some other worker stays "gpt". Loading never writes the file. A cyberine fleet write (enable, disable, set-default, set-model, init --merge, seat add|remove) re-serializes the whole file, so it stores every old key under its new name, never both.

fleet doctor says CLI or API in its messages instead of key-loop, and two findings are new:

warn   provider "command-code-api" (API, worker loop-commandcode-build) is enabled but has no API key: none of CMD_API_KEY is set; its CLI twin "command-code" is installed and logged in (file api key @ ~/.commandcode/auth.json; enabled in this fleet.json), so this API provider is optional: add the key to run both, or `cyberine fleet disable command-code-api` to rely on the CLI alone
warn   routing.steps.implement pins worker "loop-commandcode-build" (provider "command-code-api", API) which has no API key: none of CMD_API_KEY is set — the implement step will skip it and dispatch its other pinned workers, or refuse to run if none of them is built

A provider bound to a retired worker now says it is retired instead of claiming a missing key.

Known limitations and deferred work (kv9y5):

  • API-only vendors keep their bare names (deepseek, kimi, mimo, minimax, qwen, xai, groq, mistral, together, fireworks, zhipu, perplexity, cerebras, nvidia, cohere, azure, copilot, ollama, ollama-cloud, openrouter, bedrock, vertex). Moving them to -api too is a proposed phase 2; qwen and minimax are the first candidates, since their vendors ship CLI workers under other product names (qwen-code, mmx-text-build).
  • cyberine auth keeps its own ids (commandcode, anthropic, openai, ...). They name a credential, and every cyberine auth entry is an API key, so they carry no transport ambiguity; cyberine auth login command-code-api is not an alias.
  • Fleet auto-disable (k2g29) keeps whatever key the file uses when it flips an entry, so its note may name an old key (cyberine fleet enable commandcode), which still works.
  • Judge backend labels in run notes (commandcode-free/<model>, commandcode/<model>) are labels, not fleet.json keys, and are unchanged.

Auto-disable a worker that refused for billing or auth (k2g29)

Cyberine cannot see whether a dispatch spends the customer's money, so enabled IS the consent: a worker that refuses because the account cannot pay, or cannot authenticate, is turned OFF in the fleet.json in effect and handed back to the operator with the command that turns it on again. Nothing re-enables it on a timer.

Which refusals disable, and which only cool down:

RefusalWhat happens
HTTP 402, payment required, insufficient balance, out of credits, no credits, upgrade your planenabled: false + autoDisabled.kind = "out-of-credits"
HTTP 401 / 403, unauthorized, forbidden, invalid api key, not logged in, an expired session or tokenthe error is reported AND enabled: false + autoDisabled.kind = "auth-failure"
HTTP 429, a rate limit, a usage / daily / weekly / monthly window that names a reset time, a retry-after headercooldown only, exactly as before — the entry stays enabled
Timeouts, connection failures, 5xx, and any failure with no billing or login evidencecooldown only (or nothing) — the entry stays enabled

The rule is applied to dispatch workers AND to judge backends, which share the same fleet.json entries. The classification reuses the cooldown classifier (src/store/workerCooldown.ts's classifyQuotaError / classifyCliUnavailability, the same one src/judges/fallbackCompletion.ts already switches judge backends on), so there is only ever one opinion about what a refusal means.

What a disable writes, and what it prints:

cyberine run "..."
# cyberine: fleet auto-disable: "deepseek" (worker loop-deepseek-build) is now enabled:false in
#   /repo/.cyberine/fleet.json after an out-of-credits refusal: 402 Insufficient Balance.
#   Nothing re-enables it automatically -- run: cyberine fleet enable deepseek

cyberine fleet list          # the entry reads auto-off, with the reason, the timestamp and the command
cyberine fleet enable deepseek
# enabled deepseek
# cleared the auto-disable reason recorded 2026-09-18T09:14:02.511Z (out-of-credits: 402 Insufficient Balance)

The entry gains one optional block, validated by src/core/fleetConfig.ts and cleared by cyberine fleet enable:

"deepseek": {
  "kind": "key-loop",
  "workerId": "loop-deepseek-build",
  "enabled": false,
  "default": true,
  "models": { "standard": { "id": "deepseek-chat" } },
  "autoDisabled": {
    "kind": "out-of-credits",
    "reason": "402 Insufficient Balance",
    "at": "2026-09-18T09:14:02.511Z",
    "by": "loop-deepseek-build",
    "role": "worker"
  }
}

kind is the machine-readable class (out-of-credits or auth-failure), reason the redacted single line of evidence taken from the refusal itself, at an ISO 8601 timestamp, by the adapter id that refused (a seat-bound id such as claude-kt names the exact seat), and role whether it refused as a worker or a judge. A manual cyberine fleet disable leaves the block absent, which is what tells a hand-off apart from an auto-off.

When the disable takes the LAST enabled entry, the run ends with a second note so the empty fleet is never a mystery:

fleet is empty: /repo/.cyberine/fleet.json has no enabled provider left, so no worker remains to
dispatch. Fix the account, then run: cyberine fleet enable deepseek

The file in effect is the one the loader already resolves — the project .cyberine/fleet.json when it exists, else the global ~/.cyberine/fleet.json. With NO fleet.json anywhere, nothing is written and the refusal only prints its note, naming cyberine fleet init as the way to get a file that can hold the decision.

Concurrency is safe by construction: several workers of one run, or two runs in different shells, can hit the wall in the same second, so the write takes an advisory lock beside the target (<fleet.json>.lock, O_EXCL, stale-broken when the recorded pid is gone), re-reads the file from disk inside the lock, and writes it back through the tmp-file + fsync + rename helper (src/store/atomicWrite.ts). An unrelated edit that landed while a writer waited is carried forward untouched.

Extend recipe — turn it off. CYBERINE_FLEET_AUTO_DISABLE=0 keeps today's cooldown-only behaviour: refusals still cool the worker down, nothing is ever written to fleet.json.

It is also off by itself inside any npm run selftest:* process (detected through npm's npm_lifecycle_event), because smokes drive real adapters into fake auth failures from the repo's own working directory and must never edit the operator's fleet.json; CYBERINE_FLEET_AUTO_DISABLE=1 forces it back on for a smoke that tests the feature against a temp fleet file.

CYBERINE_FLEET_AUTO_DISABLE=0 cyberine run "..."

Known limitations and deferred work (k2g29):

  • The note is a run NOTE (printed by every front-end, returned in notes by the SDK) plus one live cyberine: line on stderr. It is NOT a new field in run.json — Run has no top-level notes array, and adding one was out of scope.
  • Nothing ever re-enables an entry, by design. There is no probe that re-checks a topped-up balance and no expiry on autoDisabled; cyberine fleet enable <provider> is the only way back. The proactive DeepSeek balance probe (src/core/deepseekCreditsGate.ts) still writes a cooldown, not a disable.
  • A refusal that names no billing or login evidence — a CLI that just exits fast and silent — is left to the existing cli-unavailable cooldown rather than disabled. Deliberate: a bare fast exit is not proof that the account cannot pay.
  • Several fleet.json entries binding the same workerId with no seat are all disabled together, because they share one credential. Seat-bound entries (claude-<seat>) match exactly and cool only the seat that refused.
  • A malformed autoDisabled block WARNS and is dropped rather than failing the file, so a bad write can never leave the machine with an unusable fleet.json and no dispatchable worker. The entry's enabled value still stands.
  • The write targets the fleet.json the LOADER picks. A project file that borrows the global seats map keeps borrowing it: global-only blocks are stripped before the write, exactly as cyberine fleet enable already does.

Set default flag

cyberine fleet set-default <provider> on    # include in the no-arg default fleet
cyberine fleet set-default <provider> off   # remove from the default fleet

Route a task (dry run against fleet.json)

cyberine fleet route "explain the router"
cyberine fleet route "implement user authentication"

Classifies the task (qa / code-small / code-large) via the same classifyKind the router uses, then resolves fleet.json routing to show the worker + model that would be dispatched. Prints (unmapped -> heuristic router) when the kind has no explicit routing entry.

Edit in place

cyberine fleet edit    # open fleet.json in $EDITOR (fallback: vi); re-validates on exit

What a fleet.json gates (kc4jj)

REPLACE semantics apply to the WHOLE fleet, not just the router's candidate pool. With a fleet.json in effect, a provider that is absent or enabled: false is not used anywhere, even when its API key is present:

  • the always-on key-loop compare workers (loop-deepseek-direct, loop-mimo-direct, every other loop-*-direct, loop-ollama, and the LiteLLM gateway pair loop-gateway-a / loop-gateway-b),
  • the LLM-judge backends (DeepSeek-direct, OpenRouter, Moonshot) and the DiffJudge self-heal / swarm integration-fix agents that share their credentials,
  • the router's auto-candidate pool and the default-compare OpenCode worker, as before.

Each skipped worker emits one note naming the file in effect and the workerId whose entry would bring it back, e.g.:

loop-deepseek-direct has its credential but is not enabled in fleet.json (/path/.cyberine/fleet.json) - skipped.
fleet.json has REPLACE semantics: a provider absent or disabled there is not used, even with a valid key.
Enable the entry binding workerId "loop-deepseek-build" to bring it back.

Two knock-on behaviours, both noted at run start:

  • No LLM-judge backend enabled — the panel is ConsensusJudge alone (plus DiffJudge on isolate steps). No subscription-backed judge is substituted.
  • No non-subscription worker enabled — implement-task's design and review steps drop their cheap-fleet whitelist and run on every built worker, so a seats-only fleet plans and reviews with the seats it enabled instead of failing the step.

Verify a policy without spending anything: cyberine fleet list shows the enabled set, cyberine route "<task>" dry-runs the routing, and npm run selftest:seats-only-fleet asserts the gate against throwaway fixtures (synthetic keys, no provider reached).

build.fleet from .cyberine/config.json is a preference, not a pin (kvr1t)

.cyberine/config.json lands in the environment as CYBERINE_BUILD_FLEET, and until 2026-09-12 that made every run in the repo look like an explicit pin: cyberine init --seats-only wrote build.fleet: "claude,muse,codex" next to a fleet.json that enabled only claude-<seat> lanes, and a plain cyberine run (what /cyberine:run issued) ran three BARE workers on no seat at all. Now a build.fleet that came from a config.json layer is marked (CYBERINE_BUILD_FLEET_SOURCE=config.json) and resolved against the merged fleet.json at build time:

  • a bare worker whose fleet.json entry is enabled runs as before;
  • a bare worker bound only to seat lanes (claude with claude-kt / claude-ft enabled) expands through the global routing.seats policy — default, byKind, strategy: first, or round-robin with the persisted pointer — or to every lane when no policy narrows it;
  • a worker fleet.json disables or omits is skipped with a note: CYBERINE_BUILD_FLEET (from config.json): muse: named by config.json build.fleet but not enabled in <path> - skipped (...);
  • a claude-<seat> id named directly in config.json is kept when that entry is enabled, skipped with a note otherwise.

A shell CYBERINE_BUILD_FLEET, --fleet, --cheap, and the router's own pick clear the marker and keep the documented bypass below. npm run selftest:build-fleet-preference asserts the resolver and the marker on throwaway fleet files.

# seats-only repo, config.json build.fleet: claude
cyberine run "add a clamp helper"
#   NOTE: CYBERINE_BUILD_FLEET (from config.json): claude: config.json build.fleet names the bare worker; <path>/fleet.json binds it to seat lane(s) [claude-kt, claude-ft] - claude seat-policy: routing.seats.strategy=round-robin (...) selects claude-ft
#   NOTE: CYBERINE_BUILD_FLEET (from config.json): build.fleet [claude] resolved against fleet.json to [claude-ft]

Known limitations and deferred work. byKind needs the task kind, which the adapter build does not classify, so a config.json preference honours default and strategy but not byKind (an explicit --fleet claude-<seat> or the router path does). The marker is per process: a workflow launched through npm run wf with a shell CYBERINE_BUILD_FLEET is still an explicit pin.

Known limitations and deferred work. An explicit CYBERINE_BUILD_FLEET still bypasses fleet.json by design — that switch is the documented escape hatch, so a pinned id runs even when its provider entry is disabled. The gate reads the file on every build, so an edit mid-run affects only the next one. A fleet.json that enables nothing at all leaves an empty fleet: the run fails with "no adapters available" rather than silently falling back to key availability.

Declare and dispatch to a Claude subscription seat (kgjhf: k8wpr, k2fhq, kgn4s, kd2dt, krjev, kwr72, kmejq)

A seats map names the Claude subscription seats this machine can authenticate as, and a provider entry binds itself to one with seat. Seat names are yours to choose — Cyberine ships no built-in names — and may contain letters, digits and underscore only, because an adapter id splits into base and seat on the last dash.

1. Create a token file, in your own terminal — never inside a Claude Code session, because the printed token would land in the session transcript:

claude setup-token

Paste the long-lived token it prints into a file of your choosing and lock it down:

chmod 600 ~/.secrets/claude-team.token

2. Declare the seat and bind a provider entry to it. Lead with the command — it writes both pieces into the GLOBAL ~/.cyberine/fleet.json ($CYBERINE_HOME/fleet.json) in one step, never a project file:

cyberine fleet seat add personal --token-file ~/.secrets/claude-personal.token
cyberine fleet seat add team --token-file ~/.secrets/claude-team.token
cyberine fleet seat list      # name, token-file state, fingerprint, bound provider
cyberine fleet seat remove team   # drops the seat AND every provider entry bound to it

seat add refuses, each with a fix hint and without touching fleet.json: a name with anything but letters/digits/underscore (an adapter id splits base and seat on the last dash); a --token-file value that looks like a token rather than a path (sk-ant-..., or no path separator and longer than 64 characters); a path that does not exist; or a file that is not mode 600. On success it prints the token file's fingerprint (seat-<12 hex>, the same one raw.accountKey uses below) — never the token itself — and reuses the built-in claude worker profile's models, so the new claude-<name> provider entry is enabled and ready to route immediately.

Equivalent by hand, in case you are scripting a fleet.json directly instead:

{
  "version": 1,
  "seats": {
    "personal": { "tokenFile": "~/.secrets/claude-personal.token" },
    "team": { "tokenFile": "~/.secrets/claude-team.token" }
  },
  "providers": {
    "claude": { "kind": "cli-agent", "workerId": "claude", "enabled": true, "default": true, "seat": "personal", "models": { "standard": { "id": "sonnet" } } },
    "claude-team": { "kind": "cli-agent", "workerId": "claude", "enabled": true, "default": false, "seat": "team", "models": { "standard": { "id": "sonnet" } } }
  }
}

tokenFile is a PATH to the mode-600 file from step 1 — the token value itself never goes in fleet.json — and a leading ~ is expanded.

The seats map is GLOBAL-ONLY: it belongs in ~/.cyberine/fleet.json and is rejected by name in a project .cyberine/fleet.json (the error prints the offending file), so a cloned repo cannot introduce its own token paths. A provider's seat field, by contrast, may live in EITHER file — loadFleetConfig carries the global seats map onto a project-layer config, so a project .cyberine/fleet.json can bind a provider entry to a seat declared globally and have it resolve, validate and route exactly as it would from the global file. Declare seats once, globally; bind seat wherever the provider entry itself lives. Other rules the loader enforces in either file: seat is accepted only on workerId: "claude"; a seat name with a dash is rejected; a seat naming something the global seats map never declared warns and drops that entry from routing, never falling back silently to the unpinned worker; and (global scope only, where the map itself is parsed) a tokenFile that looks like a token value rather than a path (starts with sk-ant-, or has no path separator and is longer than 64 characters) is rejected without echoing the value back.

Omit seats and seat and nothing changes — a fleet.json without them behaves exactly as before, and the worker authenticates through the usual keychain login.

3. Check the setup:

cyberine fleet doctor

One WARN line per declared seat, naming its resolved token-file path, whether the file exists, and whether it is mode 600 — never its contents: seat "team" -> /Users/you/.secrets/claude-team.token (exists, mode 600), or (missing), or (exists, mode 644 — expected 600, run chmod 600 on it) when it is not locked down.

4. Dispatch to the seat. Two ways to reach it:

  • Explicit, one call: CYBERINE_BUILD_FLEET=claude-team npm run wf -- <def.ts> (or cyberine run --fleet claude-team "<task>"). The id is always <workerId>-<seat> — split against the GLOBAL fleet.json regardless of what the provider entry happens to be named in the JSON. An id naming an undeclared seat, or a seat on a worker other than claude, THROWS (names the id and the declared seats) instead of silently falling back to the keychain login.
  • Auto-routing, with no CYBERINE_BUILD_FLEET pin: a qa task keeps one adapter per profile — the first bound seat, in fleet.json entry order — because a question needs one answer, not one bill per seat. A build task (code-small / code-large) fans out to every enabled seat bound to claude — several seat-pinned adapters are several real workers, one per subscription.

Preemptive rotation away from a warned seat (k9cw8). Auto-routing (no CYBERINE_BUILD_FLEET pin) already picks a seat via routing.seats; when that pick comes from strategy: round-robin or strategy: first and the picked seat's last known reading ($CYBERINE_HOME/quota/claude-<tag>.json, written after every turn that seat ran) is allowed_warning, rejected, or otherwise at or past the CLI's own surpassedThreshold, the router walks forward through the declared seat lanes for the next one with no warning (or no reading at all) and dispatches that seat instead — never emptying the fleet, only reordering which seat gets today's turn. The swap is named in the route reason: seat personal skipped: allowed_warning at 93%, resets in 42 min; picked team. An explicit default or byKind pin is dispatched exactly as named — the operator asked for that seat — but the reason still carries the warning: seat personal is allowed_warning at 93%, resets in 42 min (pinned by routing.seats.default, dispatching anyway). When every declared seat is warned, the original pick stands (same note, no healthy seat among [...]) since there is nothing better to rotate to. See it without spending anything: cyberine route "<task>".

Known limitations and deferred work:

  • Only Claude seats report the warning. The signal is the Claude CLI's own rate_limit_event stream-json line; a seat that has never run a turn in this $CYBERINE_HOME, or whose last reading is older than 30 minutes, has no usable data and is treated as healthy (or as a healthy alternative) so missing data never penalises a seat that simply hasn't spoken up yet.
  • The threshold is the CLI's own surpassedThreshold, not a Cyberine setting — there is no config knob to tune how early the rotation kicks in.
  • This is a pre-dispatch decision only. It changes which seat gets picked BEFORE a turn starts; it does not rotate mid-turn. A seat that hits an actual 429 while a turn is running still fails loudly and cools down exactly as described above (kzdc7) — that seat is simply less likely to be picked again once its next reading lands.

Dispatch a seat in the background (k6hgj): cyberine run --bg --fleet claude-team "<task>" works, and takes a different lane from an unpinned --bg. Every other background dispatch is exec'd as a claude --bg --name cyberine-run-<runid8> fleet row by default; a run whose fleet carries a seat-pinned id skips that row entirely and spawns Cyberine's own detached child instead, because the row is served by the CLI's daemon under the daemon's credential, not the seat's. The launcher prints one line saying so (run <runid8>: seat-pinned: claude-team — dispatching as a detached background process) and hands the child CYBERINE_DISPATCH_REASON=seat-pinned: <ids>, which lands on the run's run.json as dispatchReason; an unpinned --bg is unchanged and carries no such field. That env var is set by the launcher only — setting it by hand labels a run without changing its shape. Nothing else differs: the workers are the same one-shot claude -p processes reading the same seat token file, the run registers in the global registry as always, and cyberine agents lists it exactly as before — it simply has no claude agents row of its own.

5. Read which seat actually ran a result: each worker's Result.raw.accountKey — persisted into a run's run.json (results[].raw.accountKey for a single dispatch, steps[].results[].raw.accountKey for a workflow) — is seat-<12 hex chars>, a fingerprint of the token file's own contents, never the raw value. A worker with no seat pin instead carries acct-<12 hex chars>, fingerprinted from the keychain login's account/org UUIDs — the prefix alone tells a pinned run from a keychain one.

Known limitations and deferred work:

  • In-process claude -p only — the WORKER always is, whatever shape the RUN takes. No fleet-visible claude agents session for a seat-pinned worker: CYBERINE_CLAUDE_WORKER_MODE=bg is explicitly ignored for a seat id, because a --bg row is served by the CLI's own daemon under the daemon's environment, not the credential just resolved for the seat. Every autopilot driver (stream / bg / session) strips an ambient seat-selector env var on its own boundary and refuses one passed as an option or argv — those lanes always run on the keychain login, never a pinned seat. Do not read this as "a seat cannot run in the background": cyberine run --bg --fleet claude-team is supported and detaches through Cyberine's own child process (see the dispatch note above, k6hgj) — what stays off-limits is the CLI's own daemon-owned row, not backgrounding itself.
  • A background seat run has no claude agents row (k6hgj). Skipping the fleet row is what keeps the seat's credential in charge, and the cost is that the run is watchable only through Cyberine's own surfaces (cyberine agents, the TUI, cyberine wait) — there is no row to claude attach, and no PTY for a peek reply. The decision is made from the resolved fleet ids at launch, so an id naming a seat the global fleet.json never declares is ALSO routed to the detached lane and then refused inside the child by the same error an in-process run would raise — deliberately, since a daemon row would have billed an unchosen account before that refusal was ever reached.
  • Model availability is per seat, not global. A subscription plan does not necessarily serve every model — pin a model each seat can actually run rather than assuming one config works everywhere.
  • No automatic seat rotation on quota exhaustion (kzdc7). A seat that runs out of quota mid-turn fails loudly rather than falling back to another seat or the keychain — silently billing an account the operator did not choose is the one outcome seat pinning exists to prevent. The pre-dispatch preemptive rotation above (k9cw8) narrows this: an auto-routed round-robin/first pick avoids a seat already known to be warned, but an in-flight exhaustion is still not rotated away from, and an explicit pin (--fleet, default, byKind) is never overridden either way.
  • qa routes use one seat. Fan-out across every declared seat is a build-route property only (step 4) — a qa dispatch never spends more than one subscription's turn.
  • Identity is verified only by the anthropic-organization-id response header, probed with that worker's own credential — not by claude auth status, a claude-whoami run from inside a Claude Code session, or the startup banner, all of which report the KEYCHAIN account regardless of which seat a worker is pinned to. fleet doctor and accountKey both attest to CONFIG — a token file exists, is mode 600, and fingerprints consistently — neither proves the token inside is valid, unexpired, or belongs to the account you think it does.

Skill: cyberine-run

A Claude Code skill (plugin/skills/run/, exposed as /cyberine:run; renamed from seat 2026-09-17, kzaqn; the skills/cyberine-seat rollback symlink was removed 2026-09-25, k5pnj) turns a plain-language seat request — "run this on seat X", "background it and tell me when done", "which seat ran this", "chay tren seat X", "chay ngam roi bao toi", "seat nao dang chay" (Vietnamese and English both trigger it) — into the exact cyberine run / wait / digest / apply invocation above, discovering the declared seat names via cyberine fleet doctor rather than assuming any. It covers explicit one/several-seat dispatch, no-seat routing-policy dispatch, foreground vs background, the background-and-notify recipe, digest-then-apply (asks before applying), seat health listing, and reading raw.accountKey to answer "which seat ran run X". Its init verb — "setup cyberine cho repo nay chi dung seat", "init cyberine here, seats only", "/cyberine:run init" — turns any repo into a seats-only Cyberine project without hand-editing JSON, with the single command cyberine init --yes --seats-only: cyberine init now pins the fleet itself, writing .cyberine/config.json, then .cyberine/fleet.json through the same seats-only writer fleet init --seats-only used to call directly, appending .cyberine/fleet.json to .gitignore if the line is missing, then running fleet doctor in-process. It refuses the fleet.json write (naming the global $CYBERINE_HOME/fleet.json to declare a seat in) when none is declared yet, but still leaves config.json written. Worked examples per scenario live in plugin/skills/run/references/scenarios.md (section 11 for init).

Validate and audit

cyberine fleet doctor    # structural errors + semantic warnings + drift (missing built-in profiles)

Doctor output is grouped into three sections:

  • ERRORS — structural problems (invalid JSON, missing required fields). Exit code 1.
  • WARN — semantic issues: enabled-but-NO-KEY providers, stale model ids not in catalog, routing pointing at disabled/unknown providers, one line per declared seat carrying its resolved token-file path, whether that file exists, and whether it is mode 600 (never its contents), and — when routing.steps is present — the step policy in one line (a model-pinned entry renders as worker@model) plus one line per pinned worker id that is unknown or not enabled.
  • DRIFT — built-in worker profiles not in fleet.json. fleet init --merge adds every one of them disabled, so the operator opts each in deliberately; its output and the doctor hint both name cyberine fleet enable <provider> for each added worker that is already installed and logged in (CLI) or keyed (API), and that enable is what puts it on cyberine route's fleet line. A worker with no login or key stays disabled until it gets one.

Set routing for a model alias

cyberine fleet set-model deepseek flash --for qa    # route qa tasks to deepseek:flash

Pin which workers run each implement-task step (routing.steps, kha9c + k7ysm)

routing.seats picks the seat; routing.<kind> picks the model for a task kind; routing.steps picks the WORKERS for each step of the implement-task workflow — design, implement, review, any subset — so strong models can plan while cheap multi-provider workers implement. Values are adapter ids (claude-kt, loop-deepseek-direct, loop-deepseek-build), a pinned step runs exactly those workers and refuses to dispatch rather than widening when none of them was built, and the design step's default ban on subscription-billed workers is lifted only for ids named there. An entry may also be the object form { "worker": "claude-kt", "model": "claude-fable-5-1" } (k7ysm), which additionally pins the MODEL that worker runs for that step only — injected per dispatch, never as an environment variable, refused at load when the worker's adapter cannot take a per-step model or the id is outside that worker's catalog. cyberine fleet doctor renders a pinned entry as worker@model. Omit the block and every step fleet is unchanged. Full recipe, validation rules and limits: "Pin a fleet per step" and "Pin the model a step's worker runs" under section 7 (Implementer fleet) above.

routing.seats picks the seat; routing.<kind> picks the model for a task kind; routing.steps picks the WORKERS for each step of the implement-task workflow — design, implement, review, any subset. Values are adapter ids (claude-kt, loop-deepseek-direct, loop-deepseek-build), and a pinned step runs exactly those workers, refusing to dispatch rather than widening when none of them was built. Omit the block and each step derives its own fleet from the strength table instead (strongest enabled workers plan and review, cheap-class workers implement). Full recipe, validation rules and limits: "Pin a fleet per step" under section 7 (Implementer fleet) above.