Adapters
Two things per agent: what Leg reads from an interactive session
(leg claude|codex|agy), and the headless argv the v0.1 pipeline spawns.
Every fact here was written against src/taps/*.mjs, src/attach.mjs and
src/adapters/*.mjs; the evidence trail, including which lines an artifact
backs, is cli-contracts.md.
An agent is never run through a shell. Interactive sessions are
spawn(bin, argv, { stdio: 'inherit' }) (src/attach.mjs spawnSpec);
headless legs are spawn(spec.bin, spec.args, …) (src/runner.mjs). Both
strip the API-key and base-URL variables and the Claude Code nested-session
markers from the child environment (src/env.mjs sanitizeEnv).
What Leg reads from each agent
Nothing is screen-scraped. Each tap was read from the CLI's own source or documentation, then checked on a real machine on 2026-09-11 (Claude Code 2.1.268, codex-cli 0.153.4, agy 1.2.0). Lines that a live run or a fixture backs say observed-live; lines read only from a CLI's source or documentation say docs-only.
claude
- How Leg attaches: one extra settings file per session, passed as
claude <your args> --settings <~/.leg/sessions/<id>/claude-settings.json>(src/taps/claude.mjssettingsFor). Hooks from--settingsmerge with yours rather than replacing them. Observed live: a Leg session ran with every user hook still firing. - Hooks wired:
SessionStart,UserPromptSubmit,PostToolUse(matcherEdit|Write|MultiEdit|NotebookEdit),Stop,StopFailure,SessionEnd, each runningnode src/hook.mjs claude-hook --session <id>. Observed live. autoContinueAtUsageLimitis set tofalsein that settings file, because Leg owns what happens at the limit.- Usage percentages:
GET https://api.anthropic.com/api/oauth/usage(LEG_CLAUDE_USAGE_URLoverrides), with theaccessTokenClaude Code stored in<CLAUDE_CONFIG_DIR>/.credentials.jsonunderclaudeAiOauth, and the headeranthropic-beta: oauth-2025-04-20. The response carriesfive_hourandseven_day, each{ utilization, resets_at }. Polled every 60 s (LEG_USAGE_POLL_MS). Observed live: real percentages come back and land in<LEG_HOME>/usage/claude--default.jsonwithsource: claude usage endpoint; a 7-day window at 93 % raised the amber warning on 2026-09-11. - The wall: the
StopFailurehook fires witherror: rate_limit(docs). Status: observed-live 2026-09-11 . A realStopFailurearrived on 2026-09-11 at 07:46:37Z, a 429rate_limit_errorfrom the API, and is kept, secrets scrubbed, asfixtures/live/claude/limit-rate_limit.json(src/live-capture.mjs); a payloadleg sessions simulate-limit <id>produces is marked and never kept (src/live-capture.mjsisSimulated). The path is also covered by the hook contract test and can be driven end to end withleg sessions simulate-limit <id>, which sends the same payload throughsrc/hook.mjs. - Why not the status line. Leg writes a
statusLineentry into the same settings file that would recordrate_limits.five_hour.used_percentageandresets_at, and chains your ownstatusLinecommand first. Claude Code 2.1.268 did not run it when it was tried on this machine: anechocommand passed through--settingsand again through a project.claude/settings.local.jsonleft the built-in status line in place, while hooks from the same--settingsfile fired. No artifact of that check was kept; the note lives in thesrc/taps/claude-usage.mjsheader. The endpoint poll is therefore the live source; the status-line route becomes a fallback the moment a build honours it. - Token handling: the stored token is read by the polling process only,
sent only to
api.anthropic.com, and written nowhere. The ledger scrubs bearer tokens from every line regardless (src/redact.mjs).
codex
- How Leg attaches: nothing is injected.
leg codexrunscodexwith your arguments, then finds and tails that session's rollout file. A hook would have to be trusted by codex on first use, which is why this tap reads instead. - Which file:
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl(CODEX_HOMEwhen an extra account is in use), picked bysession_meta.payload.cwdequal to the session's directory and a birth time or mtime at or after the spawn, with five seconds of slack (src/taps/codex.mjsfindRollout). Observed live: the tap found the right rollout for a real hand-off. On Windows the file's mtime lags its contents, across six rollouts from 2026-09-11 the mtime was 7 s to 9 min behind the last line's timestamp, sofindRolloutmatches on cwd and accepts a birth time or mtime at or after the spawn rather than relying on the two agreeing. - Usage percentages: the read-only app-server
account/rateLimits/readresponse. Leg identifies the returned windows by duration rather than by field name: 300 minutes is 5h and 10080 minutes is 7d. The board and active attach poll it every 60 seconds; neither sends a model turn nor assumes a quota. Only an explicit available response clears a prior wall. - The wall:
event_msg.task_complete.errorwithcodex_error_info: "usage_limit_exceeded"and the message "You've hit your usage limit … try again at <date>". The wording comes fromcodex-rs/protocol/src/error.rs(UsageLimitReachedError); the event shape was read from local rollouts. The error itself: observed-live 2026-09-15 , a realtask_complete.errorwithcodex_error_info: usage_limit_exceededwalled a codex leg at 07:56:24Z and handed the session to agy. No payload was kept: the capture call insrc/attach.mjswas added while that session's runner was already running, sofixtures/live/codex/limit-usage_limit_exceeded.jsonis still the slot for the next one. - Transcript: user prompts from
response_item.messagewithrole: userandcontent[].type: input_text; assistant text fromoutput_textand fromtask_complete.last_agent_message. Observed live. - Edited files: parsed from
apply_patchpayloads, the*** Add File:/*** Update File:/*** Delete File:lines. Observed live.
agy
- How Leg attaches:
agy <your args> --log-file <~/.leg/sessions/<id>/agy.log>. agy 1.2.0 is a closed Go binary with no hook surface. - Usage percentages: none. agy exposes no percentage anywhere on disk; its
own status line fetches a quota summary from the backend and writes it
nowhere. When agy is not walled the board shows a "no % from agy" chip
instead of empty bars (
src/board/sessions.js); when it is walled the chip shows the wall and its reset. - The wall:
RESOURCE_EXHAUSTED, "it resets in %s" and "out of quota" in the log. Those strings are present inagy.exe, andscanLog()also reads a relative reset out of "resets in <n><s|m|h|d>". Status: docs-only —RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in 71h19m42s.appeared in a session'sagy.logat 08:02:42Z and walled the agent. No payload was kept: the capture call insrc/attach.mjswas added while that session's runner was already running, sofixtures/live/agy/limit-agy-resource-exhausted.jsonis still the slot for the next one. - Prompts and conversation id:
~/.gemini/antigravity-cli/history.jsonl, one{ display, timestamp, workspace, conversationId }per prompt. Observed live. - One account only: agy 1.2.0 has no config-directory override, so
leg accounts add agy …is refused.
Resume prompt per agent
After a hand-off the next agent starts in the same terminal with the pointer
prompt as its first positional argument: claude "<prompt>",
codex "<prompt>", agy -i "<prompt>" (src/attach.mjs spawnSpec).
Headless adapters (the v0.1 pipeline)
These are what a pipeline card's chain spawns. Unchanged since 0.2.0.
claude (headless)
- Binary:
LEG_CLAUDE_BIN, else~/.local/bin/claude.exe(Windows) or~/.local/bin/claude, elseclaudeon PATH. - Argv:
claude -p --output-format json --permission-mode <mode>, plus--max-turns <n>,--resume <session-id>,--model <name>,--allowedTools <a,b>when set. - Stdin:
pipe: the prompt is written to stdin, not argv. - Modes: default
acceptEdits; allowedacceptEdits,auto,plan,manual,dontAsk. - Forbidden flags:
--dangerously-skip-permissions,--allow-dangerously-skip-permissions,--permission-mode=bypassPermissions,bypassPermissions. - Gotchas: prints nothing on stdout until the session ends (poll
run.json, never a stdout timeout). Stderr saying "another auth source is set" is anauth_failedleg, not a limit: it means anANTHROPIC_API_KEYor similar is shadowing the subscription login.
codex (headless)
- Binary:
LEG_CODEX_BIN, else the@openai/codexnpm package's native platform exe (resolved under%APPDATA%\npm\node_modules\@openai\codex\...on Windows), elsenode <bin/codex.js>viaresolveNpmCliEntry, elsecodexon PATH. - Argv:
codex exec --json -s <mode> -C <cwd> -c sandbox_workspace_write.network_access=<true|false>, plus-m <model>,-o <runDir>/last.md,resume <session-id>and the prompt as the last positional argument. - Stdin:
ignore, deliberately.codex execreads stdin whenever it is not a TTY and hangs on an open pipe ("Reading additional input from stdin..."); a plan-time probe with a pipe hung until it was killed at 170 s; the same task with stdin closed finished normally (the kept probe run took 28 s). - Modes: default
workspace-write; allowedread-only,workspace-write. - Forbidden flags:
danger-full-access,--dangerously-bypass-approvals-and-sandbox,--dangerously-bypass-hook-trust,--full-auto,--approve-for-me. - Gotchas: network access is off unless the chain entry sets
network: true. If you runcodex execby hand outside Leg, always pass the prompt as an argument, never on a pipe.
agy (headless)
- Binary:
LEG_AGY_BIN, else%LOCALAPPDATA%\agy\bin\agy.exe, elseagyon PATH. - Argv:
agy -p "<working-directory preamble>\n\n<prompt>" --output-format json --mode <mode> --add-dir <cwd> --print-timeout <duration>, plus--model <model>,--conversation <session-id>. The duration is the card's kill timer converted to Go syntax, for example90m. - Stdin:
ignore. - Modes: default
accept-edits; allowedaccept-edits,plan. - Forbidden flags:
--dangerously-skip-permissions. - Gotchas: agy does not act in the process cwd by default: with no
--add-dirit writes into its own scratch workspace (~/.gemini/antigravity-cli/scratch/, observed live). Leg always passes--add-dir <worktree>and prefixes the prompt with "Working directory:<worktree>" so the agent writes where the card expects.--print-timeoutdefaults to 5 minutes in the CLI itself; Leg always sets it explicitly from the leg's kill timer so the supervisor, not agy, decides what a runaway is.
fake (and fake-claude / fake-codex / fake-agy / fake-nostdin)
- Binary: always
node bin/fake-agent.mjs(process.execPath+ the script path); nothing to install. - Argv:
fake-agent.mjs [--mode <m>] [--max-turns <n>] [--resume <id>]. - Stdin:
pipeforfake,ignorefor thefake-*named variants (fake-nostdinis the same agent with stdin ignored), useful for exercising both stdin conventions without a real CLI. - Modes: default
acceptEdits; allowedacceptEdits,plan,workspace-write,read-only,accept-edits,auto_edit(a superset covering every real adapter's vocabulary, so a chain can mix fakes and real adapters without a mode conflict). - Forbidden flags:
--dangerously-skip-permissions,--yolo. - What it does: driven entirely by the
FAKE_MODEenvironment variable, set per chain entry with--fake-mode <adapter>=<mode>(see getting-started.md for the full list of modes).fake-claude/fake-codex/fake-agysetemulatesto that CLI's name, so the limit classifier applies that CLI's own fixtures to it and a demo chain reads like a real fallback.
grok (built, not registered)
- Binary:
LEG_GROK_BIN, else~/.grok/bin/grok.exe, elsegrokon PATH. - Argv:
grok -p "<prompt>" --output-format json --permission-mode <mode>, plus-m <model>,-r <session-id>. - Stdin:
ignore. - Modes: default
acceptEdits; alloweddefault,acceptEdits,auto,dontAsk,plan. - Forbidden flags:
--always-approve,bypassPermissions,--permission-mode=bypassPermissions. - Status:
src/adapters/grok.mjsexists and is unit-tested, but is not insrc/adapters/index.mjs's registry. The build machine had nogroklogin: the probe printed a device-code prompt and exitedCancelled. Register it (add an entry toREGISTRYinsrc/adapters/index.mjs) oncegrok loginhas been completed andnode scripts/probe.mjs --adapter grok --repo <toy-repo>passes.
How to add an adapter
An adapter is a plain object (see src/adapters/common.mjs for the shared
helpers, src/adapters/fake.mjs for the simplest full example):
{
name: 'mycli',
stdin: 'pipe' | 'ignore',
modes: { default: 'acceptEdits', allowed: ['acceptEdits', 'plan'] },
forbiddenFlags: ['--any-bypass-flag'],
emulates: null, // optional: another registered adapter's name
resolve() { return { bin, viaNode, entry } },
argv(opts) { return { bin, args } }, // opts: mode, maxTurns, resume, cwd, prompt, model, ...
env(base) { return sanitizeEnv(base) }, // from src/env.mjs, always
parseResult(text) { return { session_id, last_message, stop_reason, raw } | null },
}
Write
src/adapters/<name>.mjsexporting that shape asdefault.argv()must callassertAllowed(adapter, opts)(fromsrc/adapters/common.mjs) first, so a forbidden mode or flag throws before anything spawns.Add an entry to
REGISTRYinsrc/adapters/index.mjs:<name>: { path: './<name>.mjs' }.Add its limit/auth/launch signal fixtures under
fixtures/limits/(see the existing ones for the JSON shapesrc/limits.mjsexpects:id,adapter,source,produced_by,where,text,classification).Run the probe script against a real login before trusting it:
node scripts/probe.mjs --adapter <name> --repo <existing-git-repo> [--mode <m>] [--timeout-s 300]It runs one real tiny task (write a file, write
.leg/DONE) through the same runner a card uses, and printsprobe <name>: exit=<code> file=<yes|no> done=<yes|no> auth_source=<yes|no> seconds=<n>. Keep the evidence it produces underfixtures/live/<name>/and cite it in cli-contracts.md, the way every existing adapter's section does.
An interactive tap is a separate, larger job: a new agent needs a
src/taps/<name>.mjs that answers three questions (what are the usage
percentages, what does the wall look like, what are the prompts and edited
files) and a branch in src/attach.mjs spawnSpec.
See also
- cli-contracts.md: the full evidence trail, exit codes, the interactive tap sources, and every limit-signal fixture, tagged observed-live or docs-only.
- configuration.md: the
LEG_<ADAPTER>_BINoverrides and the accounts layout. - concepts.md: sessions, usage windows, the interactive hand-off, and how a headless leg's outcome is classified.
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.