LeashIndex Get the report

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

  1. Ask. Build a request and send the customer to https://leashkit.com/grant.html#req=….
  2. The customer chooses. They can only take away. A cap you asked for can be lowered, never raised.
  3. Receive. The customer arrives at your returnUrl with #grant=… in the address, and your state. A decline arrives as #grant_error=declined.
  4. Verify. Check the signature with Leash's public key, and that the grant was approved for your own host.
  5. Enforce. Before every action, check the grant: is this tool and action granted, is the cap spent, has it ended.
  6. Respect the switch. Ask /api/grant-status?id=… whether the grant was revoked. You may reuse an answer for 60 seconds.
  7. 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.
  8. 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"
}

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": [ … ] }

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

What Leash keeps

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.