SDKPythonagentagon.wallet

agentagon.wallet

Direct async client for the Agentagon Treasury service. Sub-wallet lifecycle: create, balance, pay (x402 EIP-3009), transfer (external), rotate, revoke. Use this when you want to talk to Treasury without going through Gate.

Overview

agentagon.wallet.Treasury is the right tool when you want to:

  • Create and manage sub-wallets directly (create_sub_wallet, rotate, revoke)
  • Sign x402 payments without proxying through Gate’s PAT layer
  • Build a buyer agent that holds its own Treasury bearer
  • Run policy-bound transfers (per-tx caps, daily caps, counterparty allowlists)
  • Run a custom HTTP client (proxy, custom timeouts, mTLS) against Treasury

It’s not the right tool when you want to:

  • Use a single PAT for both Gate and Treasury surfaces: use agentagon.sdk.Client which exposes list_wallets, balance, pay, transfer against the same PAT
  • Embed payment middleware in a service: use agentagon.seller

Install

pip install agentagon

The wallet client has no extra runtime deps beyond httpx (already a transitive dep of the package).

Quickstart

Connect with an existing Treasury bearer:

import os, asyncio
from agentagon.wallet import Treasury
 
async def main():
    async with Treasury.connect(api_key=os.environ["AGENTAGON_TREASURY_KEY"]) as t:
        sw = await t.create_sub_wallet(label="research-agent-1", chain="eip155:8453")
        b = await sw.balance()
        print(sw.address, b.balance_raw, "stale" if b.stale else "live")
 
asyncio.run(main())

Bootstrap a fresh Treasury if you don’t have one yet:

from agentagon.wallet import create_treasury, Treasury
 
async def main():
    result = await create_treasury(
        base_url="https://treasury.agentagon.ai",
        owner_type="agent",
        owner_id="my-agent",
    )
    print("save this once:", result.api_key)
    async with Treasury.connect(api_key=result.api_key) as t:
        sw = await t.create_sub_wallet(label="default")
        print(sw.address)

The api_key is shown once on bootstrap. Save it; there’s no recovery flow.

API reference

Treasury

class Treasury:
    @classmethod
    def connect(
        cls,
        *,
        api_key: str,
        base_url: str = "https://treasury.agentagon.ai",
        client: httpx.AsyncClient | None = None,
    ) -> "Treasury": ...
 
    async def create_sub_wallet(
        self,
        *,
        label: str,
        chain: str | None = None,
    ) -> SubWallet: ...
 
    async def get_sub_wallet(self, sub_wallet_id: str) -> SubWallet: ...
 
    async def aclose(self) -> None: ...

api_key is any credential the Treasury middleware accepts: a Treasury-scoped PAT (ag_pat_*), a session token (as_*), or a legacy master key (ag_treasury_sk_*). The legacy master keys were hard-cut on 2026-04-27 in production; new code should use PATs or sessions.

client injects a pre-built httpx.AsyncClient for tests or custom transport (proxy, mTLS). When omitted, Treasury.connect owns the lifecycle and aclose() closes it.

Treasury is an async context manager:

async with Treasury.connect(api_key="ag_pat_...") as t:
    ...
# underlying httpx.AsyncClient is closed on exit

create_sub_wallet(label, chain=None) mints a new sub-wallet. Omit chain to use the Treasury’s home_chain. Returns a SubWallet handle.

get_sub_wallet(sub_wallet_id) rehydrates a SubWallet by id (e.g. after a process restart).

SubWallet

class SubWallet:
    id: str
    chain: str
    address: str
    label: str
    status: str   # "active" | "revoked"
 
    async def balance(self) -> Balance: ...
    async def refresh(self) -> Balance: ...   # alias for balance()
    async def pay(
        self,
        *,
        to: str,
        amount_usd: str,
        capability: str | None = None,
        trace_id: str = "",
        valid_before: int = 0,
    ) -> SignedPayment: ...
    async def transfer(
        self,
        *,
        to: str,
        amount_usd: str,
        trace_id: str = "",
        valid_before: int = 0,
    ) -> SignedTransfer: ...
    async def rotate(self) -> "SubWallet": ...
    async def revoke(self) -> "SubWallet": ...

balance() / refresh()

b = await sw.balance()
print(b.balance_raw, b.balance_pending_raw, b.stale, b.updated_at)

Hits Treasury on every call. Treasury falls back to its on-chain cache when the upstream RPC is unreachable and sets stale=True. Surface that to your users.

pay()

signed = await sw.pay(
    to="0xSellerAddress",
    amount_usd="0.05",
    capability="research.company",
)

Policy-check + sign an x402 EIP-3009 TransferWithAuthorization. Returns a SignedPayment; the caller hands authorization + signature to a facilitator (or base64-encodes them into an X-PAYMENT header for an x402 seller).

Policy rules applied: per_tx_cap_usd, daily_cap_usd, weekly_cap_usd, monthly_cap_usd, velocity_cap, counterparty_allowlist, counterparty_blocklist, cooling_off, and capability_cap_usd (matched against capability).

transfer()

signed = await sw.transfer(to="0xPayee", amount_usd="10.00")

Sign an external EIP-3009 transfer with no x402 framing. Use for cold-storage withdrawals, buybacks, or any direct sub-wallet to external flow. The result is a SignedTransfer (no capability or scheme field).

⚠️

A transfer carries no capability tag, so a capability_cap_usd rule cannot meaningfully bound it. Treasury rejects transfer() with TreasuryAPIError(403) and reason transfer_not_permitted_with_capability_cap if the policy includes such a rule. Use per_tx_cap_usd + daily_cap_usd if you want transfers bounded by dollar limits.

rotate()

await sw.rotate()
print("new address:", sw.address)

Re-issues the sub-wallet’s DEK, preserving history. id, label, and chain stay stable so historical policy decisions and ledger rows remain queryable. address changes (new DEK, new derived address). Any on-chain funds at the old address are orphaned unless you sweep first.

Rotating a revoked sub-wallet raises TreasuryAPIError(409, error=sub_wallet_revoked).

revoke()

await sw.revoke()
print(sw.status)   # "revoked"

Flips the sub-wallet to revoked. Idempotent: revoking a revoked sub-wallet is a 200 with the same body. After revoke, balance() and pay() raise TreasuryAPIError(410, error=sub_wallet_revoked).

Types

@dataclass(frozen=True)
class Balance:
    sub_wallet_id: str
    chain: str
    balance_raw: str           # token base units (USDC at 6 decimals: "10000" = $0.01)
    balance_pending_raw: str
    stale: bool                # True when Treasury fell back to its cache
    updated_at: str | None
 
@dataclass(frozen=True)
class SignedPayment:
    sub_wallet_id: str
    from_address: str
    to_address: str
    chain: str
    amount_raw: str
    authorization: dict[str, Any]
    signature: str
    trace_id: str
    decision_id: str
    capability: str | None
    scheme: str | None         # "exact" | "upto"
 
@dataclass(frozen=True)
class SignedTransfer:
    sub_wallet_id: str
    from_address: str
    to_address: str
    chain: str
    amount_raw: str
    authorization: dict[str, Any]
    signature: str
    trace_id: str
    decision_id: str
 
@dataclass(frozen=True)
class CreateTreasuryResult:
    treasury_id: str
    owner_type: str
    owner_id: str
    home_chain: str
    plan_tier: str
    status: str
    api_key: str               # shown once at bootstrap; save it
    api_key_id: str
    scopes: list[str]
 
class TreasuryAPIError(RuntimeError):
    status_code: int
    body: dict

create_treasury

async def create_treasury(
    *,
    base_url: str = "https://treasury.agentagon.ai",
    owner_type: str,                # "agent" | "user" | "service"
    owner_id: str,
    client: httpx.AsyncClient | None = None,
) -> CreateTreasuryResult: ...

Bootstrap a fresh Treasury account. Returns a CreateTreasuryResult with api_key shown once. Save it; there’s no recovery flow.

Cookbook

Bootstrap, create a sub-wallet, sign a payment

import asyncio
from agentagon.wallet import Treasury, create_treasury
 
async def main():
    bootstrap = await create_treasury(
        owner_type="agent",
        owner_id="research-agent-1",
        base_url="https://treasury.agentagon.ai",
    )
    print("save this:", bootstrap.api_key)
 
    async with Treasury.connect(api_key=bootstrap.api_key) as t:
        sw = await t.create_sub_wallet(label="default", chain="eip155:8453")
        # ...top up sw.address with USDC out-of-band...
        signed = await sw.pay(
            to="0xSellerAddress",
            amount_usd="0.05",
            capability="research.company",
        )
        print("decision:", signed.decision_id)
 
asyncio.run(main())

Sweep before rotate

async def rotate_with_sweep(sw):
    # 1. Drain to a custodian or your own EOA before rotating
    cold = "0xColdStorage"
    bal = await sw.balance()
    if int(bal.balance_raw) > 0:
        # Treasury policy may cap this; check the result
        await sw.transfer(to=cold, amount_usd=raw_to_usd(bal.balance_raw))
    # 2. Now rotate; the old address is orphaned with zero balance
    await sw.rotate()
    return sw
 
def raw_to_usd(raw: str, decimals: int = 6) -> str:
    units = int(raw)
    return f"{units / 10**decimals:.{decimals}f}".rstrip("0").rstrip(".")

The orphan rule is hard: anything left on the old address after rotate is unreachable. Sweep first, always.

Handle a stale balance gracefully

async def fresh_balance(sw, max_age_seconds=30):
    from datetime import datetime, timezone
    b = await sw.balance()
    if not b.stale:
        return b
    if not b.updated_at:
        # No timestamp; surface the staleness without trying to age-check
        return b
    age = datetime.now(timezone.utc) - datetime.fromisoformat(b.updated_at)
    if age.total_seconds() <= max_age_seconds:
        return b
    raise RuntimeError(f"balance stale for {age.total_seconds():.0f}s, RPC outage?")

The on-chain RPC outage is the most common cause; surface the staleness rather than silently treating cached figures as live.

Inject a custom transport for tests

import httpx
from agentagon.wallet import Treasury
 
def handler(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/v1/wallets/sw_1/balance":
        return httpx.Response(200, json={
            "sub_wallet_id": "sw_1",
            "chain": "eip155:8453",
            "balance_raw": "1000000",
            "balance_pending_raw": "0",
            "stale": False,
            "updated_at": "2026-04-29T00:00:00Z",
        })
    return httpx.Response(404)
 
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t.test")
treasury = Treasury(api_key="ag_pat_test", base_url="http://t.test", client=client)
# tests...
await client.aclose()

When you pass client=, Treasury does not own its lifecycle; you close it.

Rotate-then-pay flow

async def rotate_and_resume(sw, *, to, amount_usd, capability):
    await sw.rotate()                     # mutate sw in place; new address
    # Top up sw.address with USDC out-of-band, then:
    return await sw.pay(to=to, amount_usd=amount_usd, capability=capability)

rotate() mutates the SubWallet instance (updates address, status); the same handle keeps working.

Concurrent payments

import asyncio
 
async def fan_out(sw, batch):
    return await asyncio.gather(*[
        sw.pay(to=item["to"], amount_usd=item["amount_usd"], capability=item["capability"])
        for item in batch
    ])

The same SubWallet handles concurrent calls cleanly: each request gets its own decision_id and signature. The tests pin this with a 5-way fan-out.

Errors

Every non-2xx surfaces as TreasuryAPIError:

class TreasuryAPIError(RuntimeError):
    status_code: int
    body: dict

Catch it and inspect; never parse str(exc).

from agentagon.wallet import TreasuryAPIError
 
try:
    await sw.pay(to="0x...", amount_usd="100.00")
except TreasuryAPIError as exc:
    if exc.status_code == 403 and exc.body.get("reason") == "daily_cap_exceeded":
        # backoff, raise the cap, or wait for the next day
        ...
    elif exc.status_code == 402 and exc.body.get("reason") == "insufficient_balance":
        # body carries available_raw and required_raw
        topup = int(exc.body["required_raw"]) - int(exc.body["available_raw"])
        ...
    else:
        raise

Catalog

Statusreason / errorWhenBody fields
402insufficient_balanceSub-wallet balance below amount_usdavailable_raw, required_raw
403policy_deniedCapability cap, allowlist, blocklist, etc.decision_id, reason
403daily_cap_exceededSpend exceeds daily_cap_usddecision_id, reason
403transfer_not_permitted_with_capability_captransfer() against a policy with capability_cap_usddecision_id
409sub_wallet_revokedrotate() on a revoked sub-wallet(none)
410sub_wallet_revokedbalance() / pay() / transfer() after revoke(none)
422(varies)Bad request body (e.g. non-EVM to on EVM chain)error

Troubleshooting

”balance.stale=True won’t clear”

Treasury falls back to its cache when the upstream RPC is unreachable. The flag stays True until the next successful read. Don’t poll faster than once every few seconds; you’ll just keep hitting the same cached row. Watch your provider’s status page or accept that the figure is old until RPC returns.

”I lost the api_key after create_treasury

There’s no recovery flow. The bearer is shown once and never again. You’ll need to claim a new Treasury (or recover via the dashboard’s claim flow if one is set up for your owner type). For PATs minted later via Gate (POST /v1/treasuries/{id}/pats), the key shows once at creation too.

”I rotated and now the old address has my USDC”

That balance is orphaned. The DEK is gone; nothing can sign for the old address again. Always sweep before rotating. See the sweep cookbook.

”transfer() returns 403 with transfer_not_permitted_with_capability_cap

Your sub-wallet’s policy includes a capability_cap_usd rule. Transfers carry no capability tag, so the rule can’t meaningfully bound them; rejecting fail-closed is safer than letting an unbounded transfer through. Use per_tx_cap_usd + daily_cap_usd for dollar limits if you want transfers allowed.

”I want to re-derive a SubWallet handle after a process restart”

Use treasury.get_sub_wallet(sub_wallet_id):

sw = await treasury.get_sub_wallet("sw_xxx")
b = await sw.balance()

Or store the metadata yourself and reconstruct:

from agentagon.wallet import SubWallet
 
sw = SubWallet(
    treasury,
    sub_wallet_id="sw_xxx",
    chain="eip155:8453",
    address="0xabc",
    label="research-agent-1",
    status="active",
)

The handle is just metadata; only the bearer on the parent Treasury matters for authorization.

Solana / Tempo support

EVM (Base, Base Sepolia) is the only chain pay() and transfer() work on today. Solana and Tempo signing paths land in a follow-up phase; the chain field on Treasury.create_sub_wallet accepts Solana CAIP-2 identifiers but signing operations will return 422 until implemented.