Docs

Counterseal documentation

Three things, one service. Approve: irreversible calls wait for your passkey. Record: every decision becomes a receipt in a public log anyone can verify. Prove who: an agent signs its own requests, so a stolen token alone is useless.

Quickstart

  1. Create an account at /app with a passkey. No password or email.
  2. Sign in the CLI. It shows a code; open /app#device, type the code, check where the sign-in started, and approve with your passkey. Never type a code someone sent you:
    npx counterseal login
  3. Vault a key. Paste it once; it is encrypted in the vault and never saved on your machine:
    npx counterseal add github prod
  4. Mint a token for your agent (or do it in the app under Agent tokens):
    npx counterseal creds                      # find the key id
    npx counterseal token <key-id> --label coding-agent
  5. Connect your agent. Add the MCP server to your agent's config (see below).
You can also do all of this in the app without the CLI. The CLI is just faster. From version 0.3.0 the CLI package is called counterseal; leashcli, the product's earlier name, keeps working as an alias that runs it; it is the same service.
Counterseal lives at counterseal.gautamkhosla.com only. Its earlier address, leash.gautamkhosla.com, answers every request (pages, /app, /v1, /p) with a permanent redirect (301) to the same path here and serves nothing itself. Point agents and command lines at counterseal.gautamkhosla.com: leashcli 0.1.1 does not follow redirects. Passkeys made on the earlier address do not work here; make a new account.

Concepts

TermWhat it is
VaultWhere your real API keys live, encrypted. Keys go in and never come out.
Agent tokenAn lsh_ token tied to one vaulted key, with a policy and an expiry (7 days by default, up to 90; one minted from the command line ends with its sign-in). This is all your agent ever sees.
Irreversible mapA versioned list, per provider, of calls that can't be undone. Calls on it are held.
HoldA call the broker stopped, or a command a coding agent's guard asked about, waiting on you to approve or deny.
Guard keyAn lsg_ key the guard on a coding agent's machine uses to ask for approvals. It can ask and read the answer; it can never approve.
ApprovalYour passkey signing off on one held request. It lets that exact request through once, within ten minutes.
Audit logYour private, append-only, hash-chained record of every write, hold and decision on your account.
ReceiptA public entry in the log for one decision: method and provider host, the decision, the rule, the time, and the hash of your audit entry. Never the path or the body.
Agent keyAn Ed25519 key pair your agent holds. You register the public half; tokens bound to it only work on requests it signed.

Connecting your agent (MCP)

npx counterseal mcp runs a local MCP server that gives your agent one request tool, leash_request, that can call every 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", "counterseal", "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. For an API that takes a form instead of JSON (Stripe), the tool has a form field next to body: {"charge": "ch_...", "amount": 2500} is sent as application/x-www-form-urlencoded (from leashcli 0.3.0).

Calling through the proxy

Any agent, script or SDK can call the broker directly. Swap the provider's base URL for the proxy and the real key for your token:

https://counterseal.gautamkhosla.com/p/<provider>/<path>
Authorization: Bearer lsh_...
curl https://counterseal.gautamkhosla.com/p/github/repos/acme/app/pulls \
  -H "Authorization: Bearer lsh_..."

The broker checks the call, swaps your token for the real key, forwards it to the provider and returns the response with an x-leash-decision header (allowed or approved). Upstream cookies, CORS grants and browser policies are dropped, the answer is marked no-store with a sandboxing CSP, and an answer over 10 MB is refused. Redirects are never followed.

When a call is held

A held call returns HTTP 428:

{
  "error": "held_for_approval",
  "hold_id": "4f2a...",
  "approve_url": "https://counterseal.gautamkhosla.com/app#hold=4f2a...",
  "rule": "rw.mutation",
  "message": "Counterseal 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. Turn on phone alerts in the app, or run npx counterseal watch for a terminal bell and link for every new hold (leashcli 0.1.1 calls leash.gautamkhosla.com, which now only redirects, and it does not follow redirects).

Approvals for a coding agent's commands

Some irreversible actions never pass through an API the broker holds a key for: terraform destroy, railway volume delete, gh repo delete, prisma migrate reset, DROP TABLE through psql, git push --force. For those, a guard on the agent's machine asks the Approvals API with a guard key and waits for your passkey:

curl -s https://counterseal.gautamkhosla.com/v1/approvals \
  -H "authorization: Bearer lsg_..." -H 'content-type: application/json' \
  -d '{"kind":"shell","agent":"claude-code","rule":"sh.terraform-destroy",
       "why":"Destroys every resource in the state.","summary":"terraform destroy",
       "detail":"terraform destroy -auto-approve","context":{"cwd":"infra"}}'
# 202 {"id":"...","status":"pending","hash":"...","approve_url":"...","expires_at":...}

curl -s "https://counterseal.gautamkhosla.com/v1/approvals/<id>?wait=20" -H "authorization: Bearer lsg_..."
# {"status":"approved"} or "denied" (with "deny_reason"), "expired", "pending"

curl -s https://counterseal.gautamkhosla.com/v1/approvals/<id>/use \
  -H "authorization: Bearer lsg_..." -H 'content-type: application/json' -d '{"hash":"<hash>"}'
# 200: run it now, once

Fields: kind (shell or tool), agent (claude-code, codex, cursor, copilot, gemini, antigravity or mcp), rule, why (up to 300 characters), summary (up to 200), detail (the exact command or canonical tool call, up to 4,096), optional context (cwd, the folder's name only; session) and map (the guard's map version). The full contract is in docs/PROTOCOL.md.

Guard your coding agent

counterseal guard puts a hook in front of your coding agent's shell commands. A command on the shell map waits for your passkey; every other command is left to the agent's own permission settings. It is in this repository (CLI 0.3.0, coming to npm), not on npm yet: until then run node cli/leash.mjs guard ... from a clone.

npx counterseal login                  # type its code at /app#device, check where it started, approve
npx counterseal guard install          # finds Claude Code, Codex and Cursor; or --agents claude-code,codex,cursor
npx counterseal guard check -- terraform destroy     # what the map says, offline
npx counterseal guard status           # what is installed, and whether the key works

Claude Code plugin. The same hook as a plugin, from this repository's marketplace file (no form):

claude plugin marketplace add GautamTalksDev/counterseal
claude plugin install counterseal@counterseal
npx counterseal guard install --agents none     # makes the key the plugin's hook uses

The plugin needs node on your PATH and runs its own pinned copy of the guard, never npx. The plugin sets the hook to block the command if the hook itself crashes or times out; Claude Code 2.1.295 or later honours that, and older versions let such a command through. Claude Code may say one plugin option is not set: it is only for the optional MCP server and can be left empty. If you use the plugin, guard install adds no second hook to Claude Code (two would run, and the second would deny a command you approved).

What happens

  1. The agent runs terraform destroy -auto-approve. The hook reads it, finds the rule, and asks the Approvals API (above) with the command, the rule, why, and the name of the folder.
  2. The command waits (up to 9 minutes by default). Your phone shows the exact command; you approve with a passkey, or deny with a reason.
  3. Approved: the hook uses the approval once and the command runs. Denied: the agent is told, in quotes, that its human said no and why. Nobody answers: the agent is told to ask you, and asking again is the same hold.

Agents whose hook cannot wait (or if you set mode to retry) get the denial at once with the link, and run exactly the same command again after you approve it.

On a team that requires two seals, a held command stays pending until two different people have approved it, each with their own passkey. The hook keeps waiting, prints how far it is (for example "1 of 2 approvals so far") and, if the time runs out, tells the agent that a second person has to approve it too. A guard key can be revoked only by the person who made it or by an owner.

What is held (shell map 2026-10-10.1)

RuleHolds
sh.terraform-destroyterraform or tofu: destroy, apply -destroy, workspace delete (also terragrunt)
sh.pulumi-destroypulumi: destroy, stack rm, state delete
sh.iac-destroycdk, cdktf, sst, serverless, sam, amplify: destroy, remove, delete
sh.railwayrailway: delete, down, volume delete, environment delete
sh.flyfly: apps destroy, volumes destroy, machine destroy
sh.vercelvercel: remove, rm, project rm, env rm, domains rm, alias rm
sh.wranglerwrangler: delete, d1 delete, r2 bucket delete, kv namespace delete, d1 execute --remote with SQL that is not a plain read
sh.supabasesupabase: db reset (local too), projects delete, db push, migration repair
sh.neonneonctl: projects delete, branches delete, restore or reset
sh.prismaprisma: migrate reset, db push --accept-data-loss or --force-reset, db execute, any --shadow-database-url
sh.drizzledrizzle-kit: push, drop
sh.sql-clipsql, mysql, mariadb, sqlite3, turso and other SQL clients: DROP, TRUNCATE, DELETE or UPDATE without WHERE, ALTER ... DROP, SQL from a file; any write to a database on another machine
sh.db-resetdropdb, rails db:drop and db:reset, manage.py flush, artisan migrate:fresh, redis-cli FLUSHALL, pg_restore --clean and other tools that empty or drop a database
sh.git-forcegit push with --force, -f, --force-with-lease, a +refspec, --mirror, --delete or :branch
sh.git-historygit filter-branch, filter-repo, reflog expire, gc --prune=now, prune, update-ref -d
sh.git-discardgit reset --hard, clean -f, checkout or restore of the whole tree, checkout --force
sh.ghgh: repo delete, archive, rename, edit --visibility, release delete, secret set or delete, any delete, api -X DELETE, PATCH or PUT on the GitHub map
sh.k8skubectl delete, drain, replace --force, apply --prune; helm uninstall or delete; argocd app delete
sh.dockerdocker: volume rm or prune, system prune, container prune, rm -v, rm of every container, compose down -v
sh.cloudaws: delete-*, terminate-*, remove-*, deregister-*, s3 rm --recursive, s3 rb; gcloud, az, doctl, heroku, firebase and other cloud CLIs: delete, destroy
sh.stripestripe CLI: refunds create, payouts create, transfers create, subscriptions cancel, any delete, post or delete on the Stripe map
sh.npmnpm, pnpm, yarn: unpublish, deprecate, owner rm, dist-tag rm, access revoke, token revoke
sh.rmrm -r on /, ~, the project root, .git, a variable that may be empty or a path outside the project; find -delete; shred; rsync --delete; chmod or chown -R on /; crontab -r
sh.diskdd of=/dev/..., mkfs, wipefs, blkdiscard, diskutil erase, and writes to a disk device
sh.curl-apicurl, wget, httpie with a write method to a host on the API map, matched against that provider's rules
sh.dynamic-programa program name built at run time (a variable or a substitution) in a command that contains destroy, delete, drop, truncate, reset, force or rm
sh.decode-execdecoded text (base64, xxd, gunzip, openssl) piped into a shell, an interpreter or eval; held without being read

The guard reads commands the way a shell does: quotes, escapes, ; && |, groups, loops, here-documents, $(...), brace expansion, simple variables on the same line, cd, and these wrappers: sudo, env, nohup, time, xargs, find -exec, sh -c and bash -lc, eval, ssh, docker exec, kubectl exec, npx and its relatives, and the scripts in the project's package.json. MCP tool calls (matcher mcp__.*) are held when the tool name says it deletes, drops, sends, pays or deploys, or when the call carries SQL that is not a plain read; a policy file ~/.config/leash/mcp/<server>.json can add hold and allow patterns. Tool annotations such as readOnlyHint are never trusted.

What it does not see

If something goes wrong

By default the guard fails closed: if the server cannot be reached, answers something unexpected, or the key is no longer valid, a command on the map is denied with the fix, and every other command runs as before. A hook request times out after 10 seconds. Set "onUnavailable": "allow" in ~/.config/leash/guard.json to let commands on the map through when the server cannot be reached; a revoked key, a rate limit and Freeze still deny. When the account is frozen the server answers 423 to asking, waiting and using: the agent is told your human froze the account, not to retry and not to look for another way, and a wait already in progress learns of it when its current long poll ends (20 seconds at most). A key made by counterseal guard install ends with that command line sign-in (7 days at most): make a lasting one in the app (Account, Guard keys) and run counterseal guard install --paste-key.

Setting in guard.jsonDefaultMeaning
onUnavailabledenydeny or allow, when the server cannot be reached
waitSeconds540how long the hook waits for the passkey (at most 590; Claude Code's hook limit is set to 660)
modewaitper agent: wait holds the command at the hook; retry denies at once and the agent runs it again after approval
disableRulesnonerule ids to leave out, for example ["sh.git-discard"] (an agent that can edit this file can do the same)
unreadableallowwhat to do with a command that cannot be parsed and has nothing on the map in it
cursorPassThroughallowCursor's hook must answer for every command: allow or ask

Which agents

AgentHowStatus
Claude Codeplugin, or a PreToolUse hook in ~/.claude/settings.json (Bash and mcp__.*)Follows the documented hook format; the plugin passes claude plugin validate --strict and installs from the marketplace file with Claude Code 2.1.293. Not yet seen firing in a live session.
Codex~/.codex/hooks.json, Bash only. Open /hooks once to trust it.Written to the documented format. Not tested in Codex yet.
Cursor~/.cursor/hooks.json (beforeShellExecution, beforeMCPExecution, fail closed)Written to the documented format. Not tested in Cursor yet. Whether Cursor still asks you about a command the hook answers allow to is not documented; cursorPassThrough: "ask" is the careful choice.
Copilot CLI, Gemini CLI, AntigravityNot supported yet: their hooks have not been tested with the guard.

What leaves your machine: the command text with credentials removed (connection strings, bearer tokens, key-shaped values, PASSWORD= style variables), the rule, why, the name of the folder (not its path) and the session id. Held commands are shown on your devices and may appear on the lock screen.

Deploy gates for GitHub Actions

Hold any GitHub Actions job until you approve it with your passkey. GitHub's own deployment approvals (required reviewers, wait timers, custom protection rules) are available only for public repositories on the Free, Pro and Team plans, according to GitHub's documentation (read 9 Oct 2026). A gate does not use them: the job's step sends the OIDC token GitHub signs for it, Counterseal checks the signature and what the token says about the run, and your phone shows the repository, workflow, commit, run and who started it, from that token.

permissions:
  id-token: write   # lets the job ask GitHub for the token the gate checks
  contents: read
steps:
  - uses: ./.github/actions/counterseal-gate     # action/deploy-gate, copied into your repository
    with:
      gate: gate_0123456789abcdef01234567        # from the app, Deploy gates; not a secret
      summary: deploy ${{ github.sha }} to production
      timeout-minutes: 15

Usage, what is checked, limits and errors: docs/DEPLOY_GATE.md. The contract is in docs/PROTOCOL.md.

Any MCP server behind Counterseal

counterseal mcp wrap sits between your MCP client and a local (stdio) MCP server. Destructive tool calls wait for your passkey, and the server's tool definitions are pinned, so a tool the server changes after you approved it is hidden until you look. It is in the repository and not yet published (CLI 0.3.0). Put it in front of the server's own command:

{ "mcpServers": { "supabase": {
    "command": "npx",
    "args": ["-y", "counterseal", "mcp", "wrap", "--name", "supabase", "--",
             "npx", "-y", "@supabase/mcp-server-supabase@latest"],
    "env": { "LEASH_GUARD_KEY": "lsg_..." } } } }

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": []
}
FieldMeaning
allowIf present, only these calls pass.
denyAlways refused with 403. Checked first.
holdHeld for approval even if the map doesn't require it.
defaultallow, hold or deny for calls that match no rule. With an allow list, allow refuses what is not on it.
readOnlyOnly GET and HEAD.
perMinuteRate limit for this token, 1 to 600. Default 120.
unattendedIrreversibleIrreversible calls allowed without approval. Can only be set with your passkey, in the app.
limitsThe breaker: writesPerHour (1 to 5000) and repeatsPer5Min (2 to 100). Any session may set it. See Brakes.
moneyMoney caps for Stripe refunds and payouts: currency, perCall, perDay. Can only be set with your passkey. See Brakes.

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" }

Brakes: money caps, a breaker and Freeze

Three ways to keep an agent small. They cover calls that go through Counterseal, not LLM tokens, and not SQL sent straight over a database connection.

Money caps

Let a support agent refund small amounts without asking and wait for your passkey above that. Set money when you make the token with your passkey (in the app: Create with passkey). Amounts are whole numbers of the currency's smallest unit, as Stripe counts them: 5000 is 50.00 USD, 5,000 JPY or 50.000 KWD.

{ "money": { "currency": "usd", "perCall": 5000, "perDay": 20000 } }

A POST /v1/refunds or POST /v1/payouts passes without asking when it is a plain form, its amount is a whole number written the usual way, it is in that currency, it is at most perCall, and the day's total (UTC) stays at most perDay. For a refund, Counterseal asks Stripe, with your key, what currency the charge is in and what is left of it; a refund without amount counts as everything left. Anything else is held, with the arithmetic in the preview and the amount in large type:

Rule idHeld because
brake.money-unreadableThe amount or currency cannot be read with certainty (a repeated, escaped or oddly spelled parameter, a raw semicolon or space in the body, a query string, a JSON body, an amount that is not a plain whole number, a charge Stripe did not answer for).
brake.money-currencyThe call is in another currency than the caps.
brake.money-per-callOver perCall.
brake.money-per-dayToday's total plus this call would be over perDay.

A call that passes is recorded with the rule st.money:capped; its amount is in your audit log and never in the public receipt. Transfers, charges, top-ups, confirms and captures, and anything not spelled exactly /v1/refunds or /v1/payouts, stay held. What you approve is yours and is not counted against the day. When Stripe refuses a call (a 4xx) the amount is handed back; after a 5xx or no answer it stays counted.

Breaker

{ "limits": { "writesPerHour": 200, "repeatsPer5Min": 3 } }

Any session may set limits. A write is any request that is not a GET or HEAD. When a token makes more than writesPerHour writes in an hour, or sends the same exact request (method, path, query, content type and body) more than repeatsPer5Min times in 5 minutes, it trips: that write and every later one is held with rule brake.tripped until you reset the token with your passkey (Agent tokens, Reset), and reads keep passing. Your devices are told once. A token with neither limit costs nothing extra.

Freeze

The Freeze bar at the top of the app, leash freeze, or the Freeze button on a phone alert stops every token of the account at once: each call answers 423 account_frozen, a coding agent's guard is stopped too, approvals nobody used yet are void, and approving waits. Any signed-in session can freeze. Only your passkey, in the app, unfreezes.

Teams: the two-person seal

In the repository, not yet on the live service. An account can have several people, each with their own passkey, and a held call or command can need two of them.

Two people who agree to act together are not stopped: two seals mean two passkeys of two people on your team.

Providers and the map

The irreversible map covers 10 APIs: GitHub, Cloudflare, Railway, Stripe, Supabase, Vercel, Neon, Fly.io, Resend and Shopify. Management APIs only; SQL sent straight over a database connection string never passes through Counterseal. The current irreversible map, with the reason for every rule, is on the homepage and at GET /v1/meta. Every DELETE is held on GitHub, Cloudflare, Stripe, Supabase, Vercel, Neon, Fly.io and Resend, and every Railway mutation is held unless every field is a redeploy or restart. Vercel, Neon and Fly.io hold production promotions and rollbacks, restores that overwrite data, secret and token changes, and access grants; Resend holds batch sends, broadcasts and any email to more than one recipient. Shopify is brokered through its Admin GraphQL endpoint only, at the store address you give when you add the key (your-store.myshopify.com, checked strictly), and holds mutations that delete, move money, cancel, change gift cards or customer data, or cannot be read. Also held: force pushes, repo transfers, Stripe refunds and payouts, Cloudflare purges of everything, a whole host or a URL prefix, and any SQL on Supabase or D1 that is not a single plain read (SELECT, WITH ... SELECT, EXPLAIN without ANALYZE, SHOW), including Supabase migrations and D1 imports. Paths are matched in one canonical form (no empty segments, no ; path parameters, escapes of plain letters decoded, one trailing slash dropped) and that canonical path is sent upstream. Rules match it without regard to case, the map also ignores a format suffix such as .json, and a HEAD counts as a GET. A body the broker cannot read one way on a route with a body rule is held: a watched parameter that appears twice, in both the body and the query string, or as a case variant, and JSON with repeated keys. A request that carries a method override (_method) is held on every provider and cannot be pre-approved.

Receipts: the public log

If it was recorded, you can prove it. When receipts are on, every decision the broker makes on a write becomes an entry in a public, append-only Merkle log (RFC 6962), sealed by a signed checkpoint (C2SP signed note, Ed25519): an allowed write, a hold, an approval, a denial, and the approved call going through. Allowed reads (GET and HEAD) are not recorded; a read that is held or denied is.

A broker receipt holds only this, and it is public and permanent:

{ "v": 2, "source": "broker", "submitter": "<salted commitment>", "received_at": 1791300000000,
  "receipt": { "agent": "broker", "action": "DELETE api.github.com", "decision": "held",
               "parent": "<SHA-256 of your audit entry>", "ts": 1791300000000,
               "meta": { "map": "2026-10-09.1", "rule": "gh.delete", "agent_key": true } } }

No path, query string, body, key, token, account id, name, email or IP address is ever in a receipt. parent is the hash of the matching entry in your private audit log, so you can open any receipt later with your audit log and prove exactly what happened, which agent key made the call (agent_key says one did) and which passkey approved it (passkey). source: "broker" sits outside the receipt and can only be written by the broker itself, so nobody can forge a broker receipt through the receipts API.

Agents logging their own actions

An agent can also record what it did, with a receipts API key (lsr_, made in the app under Receipts):

curl -s https://counterseal.gautamkhosla.com/v1/receipts \
  -H "authorization: Bearer lsr_..." -H 'content-type: application/json' \
  -d '{"receipt":{"agent":"deploy-agent","action":"deploy.prod v2.4.1","decision":"executed"}}'

Fields: agent and action (required, up to 80 characters), subject (up to 120), decision (allowed, denied, held, approved, executed, failed), input_sha256, output_sha256, parent, run (up to 80), ts (Unix ms), meta (up to 8 keys, values up to 120 characters). No control, bidi or invisible characters; unknown fields are refused. Up to 25 receipts per call, or 12 for a key that requires signatures. Everything you send is public: send hashes, not content. These entries carry no source field, so verifiers tell them apart from the broker's own.

Signed receipts. Bind a receipts key to an Ed25519 signer (one of your agent keys, or any key with a proof of possession) and the key only accepts receipts that signer signed over their canonical JSON, each with a ts within 10 minutes of the server clock, and each signature once.

Verifying a receipt

Open Verify a receipt and paste or open a bundle: it is checked in your browser against this log's key. Or offline with the SDK in this repository (sdk/receipts.mjs, zero dependencies):

node sdk/receipts.mjs verify bundle.json --key "<verifier key from GET /v1/log/key>"

Pin the verifier key: fetch GET /v1/log/key once and keep it with your auditors. A bundle's own log field is only a hint. Any two checkpoints can be linked with a consistency proof, so a log that rewrote history would be caught by anyone who kept an old checkpoint.

Agent keys: prove who

Give each agent its own Ed25519 key. You register the public half with a proof of possession; the private half never leaves the agent's machine. A token bound to the key then only works on requests that key signed, so a token copied from a log, a config file or a screen is useless on its own, and every decision on a signed request records which agent key made it.

npx counterseal agent-key agent-key.json --label laptop-agent     # makes the key (file mode 600) and registers it
npx counterseal token <key-id> --agent-key <agent-key-id>          # a token bound to it

Then run the MCP server of leashcli 0.3.0 with LEASH_AGENT_KEY=agent-key.json next to LEASH_TOKENS; it signs every call. (0.3.0 is in the repository and not yet on npm: npx counterseal still runs 0.1.1, which cannot sign, so a bound token refuses its calls.) You can also make a key in the app (it is generated in your browser and downloaded; only the public half is sent). The CLI commands for agent keys arrive in CLI 0.3.0; until it is published, use the app or the signer in cli/sign.mjs.

The signature is an RFC 9421 HTTP Message Signature with this profile:

Signature-Input: sig1=("@method" "@target-uri" "content-digest" "content-type");created=1791300000;expires=1791300060;keyid="<JWK thumbprint>";alg="ed25519";nonce="<random>";tag="leash-pop"
Signature: sig1=:<base64 Ed25519 signature>:
Content-Digest: sha-256=:<base64 SHA-256 of the body>:

Web Bot Auth checker

The free signature checker checks a Web Bot Auth request (draft-ietf-webbotauth-httpsig-protocol on RFC 9421) in your browser: paste the headers, fetch or paste the agent's key directory, and see whether the signature is valid. To verify on your own server, use the verifier in this repository (sdk/verifier.mjs, local by default, with SSRF-safe directory fetching and pluggable nonce stores).

CLI

CommandWhat it does
leash loginSign in this machine for 7 days. You type the code it shows in the app and approve with your passkey. Tokens it mints end with the sign-in; revoke it in the app under Account.
leash logoutEnd the session on the server (0.3.0) and forget it on this machine. Tokens it minted keep working until they end or you revoke the sign-in in the app.
leash add <provider> [label]Vault a key. Prompts for it; encrypted in the vault, never saved on your machine.
leash credsList vaulted keys (id, provider, label, last four).
leash token <key-id> [--label x] [--ttl hours] [--policy file.json]Mint an agent token. 0.3.0 adds --agent-key <id>.
leash tokensList active tokens.
leash revoke <token-id>Revoke a token immediately.
leash freezeStop every agent token on the account at once. Only your passkey, in the app, unfreezes. From 0.3.0.
leash holdsList calls waiting for you.
leash watchPrint and ring for each new hold.
leash mcpRun the MCP server for your agent.
leash agent-key [file], leash agent-keysMake and register an agent key; list them. From 0.3.0.

Run any command with npx counterseal, or install it globally with npm i -g leashcli.

API

Base URL https://counterseal.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 or web only work from the web app.

EndpointPurpose
GET /v1/metaProviders and the irreversible map. Public.
GET /v1/meYour account, and your receipts numbers (on, used this month, today, skipped, quotas).
GET, POST /v1/credentialsList or add vaulted keys.
DELETE /v1/credentials/:idDelete a key. Its tokens stop working. Web only.
GET, POST /v1/tokensList or mint agent tokens. agentKeyId binds the token to an agent key.
POST /v1/tokens/passkey/begin, /finishMint a token with pre-approved irreversible operations. Passkey.
DELETE /v1/tokens/:idRevoke a token.
POST /v1/tokens/:id/reset/begin, /finishReset a tripped breaker (409 not_tripped if it has not tripped). Passkey. Web only.
POST /v1/me/freezeFreeze the account: every lsh_ token is answered 423. Any signed-in session, the command line included. GET /v1/me shows account.frozen_at.
POST /v1/me/unfreeze/begin, /finishEnd a freeze (409 not_frozen if it is not frozen). Passkey. Web only.
GET /v1/holdsCalls and commands waiting for you, each with its kind (proxy, shell, tool, deploy) and a preview of what it would do (for a call: query string, SQL or GraphQL, body, at most 2 KB; for a command: the whole command). A held Stripe refund or payout has a headline with its amount.
POST /v1/holds/:id/approve/begin, /finishApprove a hold (on a team, add your seal: the answer says approved, seals and needs). Passkey. Owners and approvers.
GET /v1/teamThe people, their roles, the seals needed and a waiting change, open invites, and who you are.
POST /v1/team/invites/begin, /finish, DELETE /v1/team/invites/:idMake an invite ({role, label, hours}; owner, passkey) or revoke one.
POST /v1/invites/lookup, /begin, /finishThe invite page: what the invite is, then join with a new passkey. The invite's token goes in the body.
POST /v1/team/members/:id/role/begin, /finish, /remove/begin, /finishChange someone's role (owner) or remove them (owner, or yourself to leave). Passkey.
POST /v1/team/quorum/begin, /finish, /quorum/cancel/begin, /finishSet the seals needed ({quorum: 1 or 2, selfApprove}; owner, passkey), or cancel a waiting lowering (owner or approver, passkey).
POST /v1/holds/:id/denyDeny a hold. Optional {"reason"} (200 characters), handed back to a guard.
GET, POST /v1/guard/keys, DELETE /v1/guard/keys/:idGuard keys (lsg_, shown once). Revoking one closes its open approvals.
POST /v1/approvals, GET /v1/approvals/:id?wait=, POST /v1/approvals/:id/useAsk for, wait for (up to 20 s) and use an approval for a coding agent's command. Bearer lsg_ guard key.
GET /v1/gates, POST /v1/gates/passkey/begin, /finish, DELETE /v1/gates/:idDeploy gates: list, make (passkey, web only; a member or more) and revoke (its maker or an owner; it closes the gate's waiting requests and revokes the tokens it handed out). On a team, a deploy request needs as many different approvers as the quorum.
POST /v1/gate/request, GET /v1/gate/status/:id?wait=, POST /v1/gate/cancel/:idA GitHub Actions job asks for, waits for (up to 20 s) and withdraws a deploy approval. The first is authenticated by GitHub's OIDC token, the others by the poll secret (gps_) it returns. Used by action/deploy-gate.
GET /v1/audit, GET /v1/audit/verifyRead your audit log (with the receipt index of each sealed entry); check the hash chain, 100 entries a request (?after=<next_after> until done).
GET /v1/agent-keys, POST /v1/agent-keys/begin, POST /v1/agent-keysList agent keys; register one with a proof of possession over the text from begin.
DELETE /v1/agent-keys/:idRevoke an agent key. Web only.
POST /v1/me/receipts/on, POST /v1/me/receipts/off/begin, /finishTurn receipts on (web) or off (passkey).
GET, POST /v1/keys, POST /v1/keys/signer/begin, DELETE /v1/keys/:idReceipts API keys, with an optional signer.
POST /v1/receiptsRecord receipts. Bearer lsr_ key.
GET /v1/my/receipts, GET /v1/my/receipts/:index/bundleYour receipts with openings; a fresh bundle for one.
GET /v1/log/checkpoint, /v1/log/key, /v1/log/proof/inclusion?index=&size=, /v1/log/proof/consistency?first=&second=, /v1/log/entries?start=&count=, /v1/statsThe public log. Anyone, any origin (CORS open), no account.
GET /v1/tools/directory?agent=Fetch an agent's Web Bot Auth key directory for the checker. Public, 10 a minute per network.
GET /v1/passkeys, POST /v1/passkeys/:id/remove/begin, /finishYour passkeys; remove one with a passkey check, which also signs out your other browsers (web only, never the last).
POST /v1/device/start, POST /v1/device/pollCommand line sign-in (RFC 8628 device code): a code to type at /app#device, valid 5 minutes, 10 starts an hour per network; poll every interval seconds.
POST /v1/device/lookup, /v1/device/deny, /v1/device/approve/begin, /finishIn the app: where a sign-in started, then deny it, or approve it with a passkey. Web only.
GET /v1/sessions, DELETE /v1/sessions/:idCommand line sign-ins of the last 7 days; revoke one and every token, agent key and receipts key it made, even after it signed out (revoking is web only).
GET /v1/alertsWhat a phone alert shows: waiting holds (a held refund or payout has a headline with its amount), command line sign-ins and passkeys added in the last 15 minutes, and frozen_at.
GET /v1/push/key, POST /v1/push/subscribe, /unsubscribe, /testPhone and desktop alerts (Web Push, no payload), up to 10 devices. Web only.
POST /v1/me/delete/begin, /finishDelete your account and everything linked to it (public log entries stay, unlinked). Passkey. Finish body {"confirm":"delete", challengeId, credential}. Web only.
ANY /p/:provider/*The agent proxy. Bearer lsh_ token (and the agent's signature when the token is bound).

Errors

StatusErrorMeaning
401leash_token_required, token_invalidMissing, expired or revoked token.
401agent_signature_required, bad_agent_signature, agent_signature_replayed, agent_key_revokedThe token is bound to an agent key and the request was not signed by it, was signed wrongly (the reason says how), reused a nonce, or the key was revoked.
401api_key_required, api_key_invalidMissing, revoked or expired receipts API key.
401guard_key_required, guard_key_invalidMissing, revoked or ended guard key (a key made by a command line sign-in ends with it).
401oidc_required, oidc_malformed, oidc_alg, oidc_bad_signature, oidc_issuer, oidc_audience, oidc_expired, oidc_not_yet_valid, oidc_lifetime, oidc_claims, oidc_replayed, oidc_unknown_kidA deploy gate's request without a valid GitHub OIDC token: missing (the job lacks id-token: write), not signed by GitHub, made for another audience, expired, a claim missing or odd, or already used. The message says which.
403gate_refusedThe run is not what the gate accepts. reason is repository, workflow, ref, environment or event.
503oidc_keys_unavailableGitHub's signing keys could not be fetched; the action retries.
403deniedYou denied this command; deny_reason carries your reason.
409hash_mismatch, not_approved, already_usedThe approval is for a different command, is still waiting, or was used.
410expiredThe approval was not used within 10 minutes, or nobody decided within 30. Ask again.
402quota, daily_quotaYour monthly or daily receipts are used up. Nothing was recorded.
403leash_deniedA policy deny rule, readOnly, or the allow list or default blocked the call.
403wrong_providerThe token belongs to a different provider.
403passkey_requiredNeeds the web app and your passkey.
403role_required, own_request, seal_not_yetOn a team: your role does not allow it (or the token or key is someone else's: only its maker or an owner revokes it); your own token or guard key asked and someone else must seal it; or you joined or became an approver less than 24 hours ago while one person cannot approve alone (two seals, or self-approval off), so your seal, an unfreeze or a breaker reset by you does not count yet.
409already_sealed, quorum_blocks_preapproval, last_owner, not_enough_approversYou sealed this already; pre-approvals and money caps wait while two seals are needed; the account needs an owner; the seals needed need that many owners or approvers.
400invite_invalidThe invite was used, has expired, was revoked, or its owner is no longer an owner.
403csrfWeb write without the right Origin or x-leash header.
409replay, key_taken, signer_takenA signed receipt sent twice; a public key registered to another account.
409too_many_credentials, too_many_tokens, too_many_keys, too_many_agent_keys, too_many_guard_keys, too_many_devicesA ceiling per account: 50 vault keys, 200 live tokens, 20 receipts keys, 20 agent keys, 20 guard keys, 10 alert devices.
413too_largeRequest body over the limit.
409not_frozen, not_trippedAn unfreeze or a reset for something that is not frozen or tripped.
423account_frozenThe owner froze the account: every agent token is stopped until they unfreeze it with a passkey. Stop and tell your human; do not retry. Approving is refused with the same error while frozen.
428held_for_approvalIrreversible, or a brake (the rule starts with brake.): over a money cap, unreadable, or the token's breaker tripped. Approve, then retry once.
429slow_downToo many requests. The message says when to retry.
429too_many_holds20 requests from this token or guard key (or 100 on the account) are already waiting for approval; nothing new is held or alerted until a human decides.
503log_at_capacityThe public log reached its daily capacity. Nothing was recorded; try after 00:00 UTC.