Agent-friendly headless agent (ask/session) — the local-LLM-agent half split out of SHUKE-LABS/baton
Overview
See Usage and Sessions below for the full flag set (model selection, alternate credential env vars, --session when a trail holds more than one session, etc). This installs leg, leg-tui, and leg-web. Run leg-tui in a terminal or leg-web to open the local browser interface. Their internal supervisor is bundled beside the native binaries; no LEG_UI_SUPERVISOR_BIN setting is needed. From a checkout, run bash scripts/dev.sh to update and install leg and its UI companions. Per-platform archives for every CI-supported target (.tar.gz on Unix, .zip on Windows) are attached to each GitHub Release. Each archive contains leg, leg-tui, leg-web, and leg-ui-supervisor (.exe on Windows); keep these binaries together when installing them. Releases are created automatically when changes land on main. A Conventional Commit subject of type feat bumps the minor version; every other subject bumps patch. The first release uses the current Cargo version (0.1.0) unchanged.
README
leg
leg binary skeleton.
Quickstart
export ANTHROPIC_API_KEY=sk-...
leg ask "hello"
LEG_CONTEXT_WINDOW_TOKENS=200000 LEG_EVENT_LOG=trail.jsonl leg session
# type a message, then Ctrl-D or /exit to end the session and create trail.jsonl
leg session --resume trail.jsonl
See Usage and Sessions below for the full flag set
(model selection, alternate credential env vars, --session when a
trail holds more than one session, etc).
Install
npm i -g @shukelabs/leg
This installs leg, leg-tui, and leg-web. Run leg-tui in a terminal or
leg-web to open the local browser interface. Their internal supervisor is
bundled beside the native binaries; no LEG_UI_SUPERVISOR_BIN setting is needed.
or build from source directly:
cargo install --git https://github.com/SHUKE-LABS/leg --locked
From a checkout, run bash scripts/dev.sh to update and install leg and its UI
companions.
Per-platform archives for every CI-supported target (.tar.gz on
Unix, .zip on Windows) are attached to each
GitHub Release.
Each archive contains leg, leg-tui, leg-web, and leg-ui-supervisor
(.exe on Windows); keep these binaries together when installing them.
Releases are created automatically when changes land on main. A
Conventional Commit subject of type feat bumps the minor version; every
other subject bumps patch. The first release uses the current Cargo version
(0.1.0) unchanged.
Every published npm package includes THIRD_PARTY_NOTICES.txt with the
licenses of the bundled third-party Rust crates and vendored material.
Experimental TUI trial
The terminal interface is experimental and ships with the npm package.
For a self-contained trial with fixtures, download a leg-tui-experimental-* bundle
from a Linux or macOS CI run, or build one with the command in the
TUI trial quickstart. Run it with a
disposable workspace and the local fixture; stop the TUI and remove the
unpacked bundle directory when finished. This trial does not change how the
regular leg binary is installed.
Experimental Web trial
The Web companion is experimental and ships with the npm package.
For a self-contained trial with fixtures, get the Linux or macOS bundle from the CI run’s
leg-web-experimental-* artifact, or build one with the command in the
Web trial quickstart. The bundle
embeds its page assets and runs locally; its tools use your OS account, with
the selected workspace as their working directory. The workspace is not a
sandbox. Stop the host with Ctrl-C and remove the unpacked bundle directory
when finished.
Usage
ANTHROPIC_API_KEY=sk-... leg ask [--model ] "prompt"
ANTHROPIC_API_KEY=sk-... leg ask --image chart.png "Describe this chart"
Use repeatable --image flags to attach local JPEG, PNG, GIF, or WebP
images to an ask turn. Image type is detected from the file contents; each
base64-encoded image is limited to 10 MB, with a 32 MB cap on the serialized
image request.
Prints the assistant reply on success. A provider or delivery failure
(bad credentials, unreachable base URL, etc.) leaves stdout empty, reports an
error including kind: error on stderr, and exits non-zero. Configuration
failures (missing or malformed environment values or hook config files) also
exit non-zero.
Also accepts ANTHROPIC_AUTH_TOKEN for bearer keys of Anthropic-compatible
endpoints, and CLAUDE_CODE_OAUTH_TOKEN. Claude subscription OAuth tokens
(including claude setup-token) are unsupported outside Claude Code; use an
Anthropic Console API key with ANTHROPIC_API_KEY instead. Other settings
include ANTHROPIC_BASE_URL, LEG_MODEL, LEG_TIMEOUT_SECS,
LEG_STREAM_IDLE_TIMEOUT_SECS, LEG_BASH_TIMEOUT_SECS, LEG_MAX_TOKENS,
LEG_THINKING, LEG_THINKING_MAP,
LEG_CONTEXT_WINDOW_TOKENS, LEG_CONTEXT_RESERVE_TOKENS,
LEG_CONTEXT_KEEP_RECENT_TOKENS,
LEG_MAX_TOOL_ROUNDS, LEG_MAX_RETRIES, LEG_RETRY_BASE_DELAY_MS,
LEG_HOOKS_CONFIG, LEG_PRETOOL_HOOK, LEG_SYSTEM_PROMPT, and
LEG_EVENT_LOG.
LEG_TIMEOUT_SECS defaults to 60 and bounds DNS lookup, connection setup,
request sending, response headers, and the total body read for non-streaming
responses. Response-header timeouts are retried on both streaming and
non-streaming request paths up to LEG_MAX_RETRIES. Successful streamed
responses may run longer while data keeps arriving;
LEG_STREAM_IDLE_TIMEOUT_SECS defaults to 120 and limits how long a stream may
remain silent between received chunks.
LEG_MAX_TOKENS sets the output-token limit requested for each provider reply
(default 32000).
LEG_THINKING selects the provider-neutral reasoning level
(off, minimal, low, medium, high, xhigh, or max), defaulting to
high. Leg maps the level through a built-in model capability table and
clamps unsupported levels down to the nearest supported level; it never raises
the requested level. off omits thinking/reasoning fields. For budget-style
models, at least 1,024 output tokens remain available for the visible answer.
For Anthropic’s current Claude family, opus, sonnet, and haiku are
recognized aliases; a trailing [1m] is ignored for capability lookup. The
configured model id is still sent to the provider unchanged.
LEG_THINKING_MAP optionally supplies a JSON object mapping levels to provider
values, for example {"high":"medium"}; null marks a level unsupported.
This is useful for model ids not in the built-in table. An unknown model with
no usable override omits the thinking parameter and emits one warning naming
the model and LEG_THINKING_MAP. For budget-style models, non-null values must
be decimal budgets of at least 1,024; invalid values fail configuration
loading.
Context-window budgeting
LEG_CONTEXT_WINDOW_TOKENS is the model’s context window, in tokens. Set it
explicitly for leg session and session-backed leg exchange; leg does not
infer model limits. A missing or invalid window fails before a provider call.
LEG_CONTEXT_RESERVE_TOKENS defaults to LEG_MAX_TOKENS. The usable input
budget is window - reserve, and reserve must be positive and smaller than the
window. LEG_CONTEXT_KEEP_RECENT_TOKENS defaults to
min(8192, (window - reserve) / 2) and must be positive and smaller than the
usable input budget.
Before each provider request, leg estimates the complete serialized request
(system prompt, tool declarations, messages, and protocol framing) as
ceil(UTF-8 bytes / 3). Provider-reported input_tokens calibrate later
estimates conservatively; missing usage never disables the guard. When a
session crosses its budget, leg summarizes dropped complete turns and keeps a
recent suffix, preserving the active user turn and any complete tool chain.
The internal summary call has no tools and may return at most
min(4096, LEG_CONTEXT_RESERVE_TOKENS) tokens. Leg injects that summary as a
system-context prefix. A provider context-window rejection triggers at most one
compact-and-retry; an active turn that still cannot fit returns
kind: context_window.
Tool results sent to a provider or internal summary are capped at 2,000 source
characters followed by \n[tool result truncated by leg]; the complete result
remains in the append-only session trail. Each compaction adds a
baton.exchange/v1 compaction event before the request that uses it. Resume
uses the latest summary and retained suffix without rewriting earlier trail
lines. Older trails without compaction events remain readable and compact on
demand. Stateless calls keep their one-turn result format; if context settings
are supplied, their tool continuations still use the budget guard.
At the start of every ask, exchange, and session turn, leg discovers project
instructions from AGENTS.md. In a git repository it reads files from the
repository root down to the current working directory, with the nearest file
last; outside a repository it reads only the current working directory’s file.
The resulting system prompt is LEG_SYSTEM_PROMPT’s file content followed by
those files, separated by blank lines. Discovery is refreshed for every turn
and is not stored in session history or trails. The Skills v1
contract defines the separate selectable Skills layer:
AGENTS.md is always-on project instruction, while a Skill is loaded only
after explicit selection and never replaces project instructions.
The Skills v1 catalog discovers packages from project .agents/skills,
.codex/skills, .claude/skills, and .pi/skills roots, walking from the
current directory up to the repository root; without a repository, only the
current directory is checked. All project roots precede user roots. User
roots are ~/.agents/skills, $CODEX_HOME/skills (default ~/.codex/skills),
~/.claude/skills, and ~/.pi/agent/skills. The nearest project directory
wins, followed by that root-kind order. Within a root, normalized relative
paths decide duplicate winners. Catalog warnings use invalid_package,
unreadable_root, unreadable_path, unreadable_skill, invalid_root,
path_escape, package_changed_during_discovery, duplicate_skill, and
catalog_budget_exceeded; missing roots are skipped. Discovery reads only the
bounded YAML frontmatter, never the Skill body or package resources. Catalog
entries are omitted whole to fit the supplied serialized JSON byte budget;
diagnostics remain visible and may exceed a very small budget. Models can call
activate_skill(snapshot_id, name) to load an exact catalog entry, then
read_skill_resource(snapshot_id, name, relative_path) for a package resource.
Successful calls return acknowledgements only; loaded content reaches the model
through the next provider request, while failures return bounded diagnostics.
Activations are turn-local and do not carry into a
later turn or resumed session, so the model must activate again against the
fresh catalog. Complete SKILL.md and resource files are limited to 64 KiB;
resource paths are limited to 512 bytes, and combined loaded Skill content is
limited to 256 KiB per turn. The Skills v1 contract
documents path confinement and context-budget behavior. When context settings
are supplied, Skill metadata and loaded content count toward the input budget;
an activation or resource read that cannot fit fails before adding content.
For example, add a package to a workspace and request it by name:
.agents/skills/release-notes/SKILL.md
---
name: release-notes
description: Draft concise release notes from a change list.
---
Group changes by user-visible effect. Call out breaking changes separately.
leg ask "Use the release-notes Skill to summarize these changes: ..."
Leg first sends the package’s metadata, then loads its instructions only after
the model calls activate_skill with the current snapshot and exact name.
Each new turn, including a resumed session, discovers a fresh snapshot and
starts with no activated body or resources. Precedence is configured
LEG_SYSTEM_PROMPT, project AGENTS.md, the caller’s request, then activated
Skill instructions and resources. Bodies and resources do not enter session
history or trails; catalog metadata and activation outcomes are shown on
stderr, and tool calls and acknowledgements appear in a trail when recording
is enabled.
Anthropic Messages remains the default provider. OpenAI-compatible endpoints can use either streamed wire protocol:
LEG_PROVIDER=openai-chat-completions OPENAI_API_KEY=sk-... leg ask "hello"
LEG_PROVIDER=openai-responses OPENAI_API_KEY=sk-... leg ask "hello"
Both OpenAI protocols share OPENAI_API_KEY and OPENAI_BASE_URL
(https://api.openai.com/v1 by default). OPENAI_BASE_URL may name the API
root or end in /v1; leg appends the selected endpoint. LEG_MODEL is passed
through unchanged and defaults to gpt-4.1-mini for these protocols. OpenAI
credentials and base URL are independent of the Anthropic settings.
Tool loop
ask, session, and exchange share one tool loop. It runs only when a
reply requests tools (stop_reason: tool_use): each call is executed and
its result sent back until the model answers. ask and exchange print only
that final reply; interactive session flushes assistant text as it streams,
while keeping tool arguments and results out of stdout.
LEG_MAX_TOOL_ROUNDS optionally sets a positive round limit; unset or blank is
unbounded. With a configured limit, if the reply still requests tools after
that many rounds, leg sends no further request and warns on stderr. A call
to an unregistered tool is answered with an error result.
Transient provider retries
LEG_MAX_RETRIES sets the number of retries after the first provider request
(default 2; 0 disables retries). LEG_RETRY_BASE_DELAY_MS sets the
exponential-backoff base (default 250 ms). Each retry uses full jitter from
zero through min(base_delay * 2^(retry_number - 1), 30 seconds).
Retry-After values in delay-seconds or HTTP-date form take precedence and are
capped at 30 seconds.
Retries apply to transient connection failures (I/O, timeouts, DNS, connection
establishment, and proxy connection) and HTTP 408, 429, 500, 502, 503, 504, and
529. TLS/protocol setup failures outside those connection classes, other 4xx
responses, response-body read failures, and decode errors are surfaced without
retry. The attempts field on response_ok and response_error trail outcomes
counts all provider-call attempts in that exchange, including retries and calls
across tool-loop rounds; it is absent in older trail records, which remain
readable. stop_reason is present on response_ok when known and on
response_error when a reply hit its token limit; error records preserve the
provider’s raw reason. It is absent in older records and when unknown.
LEG_HOOKS_CONFIG optionally names a hooks v1 JSON file. Otherwise leg checks
.leg/hooks.json in the current working directory, then the per-user config
file. The first available file wins; files are not merged. Each JSON entry has
an ID, one event, an executable path, and optional enabled and tool matcher
fields. Exact tool names and * are supported; selected entries keep file
order, and disabled entries are skipped. Invalid selected files fail
configuration. The full v1 schema, event list, platform paths, precedence,
validation rules, and legacy compatibility are documented in
docs/hooks-v1.md. Project discovery is relative to the
current working directory and does not walk upward to the Git root. Project
hook files are trusted as-is. Hook commands run with the operating-system
permissions of the user running leg.
For example, this project hook runs before each turn:
{
"version": 1,
"hooks": [
{ "id": "audit-turn", "event": "TurnStart", "command": "hooks/audit-turn" }
]
}
Hooks receive one JSON payload on stdin. Turn and tool payloads include the
event name and working directory; session turns also include their session ID
and turn index. A TurnStart payload looks like:
{"hook_event_name":"TurnStart","cwd":"/workspace/project","session_id":"sess-123","turn_index":2,"prompt":"Review the change"}
LEG_PRETOOL_HOOK remains usable as a single pre-tool executable. It receives
the PreToolUse JSON payload on stdin, including hook_event_name, cwd,
tool_name, and tool_input; session_id and turn_index are included when
the call belongs to a session turn.
Exit 0 with empty or whitespace-only stdout, or {"decision":"allow"},
allows the call. Exit 0 with
{"decision":"deny","reason":"..."} denies it and returns that reason to the
model. A non-zero exit, spawn failure, malformed, truncated, or invalid UTF-8
output, 30-second timeout, or cancellation denies with a generic reason.
Declarative hooks run sequentially for the six documented events;
PreToolUse is fail-closed, while other event failures are reported to stderr
without changing the operation result. See docs/hooks-v1.md
for payloads, ordering, and session-resume behavior.
Tools
read— returns a UTF-8 text file’s contents. Args:path(relative to the working directory, or absolute),offset(1-indexed start line), andlimit(max lines). Output is capped at 2000 lines or 50 KB, whichever comes first, keeping whole lines only. When content is withheld, the result ends with a continuation notice such as[Showing lines 1-2000 of 5000. Use offset=2001 to continue.](or[N more lines in file. Use offset=M to continue.]whenlimitstopped early); a result reaching end of file carries no notice.write— writescontentto a file as UTF-8. Args:path(relative to the working directory, or absolute) andcontent. Missing parent directories are created. A new file can always be written, but an existing file can be overwritten only afterreadhas read it successfully in the same run; otherwise the call fails and the file is left untouched. On success it returnsSuccessfully wrote to.edit— replaces an exact string in a UTF-8 file. Args:path,oldString,newString, and optionalreplaceAll(defaultfalse). Matching is exact byte-for-byte text: no regex, no fuzzy or whitespace-tolerant fallback. The call fails, leaving the file untouched, whenoldStringis empty, equalsnewString, is not found, or matches more than once (overlapping occurrences count) withoutreplaceAll. On success it returnsSuccessfully replaced N occurrence(s) in .followed by a unified diff (one line of context) capped at 32 rows; a longer diff ends with... [diff truncated: N more lines].bash— runscommandusingbash -lcin the process working directory, as the caller’s OS user. Optionaldescriptionrecords a short explanation with the tool call. Optionaltimeoutis a non-negative integer number of seconds, defaulting to 120.LEG_BASH_TIMEOUT_SECSsets this default to a positive integer; an explicit per-calltimeouttakes precedence. It runs synchronously; a timeout reports statustimed_outand exit code124, retains output captured before termination, and terminates the shell and descendants (50 ms graceful period, then force termination; Windows uses a job object). It stops draining inherited output pipes after a 2-second guard. Each output stream is captured separately and capped at 2000 lines or 50 KB, whichever comes first, keeping its head and tail and showing the omitted-byte count. The tool result is JSON withwall_time_seconds,status(exitedortimed_out),exit_code,stdout,stderr,stdout_omitted_bytes, andstderr_omitted_bytes. Leg’s provider credential variables are removed from the child environment; login-shell startup files may re-export them. A non-zero command exit is returned asexit_code; a missingbashexecutable is a tool error.
Headless contract for agent callers
A caller driving leg ask or leg exchange sets the working directory:
every tool resolves relative paths against, and bash runs in, the process
cwd. The whole tool loop runs inside the one invocation — tool calls and
results are never written to stdout. On success, ask prints only the final
reply and exchange writes one response. On provider/delivery failure, both
exit non-zero: ask and plain-text exchange leave stdout empty and report
an error on stderr; envelope exchange writes its kind:"error" response
before reporting the failure. To observe tool steps, set LEG_EVENT_LOG on
ask or exchange and read the trail back with leg log show. The bash
tool needs bash on PATH (Git Bash on Windows).
tests/headless_e2e.rs exercises this contract end to end against a fake
provider.
On Unix, SIGINT or SIGTERM during ask, exchange, or session interrupts
the active turn, terminates a running bash process group, records an
interrupted outcome in any enabled trail, writes no further stdout, and
exits 130 or 143 respectively. A second signal exits immediately. An
interrupted session trail can be resumed with leg session --resume; Windows
keeps its default console-control behavior.
Sessions
LEG_CONTEXT_WINDOW_TOKENS=200000 LEG_EVENT_LOG=trail.jsonl ANTHROPIC_API_KEY=sk-... leg session
Runs an interactive multi-turn REPL: each line typed is sent with the retained
conversation context, and assistant text is flushed as it streams. Ctrl-D or a
lone /exit line ends the session cleanly. Every turn (and, with
LEG_EVENT_LOG set, the session’s start/end) is appended to the JSONL trail,
keyed by a session_id minted for the run.
leg session --resume trail.jsonl [--session ]
Reopens a prior session’s trail, rehydrates its retained conversation history —
including each turn’s tool rounds — and continues appending new turns to the
same file. A compacted trail restores its latest summary and retained suffix.
--session selects
which session to resume when the trail holds more than one; it is required
in that case and otherwise optional.
Log replay
leg log show [--file ]
leg log replay [--file ] [--index ]
log show prints every complete exchange in a JSONL trail (--file, or
LEG_EVENT_LOG when omitted), with each tool call made within it and that
call’s result. log replay re-runs one logged exchange’s
prompt — the last one, or --index (1-based) — against the current
environment’s credential, model, and base URL taken from the log entry;
timeout, max tokens, and system prompt still come from today’s environment.
A tool-bearing exchange reruns only its prompt: the current tool loop
executes the tools afresh (stored tool results are never fed back). The
replay’s own request, tool, and outcome lines are appended to LEG_EVENT_LOG
like any other ask.
Between a turn’s request and its outcome, the trail records each dispatched
tool round as a tool_round line (content: the tool_use reply’s blocks,
text included), then each of that round’s calls as a tool_call line
(tool_use_id, tool_name, input), followed by exactly one tool_result
line with the same tool_use_id, a status of completed, failed, or
denied, and the tool’s result or error. All three carry schema and
ts_ms, plus session_id/turn_index on session turns; sessionless
ask/exchange events omit those fields.
On a first-signal interruption, an active tool call gets a failed
tool_result before the turn’s response_error outcome; the interrupted
session trail can therefore be resumed without reusing the failed turn.
Exchange
leg exchange [--in ] [--out ] [--session |--new-session|--new-session-id ] [--session-id-out ] [--stream-json]
Answers one baton.message/v1 request (from --in, or stdin) with exactly
one response (to --out, or stdout); a plain-text request gets the reply
body alone. The tool loop runs inside that single exchange. leg exchange
is the headless entry point for adapters. If LEG_EVENT_LOG is non-blank,
it appends request, tool, and outcome events to the trail for leg log show.
By default, each exchange is cold and independent. Use --new-session to
create a persistent session, --session to continue one, or
--new-session-id to create one under a preallocated native sess-...
ID. The session selection flags are mutually exclusive. The preallocated ID
must not already have a trail; creation refuses an existing trail atomically.
--session-id-out may accompany any session selection and writes the
session id plus a newline after the turn. Each session is stored as
.jsonl in $LEG_SESSION_DIR, or $XDG_STATE_HOME/leg/sessions, or
~/.local/state/leg/sessions when neither variable is set. The store directory
is created when a new session is started. A missing id fails before contacting
the provider with leg: no session found: .
Pass --stream-json to write a flushed NDJSON feed using the
leg.exchange.stream/v1 schema. This mode always writes to stdout and cannot
be combined with --out. With --session, --new-session, or
--new-session-id,
--session-id-out remains available and writes that id after the turn. Each
line has schema, event, and a zero-based increasing
seq. Events are turn_start, text_delta, tool_round, tool_call,
tool_result, and turn_end. Provider-round and content-block indices start
at zero. Session records also carry session_id and turn_index. Stdout is
the NDJSON stream; human-readable warnings are written to stderr.
Example output (each line is one JSON value):
{"schema":"leg.exchange.stream/v1","event":"turn_start","seq":0,"provider":"anthropic","model":"claude-test","request":{"schema":"baton.message/v1","message_id":"m-1","conversation_id":"c-1","from":"external","to":"leg","in_reply_to":null,"kind":"request","body":"hello","ts_ms":1,"exchange":null}}
{"schema":"leg.exchange.stream/v1","event":"text_delta","seq":1,"round_index":0,"block_index":0,"text":"Hello"}
{"schema":"leg.exchange.stream/v1","event":"turn_end","seq":2,"capped":false,"response":{"schema":"baton.message/v1","message_id":"c-1-r-2-0","conversation_id":"c-1","from":"leg","to":"external","in_reply_to":"m-1","kind":"response","body":"Hello","ts_ms":2,"exchange":{"schema":"baton.exchange/v1","exchange":{"request":{"ts_ms":1,"model":"claude-test","base_url":"https://api.anthropic.com","prompt":"hello"},"outcome":{"event":"response_ok","ts_ms":2,"duration_ms":1,"reply":"Hello","input_tokens":1,"output_tokens":1,"stop_reason":"end_turn","attempts":1}}}}}
turn_start.request and turn_end.response are the correlated message
envelopes; the terminal response is authoritative. Text deltas are provisional
and are not repeated as a second final-text event. turn_end.capped is true
only when LEG_MAX_TOOL_ROUNDS stops a turn while the provider still requests
tools. Provider failures and capped turns still end with a terminal envelope.
Unix cancellation attempts an interrupted terminal record while stdout is
writable; a broken pipe stops further tool dispatch and exits non-zero. EOF
without turn_end means the exchange is incomplete. No records follow
turn_end. Unknown optional fields and events may be ignored by consumers of
this schema version. See the stream contract for
tool-event fields, session rules, and complete error/truncation behavior.
Development checks
CI uses Rust 1.89.0 with the rustfmt and clippy components. Install that
toolchain and run the same lint checks locally:
rustup toolchain install 1.89.0 --component rustfmt --component clippy
cargo +1.89.0 fmt --all -- --check
cargo +1.89.0 clippy --locked --all-targets -- -D warnings
CI-supported targets
- x86_64-unknown-linux-musl (static; cross-compiled)
- aarch64-unknown-linux-musl (static; cross-compiled)
- x86_64-apple-darwin
- aarch64-apple-darwin
- x86_64-pc-windows-msvc
- armv7-unknown-linux-musleabihf (cross-compiled; build-only, no native runner)
Linux release binaries use musl and do not require a minimum glibc version.
Recommended Tools
Try a different keyword or remove a filter.
Install
npx skillfish add shuke-labs/leg