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:
Per-tx, recipient, and contract rules are enforced inside Privy’s secure enclave. They’re encoded into a Privy policy at permission-creation time and validated on every signing request — there’s no path where Tally’s server can override them.
Daily caps are enforced by Tally before each payment, by querying the transaction log. We made that choice (rather than putting it in the enclave) to avoid hitting Privy’s per-app aggregation limit; the trade-off is that the daily cap depends on Tally being up, while the others don’t.
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.