Leg

CLI contracts

What Leg knows about each coding-agent CLI, where every fact came from, and which facts were observed live on the build machine versus read from --help or docs. Two contracts per CLI: the headless argv a pipeline leg spawns (§ per CLI below), and the interactive tap leg <agent> reads (§ Interactive taps). Evidence: fixtures/help/<cli>.txt (raw --help), fixtures/live/<cli>/ (one real tiny task per CLI, run through src/runner.mjs launch on 2026-09-11 UTC, the evening of 2026-09-10 locally; paths under the local home directory are replaced with ~), and the interactive checks run on 2026-09-11.

Every fact line ends with (source: …). observed-live means the build machine did it; docs-only means the CLI's own --help or documentation says so and Leg has not seen it happen.

Probe task (identical for every CLI, scripts/probe.mjs): "Create a file named hello-.txt in the current directory containing exactly the word hi. Then create the directory .leg if it is missing and write the file .leg/DONE containing the single line: done. Do nothing else. Do not ask questions."

CLI version probe result adapter
claude 2.1.268 exit 0, file yes, DONE yes, 20 s src/adapters/claude.mjs
codex codex-cli 0.153.4 exit 0, file yes, DONE yes, 28 s src/adapters/codex.mjs
agy 1.2.0 attempt 1 exit 0 but wrote to its scratch workspace; attempt 2 (--add-dir) exit 0, file yes, DONE yes, 41 s src/adapters/agy.mjs
grok 1.0.34 exit 1, no file, no DONE: the account answered 402 Payment Required: Grok Build usage balance exhausted, classified limit (re-probed 2026-09-17; the 2026-09-11 run on 0.2.51 was not logged in) src/adapters/grok.mjs

The auth-source check: the build shell carried ANTHROPIC_API_KEY and OPENAI_API_KEY (injected by the shell profile). Every adapter's env() deletes them (src/env.mjs). No fixture contains "another auth source" (grep -ril "another auth source" fixtures/live is empty), claude's and agy's stderr are 0 bytes, and codex's is the one stdin notice. observed-live.

claude

codex

agy

grok

leg harness (the portable harness)

What the command reads and writes per client, each fact from the engine's adapter source (src/harness/vendor/agnostic-ai/engine/harness/{sources,targets}/*.cjs, byte for byte the Agnostic AI engine) and verified by test/harness-*.test.mjs against fixture homes on 2026-09-16.

client read as a source written as a destination shim
Claude Code ~/.claude/CLAUDE.md (+ @imports inside the home), SOUL.md, the hooks and permissions of its settings file, the mcpServers of ~/.claude.json and ~/.claude/.mcp.json (the oauth block is never read), agents/*.md, commands/*.md, skills/*/SKILL.md ~/.claude/leg-rules.md + one @ line appended to CLAUDE.md; owned hook groups and permission entries in its settings file; owned servers in ~/.claude.json; agents/, commands/, skill links none: the bundle is Claude's dialect
Codex CLI ~/.codex/AGENTS.md, config.toml (hooks.*, mcp_servers.*), agents/*.toml, prompts/*.md, skills/, rules/*.rules (per-invocation absolute-path approvals skipped; auth.json never read) AGENTS.md (whole file, owned), config.toml regions hooks (with [hooks.state] trust hashes, self-tested), skills (duplicate disables), mcp; agents/*.toml; prompts/*.md; skill links; rules/leg-harness.rules prefix rules none
Antigravity CLI not a source ~/.gemini/GEMINI.md (owned), the leg-harness key in ~/.gemini/config/hooks.json, mcp_config.json servers, config/agents, config/commands, config/skills links the engine's hook shim, chained with ++
Gemini CLI not a source GEMINI.md, owned hook groups in its settings file, commands/*.toml, mcpServers, skill links shim
Grok CLI no no (reported unsupported)

Exit codes: 0 fine; 1 stale or attention (check, sync) or a doctor failure; 2 usage; 3 not captured, no source, or consent declined (enable without --yes and without a terminal). --json on status, inspect, sync, check, explain, history, doctor prints the same record the board reads.

Interactive taps

What leg claude|codex|agy|grok reads while the real interactive CLI runs. Same tagging rule: observed-live 2026-09-11 means the build machine did it; docs-only means the CLI's own source or documentation says so and Leg has not seen it happen. Machine: Claude Code 2.1.268, codex-cli 0.153.4, agy 1.2.0.

Shared: the agent is spawned with stdio inherited and the user's arguments passed through (src/attach.mjs spawnSpec); the child environment is sanitizeEnv(process.env, { interactive: true }) plus the account's config-dir variable and LEG_SESSION (source: src/attach.mjs, src/env.mjs).

claude tap

codex tap

agy tap

grok tap

Usage store and the chooser

Session store

<LEG_HOME>/sessions/<id>/ holds session.json, events.jsonl, hook.log, and, per agent, claude-settings.json or agy.log; control.json appears only once the board has asked for a hand-off or an end (source: src/sessions.mjs header, src/attach.mjs). Statuses and event types are listed in VOCABULARY.md. Board routes: GET /api/sessions, GET /api/sessions/:id, POST /api/sessions/:id/handoff, POST /api/sessions/:id/end, DELETE /api/sessions/:id, with the list pushed as the SSE sessions event (source: src/server.mjs, src/board/sessions.js).

session.ahead is the number of commits the checkout is past its upstream, or past head_at_start when it tracks nothing. It is counted with git rev-list --count <base>..HEAD on the runner's git poll and is null, never 0, when this is not a repository or the count could not be taken (source: src/attach.mjs aheadCount). Like the dirty file list, it is owner-only: redactSession drops it from another human's row.

GET /api/sessions also carries cards_waiting, so the terminals verdict can name a waiting card without reading the pipeline board: {count, first: {id, title, station, since}}, where count is the number of cards in needs_approval or waiting_human and first is the one that has been waiting longest (since is its updated_at), or null when none is. A count alone cannot write card 3e1c has waited on you for 12 minutes., which is the sentence the field exists for (redesign C.5). It is sent to anyone who may use cards, which is the owner and any operator; a guest has no cards and is not sent it (source: src/server.mjs cardsWaiting).

A guest's own row carries no figure that was measured from this machine's logins: limits, all_out and usage_source are dropped, warning keeps its window and loses its percentage and its clock, a limit or a waiting of type reset keeps its words and loses its resets_at, and a handoff_targets row carries no cost (the word credits on a claude/fable rung is the owner's extra_usage setting by another name). A row that is available carries no reason at all, because the reserve and below:N notes are advice for a human press, not a refusal. Someone else's row is redacted further: no limits, and warning is reduced to its window (source: src/server.mjs redactSession, scrubOwnerUsage).

Making a card over HTTP

POST /api/cards takes the same body createCard takes from the CLI, with one difference: pipeline must be a preset name (build, build-land, factory) or an inline array of stations. A path to a JSON file is a CLI convenience (leg card add --pipeline ./my.json) and is refused here with 400 invalid pipeline: pipeline must be one of the presets ... or a list of stations, naming no path and quoting no bytes: an operator may post cards and may not read this machine, and the JSON parser's own message quotes the first characters of whatever file it opened (source: src/cards.mjs allowPipelineFile).

chain is a comma list (claude,codex) or an array of entries ([{adapter, model?, mode?, max_turns?}]). The array form is what the board's one-line entry posts, because it is the only one that can put a different model on each leg: [{adapter: 'claude', model: 'fable'}, {adapter: 'claude', model: 'opus'}] is two legs on one login, which --model claude=fable (one model per adapter) cannot express.

A terminal becomes a card, and a card becomes a terminal

POST /api/sessions/:id/end-as-card (owner or operator; a guest is refused) writes the terminal's hand-off bundle with saveSessionBundle, creates a card whose task is the terminal's prompt plus Continue from the bundle at <path>., starts the card's chain at the rung the terminal is standing on, records lineage.from, and then requests end on the terminal exactly as POST /api/sessions/:id/end does. 201 with {card, bundle, carried: {files, adopted}}. Where the card works, and when it starts, depend on what the terminal had:

The bundle is written first, and the uncommitted work is read before anything is created: if either fails, nothing is created, nothing is ended, and the terminal is left running with 409 (source: src/server.mjs).

POST /api/cards/:id/take-over moves the card to paused through the take_over transition, killing the child of a running one, and answers 200 with {card, command}, where command is leg <agent> --resume-card <card-id> for the agent on the card's current leg. Every non-terminal status is paused, not only running: a queued or handing_off card is in the set the scheduler starts from, so leaving it there would launch a leg into the same worktree the human was just handed. One taken_over event with the caller's name is appended whatever the starting status, and it is in the audit trail. A card in done, failed or killed is refused with 409 (source: src/server.mjs, src/chain.mjs).

leg <agent> --resume-card <id> is Leg's own flag and never reaches the agent's argv. It opens an ordinary interactive terminal in that card's worktree, whatever directory it was run from, primed with the card's bundle as the first prompt and with lineage.from naming the card. The id may be the full card id or a unique tail of it; an ambiguous one is refused by name with the matches listed, and an unknown one exits 3 (source: src/attach.mjs takeFlagValue, resolveCardId, takeOverPrompt).

A live card's board payload carries what was measured and nothing else: work {files, insertions, deletions} parsed from git diff --shortstat <trunk>..HEAD in its worktree, tests {state, at} and land {state, reason, sha} read from the card's own ledger. A key that could not be measured is absent; a finished card carries none of them (source: src/server.mjs parseShortstat, cardOutcomes, summarize).

The hand-off ladder

leg sessions handoff <id> --to <agent>[/<account>[/<model>]] names the rung directly. Three parts are unambiguous; with two, the second is read as an account when the agent has one by that name, else as a model when the agent has one by that name, else the command is refused by name rather than guessed at (source: bin/leg.mjs parseTarget). leg sessions simulate-limit <id> --message "<text>" drives that exact wording through the real classifier (src/hook.mjs for claude, the session's own log for agy and grok), which is how a per-model wall is reached without waiting for a real one; codex has no Leg-owned input to simulate through and is refused by name (source: bin/leg.mjs simulateLimit).

leg ladder (also leg ladder ls) prints the ladder, rung 1 first, each with its live state (a wall and its clock, a percentage, or "no figure") and the reason it would be skipped right now, then the spending, climb-back, reserve and derived-order lines. leg ladder set <n> <agent>[/<account>[/<model>]] [--when always|below:N|walled-only] writes rung n (past the end of the ladder, it appends); leg ladder rm <n> removes rung n and refuses to remove the last rung left; leg ladder spend on|off flips may_spend (source: bin/leg.mjs ladderCommand).

Board routes: PATCH /api/settings (owner only) also accepts handoff_ladder, climb_back, may_spend, reserve, notify_terminal and notify_board. POST /api/sessions/:id/handoff-order accepts either {handoff_order} or {handoff_ladder} and rewrites both on the session. POST /api/sessions/:id/handoff accepts target.model alongside target.agent and target.account (source: src/server.mjs).

GET /api/models

The models this machine can start each agent on, so a rung and a card can name one. Owner and operator only; a guest gets the same 403 /api/adapters and /api/presets give, because picking a model is spending the owner's plan (source: src/server.mjs, src/models.mjs).

{
  "models": {
    "claude": [{ "id": "fable", "label": "Claude Fable", "default": false }],
    "codex":  [{ "id": "gpt-6-astra", "label": "GPT-6-Astra", "default": true }],
    "agy":    [{ "id": "gemini-3.8-flash-high", "label": "Gemini 3.8 Flash (High)", "default": false }],
    "grok":   [{ "id": "grok-4.6", "label": "grok-4.6", "default": true }]
  },
  "observed_at": "2026-09-18T14:37:01.577Z"
}

Where each list comes from, all observed live on 2026-09-18 and captured under fixtures/models/:

agent source today's ids default
claude MODEL_ALIASES.claude in src/buckets.mjs; four aliases Claude Code resolves itself, not service-side ids fable, opus, sonnet, haiku none: a bare claude picks for itself, and naming one would be Leg making a choice nobody made
codex <CODEX_HOME>/models_cache.json, models[] where visibility is "list" ("hide" covers gpt-reserve and codex-auto-review) gpt-6-astra, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5 the top-level model = "..." in <CODEX_HOME>/config.toml, never a [profiles.x] one
agy agy models, one id<TAB>label per line after a Fetching available models... line 14, from gemini-3.8-flash-high to gpt-oss-120b-medium, including claude-opus-4-6-thinking agy publishes none
grok grok models, * id (default) / - id bullets under a Default model: X line; it lists them even when it is not logged in grok-4.6, grok-4.5 grok-4.6

agy and grok each cost a process, so their answers are cached in <LEG_HOME>/models/<agent>.json for an hour with a 20s probe timeout. A request never waits on one: the cached list is served and the refresh runs behind the answer. A probe that fails leaves the cache exactly as it was.

An id from any of these lists may go on a ladder rung's model and on a card's chain entry, and reaches the CLI through modelFlagFor in src/buckets.mjs (claude --model, agy --model, codex -m, grok -m). Rung validation is by SHAPE for every agent (/^[a-z0-9][a-z0-9._:-]{0,63}$/, so a flag or a path can never become an argv element) and by MEMBERSHIP for claude alone, whose list is closed; the other three catalogs are live and the CLI itself is the authority on its own.

History index

<LEG_HOME>/history/index.json is the one file leg history writes: per provider, per transcript, size, mtime and a small scrubbed record (source: src/history/index.mjs). What each provider reads from its CLI's own store, with the observed file and field names, is the header of each src/history/providers/<name>.mjs; the support matrix and the resume commands quoted from each CLI's --help (observed-live 2026-09-16: claude --resume <id>, codex resume <id>, grok --resume <id>, agy --conversation <id>; copilot has none Leg has verified) are in history.md. Board routes, owner only: GET /api/history, GET /api/history/:id, GET /api/history/providers, POST /api/history/refresh, GET /api/worktrees (source: src/server.mjs, src/board/history.js).

Limit signals

Recorded in fixtures/limits/<group>/<id>.json and classified by src/limits.mjs (classify()); the runner writes the outcome into run.json.outcome with the matched signal. Every limit row below is still docs-only: its text comes from the CLI's own documentation or source, cited in "produced by". Real limits from all three agents were seen on 2026-09-11, but only the claude payload was kept as evidence (fixtures/live/claude/limit-rate_limit.json); the codex and agy ones went to the session ledger, not to fixtures/limits/. The observed-live rows are the non-limit cases the probes actually hit and the detector must never mistake for a limit.

Outcome precedence (src/limits.mjs): spawn error → launch_failed; stderr "another auth source is set" or an auth fixture → auth_failed (wins over any limit text, even with exit 0); killed from the board → killed; kill timer → stalled; exit 0 + .leg/DONE → completed; adapter-specific then generic limit fixture → limit; launch fixture → launch_failed; exit 0 + changes, no DONE → incomplete; exit 0, nothing changed → no_progress; non-zero exit → failed. Every outcome except completed, auth_failed and killed asks the chain to hand off.

Generated by node scripts/limits-table.mjs from 24 fixtures (6 observed-live, 18 docs-only). Classification limit hands the card to the next agent as a usage limit; auth is a failed launch (never a limit); launch is a failed launch that the next agent may still try; budget is a turn or spend cap set by Leg itself; info must never classify as a limit.

id adapter class where source text (excerpt) produced by
agy-resource-exhausted agy limit any docs-only resource-exhausted agy changelog ("Fixed personal accounts hitting a resource-exhausted error at startup"); agy --help documents no limit wording; falls back to the generic matchers
claude-budget-limit claude budget any docs-only Budget limit reached https://code.claude.com/docs/en/cli-reference (--max-budget-usd)
claude-fable-limit claude limit any observed-live You've reached your Fable limit. Run /usage-credits to continue or switch models with /mod fixtures/live/claude/limit-rate_limit.json: StopFailure error rate_limit, last_assistant_message on the real run, 2026-09-11 (a per-model wall: the account's session and weekly windows were open)
claude-max-turns claude budget stdout observed-live {"type":"result","subtype":"error_max_turns","is_error":true,"stop_reason":"tool_use","ter fixtures/real-run/leg1/out.log: claude -p --max-turns 2 on the real run, 2026-09-10 (docs: https://code.claude.com/docs/en/cli-reference --max-turns "Exits with an error when the limit is reached")
claude-model-limit claude limit any docs-only You’ve hit your Opus limit https://code.claude.com/docs/en/costs ("You’ve hit your Opus limit" / "You’ve hit your Sonnet limit")
claude-session-limit claude limit any docs-only You’ve hit your session limit https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)
claude-weekly-limit claude limit any docs-only You’ve hit your weekly limit https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)
codex-quota-exceeded codex limit any docs-only quota exceeded github.com/openai/codex codex-rs/response-debug-context/src/lib.rs (ApiError::QuotaExceeded => "quota exceeded")
codex-rate-limit-exceeded codex limit any docs-only rate limit exceeded: github.com/openai/codex codex-rs/codex-api/src/error.rs (#[error("rate limit exceeded: {message}")])
codex-skills-notice codex info stdout observed-live Skill descriptions were shortened to fit the skills context budget. Codex can still see ev fixtures/live/codex/out.log (item.completed, item.type=error) from scripts/probe.mjs --adapter codex, 2026-09-10
codex-usage-limit-reached codex limit any docs-only Usage limit reached. You've reached your usage limit. Increase your limits to continue github.com/openai/codex codex-rs/tui/src/chatwidget/turn_runtime.rs (WorkspaceOwnerUsageLimitReached)
codex-usage-limit codex limit any docs-only You’ve hit your usage limit for {limit_name}. Switch to another model now, github.com/openai/codex codex-rs/protocol/src/error.rs (gh search code "usage limit" --repo openai/codex, 2026-09-10)
generic-429 * limit any docs-only 429 Too Many Requests generic HTTP matcher (429 Too Many Requests); lowest priority
generic-overloaded * limit any docs-only overloaded_error generic matcher (Anthropic API 529 overloaded_error); lowest priority
generic-quota * limit any docs-only quota generic matcher; lowest priority
generic-rate-limit * limit any docs-only rate limit generic matcher; lowest priority
generic-resource-exhausted * limit any docs-only RESOURCE_EXHAUSTED generic matcher (gRPC RESOURCE_EXHAUSTED); lowest priority
generic-usage-limit * limit any docs-only usage limit generic matcher; lowest priority
grok-balance-exhausted grok limit any observed-live API error (status 402 Payment Required): Grok Build usage balance exhausted grok 1.0.34 -p --output-format json on an account with no balance left, 2026-09-17: stdout {"type":"error","message":"Internal error: { "message": "API error (status 402 Payment Required): Grok Build usage balance exhausted", "http_status": 402 }"}, exit 1
grok-not-logged-in grok auth stderr observed-live To sign in, open this URL in your browser: https://accounts.x.ai/oauth2/device?user_cod fixtures/live/grok/err.log from scripts/probe.mjs --adapter grok, 2026-09-10 (stdout JSON stopReason: Cancelled, exit 0)
grok-rate-limit grok limit any docs-only You've hit the rate limit for your plan. Try again later. crates/codegen/xai-grok-shell/src/sampling/error.rs:18 (RATE_LIMITED_USER_MESSAGE_OAUTH: "You've hit the rate limit for your plan. Try again later.")
auth-source-set * auth stderr docs-only another auth source is set project brief (Wes, 2026-09-10): stderr saying "another auth source is set" counts as a failed launch; wording not yet observed live
compile-error * info stderr docs-only SyntaxError: Unexpected token ) at compileSourceTextModule (node:internal/modules/esm/ synthetic negative fixture (a crashed agent is not a limit)
empty-stdout-exit-0 * info stdout observed-live fixtures/live/grok (exit 0, no work): silence is not a limit

Runner facts that apply to every CLI (observed-live in phase 2/3 tests)


Leg is commercial, source-available software by Wes Sander. The source you run ships in the npm package. Questions or a refund: legcli@practicalsystems.io.