Skip to content

ERC-7715 permissions

Every AGENSAI agent is bounded by an on-chain ERC-7715 grant. The grant is the agent's job description: which contracts it can call, which tokens it can spend, how much, how often, and until when.

The grant is signed once by the orchestrator's local key when the agent is spawned. After that, the orchestrator never needs to sign for the agent again. The agent signs its own sendCalls with the permissionId, and the on-chain validator checks every call against the policy.

The shape of a grant

An ERC-7715 grant from the orchestrator to a child looks like this (translated from AGENSAI's policy DSL into the JAW + ERC-7715 wire format):

// Cite: ~/.claude/skills/jaw-sdk-best-practices/rules/permissions.md (lines 7-43, 131-157)
await orchestrator.grantPermissions(
  Math.floor(Date.now() / 1000) + 30 * 86400,   // 30-day expiry
  childAddress,                                  // spender = child SA address
  {
    calls: [
      {
        target: UNISWAP_V4_ROUTER,
        functionSignature: "swapExactTokensForTokens(uint256,uint256,bytes,address,uint256)",
      },
    ],
    spends: [
      {
        token: USDC,
        allowance: parseUnits("50", 6).toString(),
        unit: "day",                             // 50 USDC/day, rolling
      },
    ],
  }
);
// Returns { permissionId: "0x...", account, spender, start, end, ... }

You don't write this directly. You write a higher-level policy DSL that compiles to this shape.

The AGENSAI policy DSL

AGENSAI's policy DSL is a small set of TypeScript objects that the lib compiles into ERC-7715 grants. There are four kinds.

Spend

{ type: "spend", token: "USDC", amount: 50, period: "day" }

The agent may spend at most 50 USDC per rolling day. Token can be a symbol ("USDC", "ETH", "DAI") or a hex address. Period: minute, hour, day, week, month, year, or forever.

Every agent must have at least one spend policy. If you forget, the lib throws PolicyError, fail-closed.

Contract

{ type: "contract", whitelist: ["uniswap.eth", "0x..."], functionSignature: "swap(...)" }

Restricts which contracts the agent can call. Targets can be ENS names or hex addresses. ENS names are resolved at compile time. If functionSignature is omitted, all selectors on the whitelisted contracts are allowed.

If you don't include a contract policy, the lib uses the wildcard sentinels target: 0x3232…3232 and selector: 0x32323232, the JAW + on-chain convention for "no contract restriction." Only do this if you understand the implication. Most agents should be scoped.

Expires

{ type: "expires", at: "2026-12-31T23:59:59Z" }

Sets the grant expiry. If omitted, defaults to 30 days from createAgent time.

Rate

{ type: "rate", max: 5, period: "day" }

Caps the number of calls (independent of value). Useful for "this agent should make at most 5 trades per day" patterns.

Compiling

The lib's compilePolicies takes the DSL list and produces a CompiledPolicy ready for account.grantPermissions(...). It enforces fail-closed semantics: every agent must have at least one spend.

import { compilePolicies } from "../lib/policies/index.js";
 
const compiled = await compilePolicies({
  policies: [
    { type: "spend", token: "USDC", amount: 50, period: "day" },
    { type: "contract", whitelist: ["uniswap.eth"] },
    { type: "expires", at: "2026-06-30T00:00:00Z" },
  ],
  spender: childAddress,
  chainId: 8453,
});
// compiled = { calls, spends, expiry }, feed straight into grantPermissions.

What happens at execution time

When an agent acts, its local key signs sendCalls with the permissionId from the grant. The flow:

// Cite: ~/.claude/skills/jaw-sdk-best-practices/rules/permissions.md (lines 131-157)
const result = await childAccount.sendCalls({
  calls: [
    {
      to: UNISWAP_V4_ROUTER,
      data: encodeFunctionData({ abi, functionName: "swap", args: [...] }),
      value: 0n,
    },
  ],
}, { permissionId });

The on-chain validator:

  1. Looks up the permission by permissionId.
  2. Checks the expiry hasn't passed.
  3. Checks each call's to is on the whitelist (or matches a wildcard).
  4. Checks the value spent stays within the rolling window.
  5. Checks the call selector is allowed.

If anything fails, the bundle reverts with PermissionViolationError. There's no AGENSAI middleware that could fail open, no off-chain check that could be bypassed. The validator is the policy engine.

Revocation is permanent

When you revoke an agent, the orchestrator signs a revokePermission(permissionId) transaction. The permission is permanently revoked, once revoked, that permissionId cannot be re-activated.

This means policy_add is implemented as revoke + re-grant: the old permissionId is destroyed, a fresh permission is granted with the merged policy bundle, and the new permissionId is persisted. The agent's ENS and smart-account address are preserved across the rotation; only the permission handle changes.

Cite: account-api.md lines 181-198, permissions.md line 190.

What this gives you

  • Bounded autonomy. Every agent's worst case is its grant.
  • Instant kill. Revoke runs on-chain in one orchestrator-signed transaction. Zero biometric.
  • No middleware. The policy engine is the validator on-chain. AGENSAI doesn't sit in the middle.
  • Composable scope. Spend + contract + rate + expires can be mixed freely. Build a $50/day USDC-only Uniswap-only 5-calls-per-day 30-day agent in 4 lines.

Further reading