Mental model
Each permission ties three things together: an agent, a wallet, and a policy.“Allow agent X to spend from wallet Y subject to policy Z” — signed with your passkey.Permissions are wallet-scoped, not just agent-scoped. The same agent can hold five permissions on five different wallets, each with completely different terms. Revoking one permission doesn’t affect the others. The reason this matters: you can reason about authorization at the wallet level — which is the level money actually moves at — rather than at a fuzzier “agent” level.
What a policy bounds
Each permission has exactly one policy. The fields are:| Field | What it bounds |
|---|---|
max_per_tx_usdc | Required. Largest single transfer this grant allows. |
daily_cap_usdc | Optional. Rolling 24-hour spend cap on top of per-tx limits. |
recipient_allowlist | Optional. If set, transfers can only go to addresses on this list. |
contract_allowlist | Defaults to USDC. The contracts this signer can interact with. |
expires_at | Optional. After this time, the grant stops working until rotated or extended. |
Creating a permission
Permissions are created from the dashboard today. You pick an agent, pick a wallet, pick the policy terms, and sign the addition with your passkey. Under the hood:- Dashboard calls
POST /api/agents/[id]/permissionswith the wallet, the per-tx max, and any optional policy fields. - Tally generates a fresh P-256 keypair for this (agent, wallet) pair, envelope-encrypts the private half, registers the public half with Privy as an additional signer, and creates a Privy policy.
- The signer is created in a pending state —
activatedAtis null until you authorize it on-chain. - Your passkey is prompted through Privy’s React SDK, which calls
addSigners({ address, signers })to attach the signer to the wallet. - The dashboard hits
POST /api/agents/[id]/permissions/[signerId]/activateto flip the AgentSigner to active.
pending and produces no spending power.
Programmatic creation (via the SDK, without dashboard interaction) isn’t supported today — every permission requires an interactive passkey signature from the wallet owner. The roadmap has a server-prepare → user-confirm variant for cases where you’ve already approved an out-of-band confirmation flow.
Inspecting permissions and allowance
Active permissions are visible on the agent detail page in the dashboard — one card per (agent, wallet) pair, with the policy summary, recent activity, and remaining rolling-window allowance. Programmatic access viatally.permissions.list({ agent_id }) returns the same shape inline, including remaining_today_usdc so agent code can preflight a payment:
Editing a policy
Policies can be updated in place from the dashboard — pick a permission, change the per-tx max or daily cap or expiry, and sign the change with your passkey. The sameAgentSigner row stays in place; only the policy fields change, and the update is mirrored to Privy via walletApi.updatePolicy(). Every change is recorded in the policy change log.
What you can’t do via edit: swap the signing key. That’s a rotation, covered below.
Rotating a permission
Rotation creates a fresh signing key while keeping the policy bounds identical:Revoking a permission
You can revoke a permission at any time from the dashboard. Revocation removes the signer from the wallet on-chain via Privy and flipsrevokedAt in Tally’s records. Effect is immediate: no new payments will be authorized for the revoked permission.
Revocation isn’t fully server-side — removing a signer from a Privy wallet requires the wallet owner’s passkey signature — so it happens through the dashboard today. An SDK affordance that orchestrates the flow (server-prepare → user-confirm) is on the roadmap.
In-flight payments that already passed the policy check may still confirm on-chain (the network can’t be paused mid-transaction). Anything that hasn’t been signed yet is dead.
Handling policy violations
When a payment violates the grant’s policy,tally.payments.create() throws with a structured err.code you can branch on. The exact codes are:
amount_too_large— request exceeds the per-tx max.daily_cap_exceeded— request would push rolling 24h spend over the cap.recipient_not_allowed—tois not in the recipient allowlist.contract_not_allowed— destination contract isn’t on the contract allowlist.permission_expired— the grant’sexpires_athas passed.permission_not_found— the agent has no active grant on this wallet.
forbidden, which the SDK wraps as AuthenticationError. The class name is awkward — the semantic distinction lives in err.code. See SDK errors for the full mapping.