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.

In the per-agent-SA model (v0.5.0+), the permission lives on the agent's own smart account, not on the orchestrator. At spawn time the agent SA is deployed, the orchestrator is added as a co-owner, and the permission is granted with the agent SA as both grantor and spender. The orchestrator can revoke or drain the agent at any time by signing cross-SA execute calls as co-owner.

The shape of a grant

A grant on the agent SA looks like this (translated from agensai's permission DSL into the JAW + ERC-7715 wire format):

// Cite: ~/.claude/skills/jaw-sdk-best-practices/rules/permissions.md (lines 7-43, 131-157)
await agentAccount.grantPermissions(
  Math.floor(Date.now() / 1000) + 30 * 86400,   // 30-day expiry
  agentSaAddress,                                // spender = agent's own SA
  {
    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 permission DSL that compiles to this shape, and create_agent handles the two-UserOp bootstrap (sponsored deploy + addOwner + approve, then USDC-paid grant).

The AGENSAI permission DSL

AGENSAI's permission 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 permission. If you forget, the lib throws PermissionError, 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 permission, 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 compilePermissions (in apps/mcp/src/lib/permissions/compile.ts) takes the DSL list and produces a CompiledPermission ready for account.grantPermissions(...). It enforces fail-closed semantics: every agent must have at least one spend.

import { compilePermissions } from "../lib/permissions/index.js";
 
const compiled = await compilePermissions({
  permissions: [
    { 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 permission engine.

Revocation and immutability

Permissions are immutable per ERC-7715. To revoke an agent, the orchestrator (as co-owner of the agent's SA) signs a cross-SA execute call that invalidates the permission on the agent SA. Once revoked, that permissionId cannot be re-activated.

To change an agent's scope, retire the agent and create_agent with a new permission bundle. The ENS subname is preserved on chain so audit history survives; a new SA and new permissionId are minted. There is no permission_add operation; the immutability is a feature, not a limitation.

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 permission 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