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.Clientwhich exposeslist_wallets,balance,pay,transferagainst the same PAT - Embed payment middleware in a service: use
agentagon.seller
Install
pip install agentagonThe 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 exitcreate_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: dictcreate_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: dictCatch 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:
raiseCatalog
| Status | reason / error | When | Body fields |
|---|---|---|---|
| 402 | insufficient_balance | Sub-wallet balance below amount_usd | available_raw, required_raw |
| 403 | policy_denied | Capability cap, allowlist, blocklist, etc. | decision_id, reason |
| 403 | daily_cap_exceeded | Spend exceeds daily_cap_usd | decision_id, reason |
| 403 | transfer_not_permitted_with_capability_cap | transfer() against a policy with capability_cap_usd | decision_id |
| 409 | sub_wallet_revoked | rotate() on a revoked sub-wallet | (none) |
| 410 | sub_wallet_revoked | balance() / 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.