Skip to main content
Permissions are the linchpin of Tally’s trust model. Every payment is validated against a permission you’ve explicitly signed for. No permission, no spend. Not even Tally can override the rule.

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:
FieldWhat it bounds
max_per_tx_usdcRequired. Largest single transfer this grant allows.
daily_cap_usdcOptional. Rolling 24-hour spend cap on top of per-tx limits.
recipient_allowlistOptional. If set, transfers can only go to addresses on this list.
contract_allowlistDefaults to USDC. The contracts this signer can interact with.
expires_atOptional. After this time, the grant stops working until rotated or extended.
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:
  1. Dashboard calls POST /api/agents/[id]/permissions with the wallet, the per-tx max, and any optional policy fields.
  2. 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.
  3. The signer is created in a pending stateactivatedAt is null until you authorize it on-chain.
  4. Your passkey is prompted through Privy’s React SDK, which calls addSigners({ address, signers }) to attach the signer to the wallet.
  5. The dashboard hits POST /api/agents/[id]/permissions/[signerId]/activate to flip the AgentSigner to active.
After step 5, the agent can spend from that wallet — subject to the policy. Until step 4 lands, the signer counts as 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 via tally.permissions.list({ agent_id }) returns the same shape inline, including remaining_today_usdc so agent code can preflight a payment:
for await (const p of tally.permissions.list({ agent_id: "research-bot" })) {
  console.log(`${p.wallet_display_name}: $${p.remaining_today_usdc} left today`);
}
If you’d rather attempt the payment and branch on the structured error, that pattern still works:
try {
  await tally.payments.create({ agent_id, wallet, to, amount_usdc });
} catch (err) {
  if (err.code === "insufficient_allowance") {
    // Prompt the user to top up the grant from the dashboard.
  }
  throw err;
}

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 same AgentSigner 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:
old permission → revoked (signing key retired)
new permission → created with identical terms, fresh P-256 keypair
Use rotation when you suspect a signing key has been compromised, or as part of a routine key-rotation policy. The permission terms (per-tx max, recipient list, etc.) must mirror the source exactly; rotation changes only the key, not what the key can do. To change the terms, edit the policy in place instead.

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 flips revokedAt 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_allowedto is not in the recipient allowlist.
  • contract_not_allowed — destination contract isn’t on the contract allowlist.
  • permission_expired — the grant’s expires_at has passed.
  • permission_not_found — the agent has no active grant on this wallet.
try {
  await tally.payments.create({ agent_id, wallet, to, amount_usdc });
} catch (err) {
  if (err.code === "amount_too_large" || err.code === "daily_cap_exceeded") {
    // Prompt the user to top up or extend the grant.
  }
  throw err;
}
All five policy-violation codes come back as HTTP 403 / 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.

Why on-chain enforcement matters

If Tally went away tomorrow, you’d still control your wallet via Privy directly. Permissions persist on-chain; you can revoke them, edit them, or just let them ride. Tally doesn’t become a custodian — and can’t, because the rules every signer is subject to are enforced by Privy’s enclave, not Tally’s server. This is the property we’re trading some convenience for. It’s also why agent payments cap out at the policy: even if Tally’s infrastructure is fully compromised, the worst an attacker can do is sign payments you’ve already authorized.