The thirty-second pitch
An agent loop does not cost a flat amount per step, and it does not always know
when to quit. A framework's max-steps setting is in-memory and resets on
restart. A gateway's budget cap is a per-key total, not a per-run one. leash is
the missing piece: durable per-run accounting that a restart cannot reset,
plus a compute meter for self-hosted agents whose real bill is machine time.
leash is one Go binary and one idea: sit in front of the model calls, count what
the wire actually reports, and refuse the next call the moment a boundary trips.
The refusal is an ordinary HTTP 429 with a machine-readable reason, so the
agent's own loop ends because its next call fails. No SDK to adopt, no servers,
no YAML. The core is the standard library plus one dependency.
$ leash --max-cost 5.00 --prices prices.json -- python my_agent.py
... agent runs, every model call metered and journaled ...
leash: stopped run a3f9 after 18 calls, $4.10 tokens + $0.91 compute = $5.01 (cost_budget)
$ echo $?
3
If leash is killed uncleanly mid-run and restarted on the same database, the run
resumes with its totals intact: a run that was over budget stays stopped, and no
call is ever counted twice.
New to durable spend control? docs/how-leash-works.md
is a concept-to-code tour: the enforcement decision, the journal that is the
source of truth, and how a crash resumes without double counting.
How it works
Every model call passes through one decision. leash rebuilds the run's totals by
folding its durable journal, evaluates the boundaries in a fixed order, and
either refuses the call or forwards it and records what it cost.
flowchart TD
A["incoming model call"] --> B["resolve run id<br/>X-Loop-Id, or the wrapper default"]
B --> C["fold the journal<br/>rebuild totals from the ledger"]
C --> D{"already stopped,<br/>or a boundary trips?"}
D -->|yes| E["HTTP 429 leash_boundary<br/>do not forward, do not spend"]
D -->|no| F["forward to the upstream"]
F --> G["tee the response to the client byte for byte<br/>meter usage on the side"]
G --> H["append one journal entry<br/>usage, fingerprint, timestamp"]
H --> I["the next call is judged against it"]
The journal is the source of truth; in-memory state is only a cache of it. A
call is counted exactly when its journal entry exists, which is why a crash can
undercount by at most the one call in flight, and can never double count.
What you persist is not a running total, but the result every completed call
produced. With the per-call records in hand, a restart rebuilds the exact
same totals by replaying them.
Mental models
Three ways to hold the idea. Pick whichever sticks.
1 - The ledger is the budget; the process is a cache. The authoritative
account is on disk, one append-only entry per governed call. The running proxy
holds a convenient copy, but if it dies, the truth is untouched and the next
process rebuilds the copy by folding the journal.
2 - Stopping is a fact you write down, not a flag you hold. When a boundary
trips, leash appends a stop entry naming the reason and freezing the totals.
From then on, every call for that run - in this process or the next one - reads
that entry and returns the same 429. The stop outlives the process that decided
it.
3 - leash meters the wire, never a guess. Token counts come only from what
the provider reports (usage in JSON, a final chunk or message_delta in a
stream). If the wire says nothing, the token meter is blind for that call. Under
a cost budget that is the dangerous case, so leash fails closed by default:
it refuses rather than forward spend it cannot measure (--on-blind=warn
restores the old count-zero-and-continue). With no cost budget, a blind call is
harmless, and leash leans on the boundaries that need no token counts (calls,
time, stall, kill).
Install
A prebuilt static binary from the releases
(linux, darwin, windows on amd64 and arm64). Every release is cosign-signed and
ships an SBOM and SLSA build provenance, so you can verify a download before you
run it (see verifying a release):
# adjust the version, OS, and arch:
curl -sSL -o leash.tar.gz \
https://github.com/sylvester-francis/leash/releases/download/v0.2.5/leash_0.2.5_linux_amd64.tar.gz
tar xzf leash.tar.gz && ./leash version
Or with the Go toolchain:
go install github.com/sylvester-francis/leash/cmd/leash@latest
Or build from a checkout:
git clone https://github.com/sylvester-francis/leash
cd leash && make build # produces ./leash
leash needs Go 1.25+ and builds as a single static binary with no C toolchain
(the SQLite ledger is pure Go via modernc.org/sqlite).
Docker
A distroless, nonroot image; the compose file runs a no-key demo against the fake
upstream (the gateway is capped so a boundary trips end to end):
docker run --rm -p 8088:8088 -v leash-data:/data \
ghcr.io/sylvester-francis/leash:latest \
serve --listen :8088 --db /data/leash.db --max-cost 20
docker compose up --build # from a checkout: gateway + fake upstream
The 60-second demo
No API key, no real spend: a fake provider plus a curl loop under a 10-cent
budget. From a checkout:
# 1. A standard-library fake provider that always reports usage.
go run ./examples/fakeupstream &
# 2. Prices are yours to supply; leash ships none.
echo '{"demo-model": {"input": 10.0, "output": 30.0, "reasoning": 0}}' > prices.json
# 3. A 10-iteration agent under a 10-cent budget.
go run ./cmd/leash \
--max-cost 0.10 --prices prices.json \
--upstream http://127.0.0.1:9099 --db ./demo.db --run demo -- \
sh -c 'for i in $(seq 1 10); do
curl -s "$OPENAI_BASE_URL/chat/completions" -d "{\"model\":\"demo-model\"}" \
-o /dev/null -w "agent call $i -> HTTP %{http_code}\n"
done'
Real captured output (each call is 2.5 cents, so the budget trips on the fifth):
agent call 1 -> HTTP 200
agent call 2 -> HTTP 200
agent call 3 -> HTTP 200
agent call 4 -> HTTP 200
agent call 5 -> HTTP 429
agent call 6 -> HTTP 429
agent call 7 -> HTTP 429
agent call 8 -> HTTP 429
agent call 9 -> HTTP 429
agent call 10 -> HTTP 429
leash: stopped run demo after 4 calls, $0.10 tokens + $0.00 compute = $0.10 (cost_budget)
Three front doors, one engine
leash is the same governor behind three surfaces. See
docs/wrapping-agents.md and
docs/gateway.md for the full guides.
Tier 1 - wrap (the headline). Zero code change. leash starts an embedded
proxy on a free port, sets the base-url variables the SDK already reads, runs
your command as a child, governs every call, prints one stop line, and exits
with the child's code (or 3 on a boundary stop):
leash -- python my_agent.py
Tier 2 - serve (the gateway). A standalone proxy for any language, CI, or a
shared team gateway. Point the agent's base_url at leash and tag each run with
an X-Loop-Id header so it gets its own durable budget:
LEASH_AUTH_TOKEN=$(leash gen-token) leash serve --listen :8088 --max-cost 5.00 --prices prices.json
serve requires a token by default (clients send X-Leash-Token; use
--insecure for a trusted local socket), and with auth on each run is scoped to
the presenting credential. On a shared gateway, add --require-run-id so untagged traffic is refused rather
than pooled into one budget, and --admin :9090 for /healthz, /readyz, and a
Prometheus /metrics endpoint on a separate listener. See
docs/deployment.md.
When a run stops or nears a budget, --webhook posts a JSON event; add
--reactions-db (a store separate from --db) to make that reaction durable.
The webhook and an --on-event-exec command hook then run as a crash-surviving,
retried rerun workflow off the enforcement path, delivered at-least-once. leash
ships no connectors; the command hook reaches yours. See
docs/cli-reference.md.
The boundaries
Evaluated in this fixed order; the first to trip stops the run. A zero value
disables a boundary; the kill switch is always active. The rate limit is the one
exception: it is recoverable backpressure, so a rate-tripped call is refused with
Retry-After and the run resumes once the window decays, rather than stopping for
good. Full detail and gotchas in docs/boundaries.md.
| Order |
Boundary |
Flag |
Trips when |
| 1 |
kill switch |
(leash kill <run>) |
a durable kill was recorded for the run |
| 2 |
deadline |
--deadline |
wall-clock since the first call reaches the limit |
| 3 |
cost budget |
--max-cost |
token cost plus compute cost reaches the budget |
| 4 |
max calls |
--max-calls |
the run has made that many governed calls |
| 5 |
rate limit |
--rate tokens/window |
tokens in the trailing window exceed the max |
| 6 |
stall |
--stall |
that many identical responses occur in a row |
The cost model: two meters, one budget, your prices
The token meter uses a price table you supply with --prices, mapping each
model to dollars per million input, output, and reasoning tokens:
{"gpt-4o": {"input": 2.5, "output": 10, "reasoning": 0}}
Optional per-model rates refine that when you need them, each falling back to a
coarser rate so a plain input/output table is unchanged: cache-read/write and
per-TTL cache rates, audio rates, per-request charges for provider-side tools
(web_search_per_request / web_fetch_per_request), and per-service-tier
overrides under tiers. leash reads the matching wire fields (reasoning/thinking
and audio tokens, the Anthropic cache-TTL split, the service tier, and server-tool
counts) and prices each subset exactly once. See
docs/cost-model.md.
An unknown model or an absent table makes that call's token cost blind. leash
never hardcodes a price and never estimates tokens, so under a cost budget it
refuses an unpriceable call by default (fail closed); --on-blind=warn keeps the
old count-zero-and-continue behavior. The same holds for billed activity it cannot
price, such as a provider-side web search with no per-request rate: it fails closed
(server_tool_unpriced) until you price it.
The compute meter is elapsed wall-clock time times --compute-rate (dollars
per hour, default zero) - the meter for a self-hosted agent whose real cost is
machine time, not tokens. Both meters feed one budget: --max-cost trips on
their sum. Details and worked examples in docs/cost-model.md.
The stop signal
A stopped call is refused with HTTP 429 and a machine-readable body, and every
later call for that run gets the same answer:
{"error":{"type":"leash_boundary","reason":"cost_budget","run":"a3f9",
"calls":18,"token_cost":4.10,"compute_cost":0.91,"total_cost":5.01}}
In wrapper mode leash also prints the one-line stop summary to stderr and exits
with code 3, so a script can tell a governed stop from an ordinary child
failure.
Durable accounting
Every governed call, kill, and stop is appended to a durable ledger (SQLite by
default at $HOME/.leash/leash.db, set with --db). Totals are rebuilt by
folding that journal. The guarantee, and it is tested: kill the proxy uncleanly
mid-run, start a new process on the same --db, and the run resumes with totals
intact; an over-budget run stays stopped; no entry is double counted.
sequenceDiagram
autonumber
participant A as Agent
participant L as leash
participant J as Journal (durable)
A->>L: call 1..N
L->>J: append call-0 .. call-(N-1)
Note over L: CRASH - process dies uncleanly
Note over L,J: new process opens the same --db
A->>L: next call
L->>J: LoadLogs, fold -> totals rebuilt (N calls)
L-->>A: over budget -> 429, stays stopped
Inspect the ledger from any process while a run is live:
$ leash ps
RUN CALLS TOKENS$ COMPUTE$ TOTAL$ STATUS REASON
demo 4 0.10 0.00 0.10 stopped cost_budget
$ leash inspect demo
run demo status stopped calls 4
cost $0.10 tokens + $0.00 compute = $0.10
tokens in 4000 out 2000 reasoning 0
SEQ TAG WHEN DETAIL
0 call-0 2026-07-03T15:08:05-04:00 demo-model in=1000 out=500 reasoning=0
...
4 stop 2026-07-03T15:08:05-04:00 stop: cost_budget
$ leash kill demo
leash: kill recorded for run demo; it stops on its next call
Privacy is a property of what the ledger stores: usage numbers, content
fingerprint hashes, timestamps, and stop reasons only. It never persists request
or response bodies, and Authorization and api-key headers are forwarded to the
upstream untouched and never logged or persisted. See
docs/durability.md.
Cross-process governance is available too: point --db at Postgres
(--db postgres://...) for a real cross-process lease, and run a second instance
with --standby for active/passive failover.
The governor caches each run's folded state so per-call overhead does not grow
with the journal. Measured end to end through httptest against a std-lib fake
upstream, before and after the warm-path cache (per-call, ns/op):
| pre-seeded journal |
before (full reload) |
after (warm cache) |
| 0 |
576025 |
212596 |
| 100 |
791254 |
164784 |
| 1000 |
2812566 |
203661 |
| 10000 |
23445414 |
290798 |
Before, per-call cost grows linearly with journal size (quadratic over a run);
after, it is flat. Allocations at journal 10000 dropped from 283086 to 1181.
These are synthetic and local: Apple M4 Pro, go1.25.6 darwin/arm64, measured
numbers only. Reproduce with make bench.
Metering the wire
leash keys on the wire format, not the model name, so a new model version needs
no code change (add a price-table row) and any endpoint speaking a format leash
knows is governed. "OpenAI-compatible" is the ecosystem's common tongue, so that
one format already covers Gemini and Ollama (through their OpenAI-compatible
endpoints), OpenRouter, Groq, Together, vLLM, and more. leash reads usage from
real responses, in three formats and both shapes:
- OpenAI-compatible: non-streaming
usage.prompt_tokens /
completion_tokens / completion_tokens_details.reasoning_tokens. Streaming
reports usage only in a final chunk, and only when the request set
stream_options.include_usage.
- Anthropic: non-streaming
usage.input_tokens / output_tokens. Streaming
carries input tokens in message_start and cumulative output tokens in
message_delta.
- Gemini (native
generateContent): usageMetadata.promptTokenCount /
candidatesTokenCount / thoughtsTokenCount / cachedContentTokenCount, with
a cumulative usageMetadata on the SSE stream. Gemini's OpenAI-compatible
endpoint is metered as OpenAI.
For streaming OpenAI requests, leash rewrites the body to set
stream_options.include_usage=true so the final chunk reports usage; turn it off
with --no-inject and accept a blind meter on those calls. Streaming responses
are teed to the client byte for byte as they arrive - leash never buffers a
whole stream. See docs/metering.md.
Flags
All boundaries default to safe, honest values and are overridable; a zero value
disables that boundary. Full reference: docs/cli-reference.md.
--max-cost dollar budget over token + compute cost (default 5.00)
--max-calls maximum governed calls (default 100)
--deadline wall-clock budget from the first call (default 30m)
--rate trailing token rate as tokens/window (default off)
--stall identical responses tolerated in a row (default off)
--prices path to a JSON price table (default none)
--compute-rate compute meter in dollars per hour (default 0)
--on-blind unpriceable call: refuse, warn, or allow (default refuse)
--warn-at warn at this fraction of a budget (default 0.8)
--upstream upstream base URL override (default inferred)
--db ledger database path (default ~/.leash/leash.db)
--run run name; reuse it to resume that budget (default random)
--no-inject do not add stream_options.include_usage (default off)
--log-level debug, info, warn, error (default info)
--log-format text or json (default text)
--listen serve address (leash serve only) (default :8088)
--admin admin listener: healthz, readyz, metrics (serve only, default off)
--require-run-id refuse requests with no X-Loop-Id (serve only, default off)
--standby wait for the lease; active/passive HA (serve only, default off)
--auth-token require a matching X-Leash-Token header (serve only, default off)
--max-runs cap concurrently-tracked runs (503 over) (serve only, default 0)
--webhook POST a JSON event on warn or stop (serve only, default off)
--reactions-db durable reactions store, separate from db (serve only, default off)
--on-event-exec command hook on warn or stop (LEASH_* env) (serve only, default off)
Every shared flag also reads a LEASH_-prefixed environment variable
(--max-cost reads LEASH_MAX_COST, and so on); an explicit flag wins. The
default cost budget is active only when a price table or compute rate makes a
meter live; without one the token meter is blind, and under a cost budget leash
fails closed by default (--on-blind).
Design
Dependencies point one way only, inward toward the pure core. The policy core
knows nothing about HTTP, the ledger, or the clock beyond the timestamps handed
to it, so every decision it makes is reproducible from recorded inputs.
flowchart TD
CLI["cmd/leash<br/>run - serve - ps - inspect - kill"] --> Wrap["internal/wrap<br/>child launch, env, signals"]
CLI --> Proxy["internal/proxy<br/>enforcement, streaming, redaction"]
Wrap --> Proxy
Proxy --> Meter["internal/meter<br/>usage parsing, JSON and SSE"]
Proxy --> Ledger["internal/ledger<br/>durable journal on rerun Store"]
Proxy --> Policy["internal/policy<br/>cost math, state, boundaries"]
Meter --> Policy
Ledger --> Policy
Ledger -.->|Store| Rerun["github.com/sylvester-francis/rerun<br/>SQLite (pure Go) / Postgres"]
Each package changes for one reason: policy is the pure guarantee (cost,
state, boundaries); meter reads provider wires; ledger persists and replays;
proxy enforces per call; wrap runs the child; cmd/leash is the surface.
Repository layout:
leash/
cmd/leash/ CLI: dispatch + the governance subcommands
internal/policy/ pure core: cost.go, state.go, boundary.go, report.go
internal/meter/ provider.go, parse.go, stream.go, inject.go
internal/ledger/ durable journal on rerun's Store (SQLite default)
internal/proxy/ proxy.go (enforcement), transport.go (headers, tee)
internal/reactions/ durable escalations on rerun's execution layer
internal/wrap/ child launch, base-url injection, signals, exit codes
examples/fakeupstream/ a std-lib fake provider for the demo
tools/doccheck/ std-lib AST walker enforcing godoc on exported symbols
Testing
make test # go test ./...
make race # the concurrent paths, race-clean
make vet
make ascii-check # fail on any non-ASCII byte in .go or .md
make doc-check # fail on any undocumented exported symbol
make fuzz # fuzz the three parsers (short duration)
make bench # governed-call, fold, and stream-meter benchmarks
make mutate # mutation testing on the deterministic core
The suite covers each boundary end to end against a fake upstream, OpenAI,
Anthropic, and Gemini parsing (non-streaming and streaming, including the injected
include_usage path and the --no-inject blind path), the 429 body shape,
header redaction, an unclean-crash resume with no double count, a kill from a
second process, and a wrapper run where a looping child is cut off at a boundary
and leash exits 3.
The invariants are also checked as properties over random inputs with the
standard library's testing/quick: the fold is deterministic, cost is monotonic,
and a call is counted at most once through arbitrary retries. A fault-injection
harness breaks the ledger at each boundary to prove the fail-closed path, and the
Windows single-governor lock is exercised on real Windows in CI.
The parsers, which are the attack surface, are also fuzzed with native Go
fuzzing: the JSON and SSE meters never panic on any input, and the stream tee is
asserted byte-identical to its source. A nightly workflow runs mutation testing.
Mutation testing uses gremlins over
internal/policy and internal/meter, gated at 90% efficacy. The last measured
runs: policy 96.23%, meter 96.00% (Apple M4 Pro, go1.25.6). These figures
are measured, not claimed; rerun them with make mutate.
Guarantees and non-goals
leash is v0.x and unstable - flags and formats may change between versions.
What it guarantees: per-run accounting is durable and resumable. A
run's totals survive a crash, an over-budget run stays stopped after a restart,
and no journal entry is double counted. Token counts are exactly what the wire
reported - never estimated. A call is counted at-most-once: the only way to
lose one is a crash in the narrow window after the upstream responds and before
the journal entry commits, which undercounts by one and never re-spends.
What it cannot see: whether your agent achieved its goal. leash bounds the
economics of a loop; it does not judge the work.
Non-goals (deliberately not built): no model routing, no dashboard or web UI,
no provider SDK dependencies, no hosted anything, no request/response body
persistence, no end-user identity auth (token auth and per-credential run
isolation are built in, but caller identity belongs at an ingress or mesh), no
TLS termination (front it with an ingress), no OpenTelemetry exporter (a
Prometheus /metrics endpoint and an observer seam are there), no per-run-id
metric labels, no journal retention yet (see docs/operations.md), and no YAML -
flags and LEASH_* environment variables only. See
docs/known-issues.md for the deferred roadmap.
Documentation
docs/getting-started.md - install and your first governed run, both tiers.
docs/how-leash-works.md - the enforcement model and durability, concept to code.
docs/boundaries.md - every boundary, when to reach for it, and its edges.
docs/cost-model.md - price tables, the two meters, and the blind path.
docs/wrapping-agents.md - Tier 1 in depth, with Python and Node examples.
docs/gateway.md - Tier 2 as a sidecar or team gateway, and X-Loop-Id.
docs/metering.md - streaming, usage injection, and the wire formats.
docs/durability.md - the ledger, crash safety, ps/inspect/kill, Postgres and standby.
docs/deployment.md - systemd, Docker, Kubernetes sidecar, health checks, and metrics.
docs/security-model.md - trust boundaries, what is and is not persisted, hardening.
docs/operations.md - backups, ledger sizing, monitoring, failover, and retention.
docs/cli-reference.md - every command, every flag, every exit code.
docs/examples.md - copy-paste recipes by task; runnable demos in examples/.
docs/known-issues.md - bounded behavior and the deferred roadmap, stated plainly.
docs/faq.md - honest answers, including what leash is not.
ARCHITECTURE.md - the layering, the request path, the invariants, and where to change things.
docs/adr/ - architecture decision records: why leash is built the way it is.
COMPATIBILITY.md - what is treated as a stable contract, and what is not.
CONTRIBUTING.md - how to build, test, and propose changes.
License
Apache License 2.0. leash depends on
rerun for its durable ledger.