Docs
LEASH documentation
Everything you need to put your agents behind LEASH: set up, policies, the proxy, the CLI and the API.
Quickstart
- Create an account at /app with a passkey. No password or email.
- Sign in the CLI and approve the code it shows in the app:
npx leashcli login
- Vault a key. Paste it once; it is encrypted and never written to disk:
npx leashcli add github prod
- Mint a token for your agent (or do it in the app under Agent tokens):
npx leashcli creds # find the key id npx leashcli token <key-id> --label coding-agent
- Connect your agent. Add LEASH to your MCP client's config (see below).
Concepts
| Term | What it is |
|---|---|
| Vault | Where your real API keys live, encrypted. Keys go in and never come out. |
| Agent token | An lsh_ token tied to one vaulted key, with a policy and an expiry (7 days by default, up to 90). This is all your agent ever sees. |
| Irreversible map | A versioned list, per provider, of calls that can't be undone. Calls on it are held. |
| Hold | A call LEASH stopped and is waiting on you to approve or deny. |
| Approval | Your passkey signing off on one held request. It lets that exact request through once, within ten minutes. |
| Audit log | An append-only, hash-chained record of every call and decision on your account. |
Connecting your agent (MCP)
npx leashcli mcp runs a local MCP server that gives your agent a request tool for each provider in LEASH_TOKENS. Add it to any MCP client's server config. List several providers separated by commas:
{
"mcpServers": {
"leash": {
"command": "npx",
"args": ["-y", "leashcli", "mcp"],
"env": { "LEASH_TOKENS": "github=lsh_aaa,stripe=lsh_bbb" }
}
}
}
The tool tells the agent that held calls need a human, so a well-behaved agent stops and gives you the approval link instead of looking for another way in. Any MCP client works the same way.
Calling through the proxy
Any agent, script or SDK can use LEASH directly. Swap the provider's base URL for the LEASH proxy and the real key for your token:
https://leash.gautamkhosla.com/p/<provider>/<path> Authorization: Bearer lsh_...
curl https://leash.gautamkhosla.com/p/github/repos/acme/app/pulls \ -H "Authorization: Bearer lsh_..."
LEASH checks the call, swaps your token for the real key, forwards it to the provider and returns the response unchanged, plus an x-leash-decision header (allowed or approved). Cookies from upstream are stripped.
When a call is held
A held call returns HTTP 428:
{
"error": "held_for_approval",
"hold_id": "4f2a...",
"approve_url": "https://leash.gautamkhosla.com/app#hold=4f2a...",
"rule": "rw.mutation",
"message": "LEASH held this request because it cannot be undone..."
}
Open the link, check the method, host, path, reason and the request preview, and approve with your passkey. Then retry the exact same request once. Run npx leashcli watch to get a terminal bell and link for every new hold.
Token policies
Each token can carry a policy that narrows what its key can do. A policy can never widen it.
{
"allow": [{ "method": "GET", "path": "/repos/acme/**" }],
"deny": [{ "path": "/orgs/**" }],
"hold": [{ "method": "POST", "path": "/repos/*/*/releases" }],
"default": "allow",
"readOnly": false,
"perMinute": 120,
"unattendedIrreversible": []
}
| Field | Meaning |
|---|---|
allow | If present, only these calls pass. |
deny | Always refused with 403. Checked first. |
hold | Held for approval even if the map doesn't require it. |
default | allow, hold or deny for calls that match no rule. |
readOnly | Only GET and HEAD. |
perMinute | Rate limit for this token, 1 to 600. Default 120. |
unattendedIrreversible | Irreversible calls allowed without approval. Can only be set with your passkey, in the app. |
A rule is { "method": "GET", "path": "/glob" }. * matches one path segment, ** any depth, {a,b} alternatives. Paths are relative to the provider's API base. Evaluation order: deny, readOnly, the irreversible map, hold, allow, default.
Recipes
Read-only access to one GitHub org:
{ "readOnly": true, "allow": [{ "path": "/repos/acme/**" }, { "path": "/orgs/acme/**" }] }
Hold every write to Stripe, not just the money-moving ones:
{ "allow": [{ "method": "GET", "path": "/v1/**" }], "default": "hold" }
Providers and the map
LEASH supports GitHub, Cloudflare, Railway, Stripe and Supabase. The current irreversible map, with the reason for every rule, is on the homepage and at GET /v1/meta. Every DELETE is held on every provider, plus provider-specific calls such as force pushes, repo transfers, Railway volume deletes, Stripe refunds and payouts, Cloudflare cache purges, and any SQL on Supabase or D1 that is not a single plain read (SELECT, WITH ... SELECT, EXPLAIN without ANALYZE, SHOW). Railway mutations are held unless every field is a redeploy or restart. Paths are matched in one canonical form (no empty segments, one trailing slash dropped, GitHub owner and repo lowercased) and that same path is sent upstream. A body LEASH cannot read on a route with a body rule is held.
CLI
| Command | What it does |
|---|---|
leash login | Sign in this machine. You approve the code in the app. |
leash logout | Forget the session on this machine. |
leash add <provider> [label] | Vault a key. Prompts for it; never written to disk. |
leash creds | List vaulted keys (id, provider, label, last four). |
leash token <key-id> [--label x] [--ttl hours] [--policy file.json] | Mint an agent token. |
leash tokens | List active tokens. |
leash revoke <token-id> | Revoke a token immediately. |
leash holds | List calls waiting for you. |
leash watch | Print and ring for each new hold. |
leash mcp | Run the MCP server for your agent. |
Run any command with npx leashcli, or install it globally with npm i -g leashcli.
API
Base URL https://leash.gautamkhosla.com. Account endpoints use your session (web cookie or CLI bearer). Web writes need the same Origin and an x-leash: 1 header. Endpoints marked passkey only work from the web app.
| Endpoint | Purpose |
|---|---|
GET /v1/meta | Providers and the irreversible map. Public. |
GET /v1/me | Your account. |
GET, POST /v1/credentials | List or add vaulted keys. |
DELETE /v1/credentials/:id | Delete a key. Its tokens stop working. Web session only. |
GET, POST /v1/tokens | List or mint agent tokens. |
POST /v1/tokens/passkey/begin, /finish | Mint a token with pre-approved irreversible operations. Passkey. |
DELETE /v1/tokens/:id | Revoke a token. |
GET /v1/holds | Calls waiting for you, each with a preview of what it would do (query string, SQL or GraphQL, body; at most 2 KB). |
POST /v1/holds/:id/approve/begin, /finish | Approve a hold. Passkey. |
POST /v1/holds/:id/deny | Deny a hold. |
GET /v1/audit, GET /v1/audit/verify | Read the audit log; check the hash chain. |
POST /v1/me/delete/begin, /finish | Delete your account and all its data. Passkey. Finish body {"confirm":"delete", challengeId, credential}. Web session only. |
ANY /p/:provider/* | The agent proxy. Bearer lsh_ token. |
Errors
| Status | Error | Meaning |
|---|---|---|
| 401 | leash_token_required, token_invalid | Missing, expired or revoked token. |
| 403 | leash_denied | A policy deny rule or readOnly blocked the call. |
| 403 | wrong_provider | The token belongs to a different provider. |
| 403 | passkey_required | Needs the web app and your passkey. |
| 403 | csrf | Web write without the right Origin or x-leash header. |
| 413 | too_large | Request body over the limit. |
| 428 | held_for_approval | Irreversible. Approve, then retry once. |
| 429 | slow_down | Too many requests. The message says when to retry. |