Leash Grant
Your agent acts on your customer's account. Let the customer set the limits, and hold the agent to them.
You send your customer to a Leash page that says, in plain words, what your agent is asking to do. They can drop a line, lower a cap, shorten the time, or decline. If they approve, you get back a grant signed by Leash. Your agent checks every action against it, and the customer can switch it off whenever they like. It is free while it is new.
The demo plays both parts: you approve as the customer, see what the vendor receives, let its agent act, then read the log and answer a request for approval from your own grant page.
The flow
- Ask. Build a request and send the customer to
https://leashkit.com/grant.html#req=…. - The customer chooses. They can only take away. A cap you asked for can be lowered, never raised.
- Receive. The customer arrives at your
returnUrlwith#grant=…in the address, and yourstate. A decline arrives as#grant_error=declined. - Verify. Check the signature with Leash's public key, and that the grant was approved for your own host.
- Enforce. Before every action, check the grant: is this tool and action granted, is the cap spent, has it ended.
- Respect the switch. Ask
/api/grant-status?id=…whether the grant was revoked. You may reuse an answer for 60 seconds. - Show your work (optional). Send the receipts your gate writes, and the customer can read what the agent did from their own grant page. See the activity log.
- Ask when told to (optional). Where the customer ticked "ask me each time", leave a request and wait for their answer. See approvals.
The request
{
"v": 1,
"vendor": { "id": "acme-crm", "name": "Acme CRM", "class": "crm" },
"agent": { "id": "acme-assistant" },
"subjectRef": "cust_8f3a",
"scopes": [
{ "tool": "crm.contacts", "action": "read" },
{ "tool": "email.outbox", "action": "send" },
{ "tool": "crm.contacts", "action": "delete", "requiresApproval": true }
],
"budget": { "maxSends": 20, "maxDeletes": 5 },
"ttlSeconds": 604800,
"returnUrl": "https://app.acme.dev/leash/return",
"state": "abc123"
}
actionis one of read, write, send, delete, pay. Budgets aremaxReads,maxWrites,maxSends,maxDeletes,maxPaymentsandmaxUsd.subjectRefis your own opaque handle for the customer. It must not be an email address or a name. Leash does not show it and does not keep it.ttlSecondsis at most 90 days.returnUrlmust be https.- The request is JSON, UTF-8, base64url, in the fragment. A fragment is not sent to any server, so loading the page tells Leash nothing.
The customer is shown your returnUrl's host as who is asking. The name you give is shown as a name; the host is shown
as the fact. The words for each capability are written by Leash from the action and the tool, not taken from the request.
Verify a grant
The token is two base64url parts joined by a dot: the JSON body and an ECDSA P-256 / SHA-256 signature over the body's text. This needs nothing but the platform:
const KEY = await (await fetch('https://leashkit.com/grant-key.json')).json();
async function readPermit(token, myHost) {
const [body, sig] = token.split('.');
const bytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey('jwk', KEY, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, bytes(sig), new TextEncoder().encode(body));
if (!ok) return undefined;
const value = JSON.parse(new TextDecoder().decode(bytes(body)));
if (value.t !== 'grant' || value.aud !== myHost) return undefined; // approved for someone else
return value.grant;
}
async function revoked(id) {
const res = await fetch('https://leashkit.com/api/grant-status?id=' + id);
if (!res.ok) throw new Error('status unknown'); // unknown is not "not revoked"
return (await res.json()).revoked;
}
Then, before each action: refuse if revoked, if the time is past grant.expiresAt, if no entry in
grant.scopes matches the tool and action, or if the count for that action has reached its limit in
grant.budget. Where a scope has requiresApproval, ask the customer first. Keep the counts yourself: they
are yours, and Leash never sees them.
The activity log
This part is optional. If you send it, the customer's grant page gains a list of what your agent did and what it was refused, which is the thing a customer who is deciding whether to trust an agent most wants to see.
The gate in the package writes one receipt for each decision. Each receipt carries the hash of the one before it, so a log can be added to and cannot be quietly edited. Post them, with the signed grant you were handed:
POST https://leashkit.com/api/grant-receipts
content-type: application/json
{ "grant": "<the signed grant>", "receipts": [ … ] }
- The signed grant is what entitles you to write to its log. Nobody without it can.
- Up to 200 receipts a request and 5,000 a grant. Send from where you left off; receipts already held are skipped, so sending again is safe.
- A batch must continue the chain already held. One that leaves a gap, or changes a receipt sent earlier, is refused whole.
- A receipt holds a time, the decision, the tool, the action, the reasons, and the ids already in the grant. Any other field is refused, and the sentence in it must be the one the gate writes. There is nowhere in it to put a name, an address or a message.
- The customer is told what this proves: nothing sent has been changed since. It cannot prove you sent everything, and the page says so.
Approvals
A customer can mark a capability "ask me each time". The gate then holds that action instead of allowing it. To get an answer, leave a request with the signed grant, the tool and the action:
POST https://leashkit.com/api/grant-ask
content-type: application/json
{ "grant": "<the signed grant>", "tool": "crm.contacts", "action": "delete" }
→ { "id": "…", "state": "waiting", "expiresAt": … }
GET https://leashkit.com/api/grant-ask?id=…
→ { "id": "…", "state": "waiting" | "approved" | "refused" | "expired", … }
- The request shows on the customer's own grant page with two buttons: approve once, or refuse. Leash does not know who your customer is and cannot notify them. Tell them yourself that something is waiting on their Leash grant page.
- A request names a tool and an action the grant already lists as needing approval, and nothing else. It cannot carry a message: the customer reads Leash's words for the capability, the same words as on the consent screen.
- A request lapses after 24 hours, or when the grant ends if that is sooner. Up to 10 can wait on one grant; asking again for the same thing returns the request already waiting.
- An answer you cannot read is unknown, and unknown is not yes.
- An approval is for one action. On
approved, run the gate once more with that scope'srequiresApprovalcleared, act if it allows, and go back to the grant as signed. Every other limit still applies, so an approved action past its cap is still refused. Leash is not in the path of your calls and relies on you to use an approval once.
The whole loop in one file
A made-up product whose assistant acts on a customer's account: it asks, receives and verifies the grant, checks every action, sends its receipts, asks for approval and handles the webhook. It is tested against the same code this site runs.
Show the file
// Leash Grant, the whole loop in one file.
//
// A product ("Acme") whose agent acts on a customer's account:
// 1. sends the customer to Leash to set the agent's limits
// 2. receives the signed grant and verifies it
// 3. checks every action against the grant, and against the off switch
// 4. sends its receipts, so the customer can read what the agent did
// 5. asks the customer before anything they marked "ask me each time"
// 6. listens for a webhook, so it need not ask often
//
// Storage here is a Map in memory, one customer, to keep the shape visible.
// In a real product each of these is a row keyed by your own customer id.
import { randomBytes } from 'node:crypto';
export function createApp({ lib, baseUrl, key = lib.LEASH_GRANT_KEY, leash = 'https://leashkit.com', fetchImpl = fetch }) {
const myHost = new URL(baseUrl).host;
const customers = new Map(); // customerId -> { state, token, grant, consumed, chain, sent, status, asks }
const customer = (id) => customers.get(id) ?? customers.set(id, { consumed: lib.ZERO_CONSUMPTION, chain: [], sent: 0, asks: new Map() }).get(id);
// 1. Ask. The request rides in the link's fragment, so loading Leash's page sends it nowhere.
function connectUrl(customerId) {
const c = customer(customerId);
c.state = randomBytes(16).toString('hex'); // ties the answer to this customer; check it on return
return lib.grantRequestUrl(
{
v: 1,
vendor: { id: 'acme-crm', name: 'Acme CRM', class: 'crm' },
agent: { id: 'acme-assistant' },
subjectRef: customerId, // your own opaque id. Never an email address or a name.
scopes: [
{ tool: 'crm.contacts', action: 'read' },
{ tool: 'email.outbox', action: 'send' },
{ tool: 'crm.contacts', action: 'delete', requiresApproval: true },
],
budget: { maxSends: 20, maxDeletes: 5 },
ttlSeconds: 7 * 86_400,
returnUrl: `${baseUrl}/leash/return`,
state: c.state,
// A webhook must be https on the same host as returnUrl, so it is left out while you run on localhost.
...(baseUrl.startsWith('https://') ? { webhookUrl: `${baseUrl}/leash/hook` } : {}),
},
leash,
);
}
// 2. Receive. The customer lands on /leash/return with "#grant=<token>&state=<state>". A fragment never reaches
// a server, so that page's own script posts it here. Verify before believing any of it.
async function receiveGrant(customerId, token, state) {
const c = customer(customerId);
if (state !== c.state) return { ok: false, why: 'state does not match the request we made' };
const signed = await lib.verifyGrant(token, key);
if (signed === undefined) return { ok: false, why: 'not signed by Leash' };
if (signed.host !== myHost) return { ok: false, why: 'approved for a different host' };
Object.assign(c, { token, grant: signed.grant, consumed: lib.ZERO_CONSUMPTION, chain: [], sent: 0, status: undefined });
return { ok: true, grant: signed.grant };
}
// The off switch. An answer may be reused for STATUS_MAX_AGE_SECONDS. Unknown is not "still on":
// with no answer yet, refuse; with an old one, keep it.
async function current(c) {
const stale = c.status === undefined || Date.now() - c.status.at > lib.STATUS_MAX_AGE_SECONDS * 1000;
if (stale) {
const fresh = await lib.fetchGrantStatus(c.grant.id, fetchImpl, leash);
if (fresh !== undefined) c.status = { ...fresh, at: Date.now() };
}
return c.status === undefined ? undefined : lib.withStatus(c.grant, c.status);
}
// One decision: run the gate, keep the count, write the receipt, send what is new.
async function decide(c, grant, scope) {
const at = Date.now();
const result = lib.evaluate({ permit: grant, consumed: c.consumed, scope, at });
c.consumed = result.consumed; // moves only when the gate allowed the call
c.chain = lib.append(c.chain, { permitId: grant.id, decision: result.decision, outcome: result.outcome, at });
// 4. Send receipts. Optional. Send from where you left off; resending is harmless.
const sent = await lib.sendReceipts(c.token, c.chain.slice(c.sent), fetchImpl, leash);
if (sent.ok && sent.stored !== undefined) c.sent = sent.stored;
return result.decision;
}
// 3. Enforce. Call this before every action your agent takes for this customer.
async function act(customerId, scope, run) {
const c = customer(customerId);
if (c.grant === undefined) return { verdict: 'deny', why: 'this customer has not connected the agent' };
const grant = await current(c);
if (grant === undefined) return { verdict: 'deny', why: 'cannot tell whether the grant is still on' };
const decision = await decide(c, grant, scope);
if (decision.verdict === 'allow') return { verdict: 'allow', result: await run() };
if (decision.verdict === 'deny') return { verdict: 'deny', why: decision.summary };
// 5. Held: the customer asked to be asked. Leave a request, then tell them yourself that it is waiting;
// Leash does not know who they are.
const { ask, problem } = await lib.askApproval(c.token, scope, fetchImpl, leash);
if (ask === undefined) return { verdict: 'escalate', why: `could not ask: ${problem}` };
c.asks.set(ask.id, { scope, run, used: false });
return { verdict: 'escalate', askId: ask.id, why: decision.summary };
}
// Later: read the answer and, on a yes, do that one thing once.
async function resume(customerId, askId) {
const c = customer(customerId);
const held = c.asks.get(askId);
if (held === undefined || held.used) return { verdict: 'deny', why: 'no such request, or its approval was already used' };
const answer = await lib.fetchApproval(askId, fetchImpl, leash);
if (answer === undefined || answer.state === 'waiting') return { verdict: 'escalate', askId, why: 'no answer yet' };
if (answer.state !== 'approved') {
c.asks.delete(askId);
return { verdict: 'deny', why: `the customer's answer was: ${answer.state}` };
}
const grant = await current(c);
if (grant === undefined) return { verdict: 'deny', why: 'cannot tell whether the grant is still on' };
held.used = true; // an approval is for one action; mark it before acting
// The same gate, with that one scope no longer asking. Caps and expiry still apply.
const decision = await decide(c, lib.withApproval(grant, answer), held.scope);
return decision.verdict === 'allow' ? { verdict: 'allow', result: await held.run() } : { verdict: 'deny', why: decision.summary };
}
// 6. Webhook. A nudge, sent once and not retried; the endpoints above stay the record.
async function webhook(body) {
const event = typeof body?.event === 'string' ? await lib.verifyEvent(body.event, key) : undefined;
if (event === undefined) return { ok: false };
for (const c of customers.values()) {
if (c.grant?.id !== event.grantId) continue;
if (event.type === 'grant.revoked') c.status = { id: event.grantId, revoked: true, revokedAt: event.at, at: Date.now() };
// For 'ask.answered', call resume(customerId, event.askId) from wherever your agent's work is queued.
}
return { ok: true, event };
}
return { connectUrl, receiveGrant, act, resume, webhook, customer };
}
Webhooks
Asking is the record; a webhook saves you asking often. Add "webhookUrl" to your request and Leash posts a signed
note when the customer switches the grant off or answers a request for approval.
POST https://app.acme.dev/leash/hook
content-type: application/json
{ "event": "<signed note>" }
→ { "type": "grant.revoked", "grantId": "…", "at": … }
→ { "type": "ask.answered", "grantId": "…", "askId": "…", "tool": "…", "action": "…", "state": "approved" | "refused", "at": … }
- The address must be https and on the same host as your
returnUrl, the one host your customer was shown. - The note is signed like a grant, with the same key. Check the signature before you believe it.
- It is sent once, with a short wait, and not retried. If your server is down you miss it, so keep asking
/api/grant-statusand/api/grant-askas well: those are the record, the note is a nudge. - Leash stores no address. It travels in the customer's own link and is read from there when they act.
What Leash keeps
- When a customer approves: nothing. The request and the choices pass through once to be signed, and are not stored.
- When a customer switches a grant off: the grant's id, the time, and when it would have ended.
- If you send the activity log: those receipts, under the grant's id. Only the customer's own link opens them.
- If you ask for an approval: the tool, the action, the times, and the customer's answer, under the grant's id.
- For how long: the log, the requests and the revocation are deleted 30 days after the grant ends.
- Never: who the customer is, the content of anything the agent read or wrote, or your counts.
Limits
A grant is a signed statement of what a customer allowed. It holds an agent only where your code checks it: Leash is not in the path of your agent's calls and cannot stop one you do not check. A revocation takes effect when you next ask, so up to 60 seconds can pass. Leash is not insurance and settles no payment; a dollar limit is a number your gate refuses to exceed.