@agentagon/sdk/wallet
TypeScript / Node client for the Agentagon Treasury service. Mirror of agentagon.wallet (Python) with the same shape: sub-wallet lifecycle, x402 pay, external transfer, rotate, revoke.
Overview
Use @agentagon/sdk/wallet when you want to:
- Create and manage sub-wallets directly
- 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)
- Inject a custom
fetchfor tests, proxies, or mTLS
It’s not the right tool when you want to:
- Use a single PAT for both Gate and Treasury surfaces: use
@agentagon/sdk/clientwhich exposeslistWallets,balance,pay,transferagainst the same PAT - Embed payment middleware in a service: use
@agentagon/sdk/seller
Install
npm install @agentagon/sdkRequires Node.js 18+ (native fetch). Zero runtime dependencies.
Quickstart
Connect with an existing Treasury bearer:
import { Treasury } from "@agentagon/sdk/wallet";
const t = Treasury.connect({ apiKey: process.env.AGENTAGON_TREASURY_KEY! });
const sw = await t.createSubWallet({ label: "research-agent-1", chain: "eip155:8453" });
const b = await sw.balance();
console.log(sw.address, b.balanceRaw, b.stale ? "stale" : "live");Bootstrap a fresh Treasury if you don’t have one yet:
import { createTreasury, Treasury } from "@agentagon/sdk/wallet";
const account = await createTreasury({
ownerType: "agent",
ownerId: "my-agent",
baseUrl: "https://treasury.agentagon.ai",
});
console.log("save this once:", account.apiKey);
const t = Treasury.connect({ apiKey: account.apiKey });
const sw = await t.createSubWallet({ label: "default" });The apiKey is shown once on bootstrap. Save it; there’s no recovery flow.
API reference
Treasury
class Treasury {
static connect(opts: {
apiKey: string;
baseUrl?: string; // default "https://treasury.agentagon.ai"
fetch?: typeof fetch;
}): Treasury;
createSubWallet(opts: { label: string; chain?: string }): Promise<SubWallet>;
getSubWallet(subWalletId: string): Promise<SubWallet>;
}apiKey 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.
fetch injects a custom fetch implementation for tests or custom transport.
createSubWallet({ label, chain }) mints a new sub-wallet. Omit chain to use the Treasury’s homeChain. Returns a SubWallet handle.
getSubWallet(subWalletId) rehydrates a SubWallet by id (e.g. after a process restart).
SubWallet
class SubWallet {
readonly subWalletId: string;
readonly treasuryId: string;
readonly chain: string;
address: string; // mutated by rotate()
readonly label: string;
status: string; // "active" | "revoked", mutated by revoke()
balance(): Promise<Balance>;
refresh(): Promise<Balance>; // alias for balance()
pay(opts: PayOptions): Promise<SignedPayment>;
transfer(opts: TransferOptions): Promise<SignedTransfer>;
rotate(): Promise<SubWallet>;
revoke(): Promise<SubWallet>;
}balance() / refresh()
const b = await sw.balance();
console.log(b.balanceRaw, b.balancePendingRaw, b.stale, b.updatedAt);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()
const signed = await sw.pay({
to: "0xSellerAddress",
amountUsd: "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).
Policy rules applied: perTxCapUsd, dailyCapUsd, weeklyCapUsd, monthlyCapUsd, velocityCap, counterpartyAllowlist, counterpartyBlocklist, coolingOff, and capabilityCapUsd (matched against capability).
transfer()
const signed = await sw.transfer({ to: "0xPayee", amountUsd: "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 capabilityCapUsd 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 perTxCapUsd + dailyCapUsd if you want transfers bounded by dollar limits.
rotate()
await sw.rotate();
console.log("new address:", sw.address);Re-issues the sub-wallet’s DEK, preserving history. subWalletId, label, and chain stay stable; address changes. Any on-chain funds at the old address are orphaned unless you sweep first.
Rotating a revoked sub-wallet throws TreasuryAPIError(409) with error: "sub_wallet_revoked".
revoke()
await sw.revoke();
console.log(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() throw TreasuryAPIError(410, error: "sub_wallet_revoked").
Types
interface Balance {
subWalletId: string;
chain: string;
balanceRaw: string; // token base units (USDC at 6 decimals: "10000" = $0.01)
balancePendingRaw: string;
stale: boolean;
updatedAt: string | null;
}
interface PayOptions {
to: string;
amountUsd: string;
capability?: string;
traceId?: string;
validBefore?: number;
}
interface TransferOptions {
to: string;
amountUsd: string;
traceId?: string;
validBefore?: number;
}
interface SignedPayment {
subWalletId: string;
fromAddress: string;
toAddress: string;
chain: string;
amountRaw: string;
authorization: Record<string, unknown>;
signature: string;
traceId: string;
decisionId: string;
capability: string | null;
scheme: string | null; // "exact" | "upto"
}
interface SignedTransfer {
subWalletId: string;
fromAddress: string;
toAddress: string;
chain: string;
amountRaw: string;
authorization: Record<string, unknown>;
signature: string;
traceId: string;
decisionId: string;
}
interface CreateTreasuryResult {
treasuryId: string;
ownerType: string;
ownerId: string;
homeChain: string;
planTier: string;
status: string;
apiKey: string; // shown once at bootstrap; save it
apiKeyId: string;
scopes: string[];
}
class TreasuryAPIError extends Error {
readonly statusCode: number;
readonly body: Record<string, unknown>;
}createTreasury
async function createTreasury(opts: {
ownerType: "agent" | "user" | "service";
ownerId: string;
baseUrl?: string;
fetch?: typeof fetch;
}): Promise<CreateTreasuryResult>;Bootstrap a fresh Treasury account. Returns a CreateTreasuryResult with apiKey shown once. Save it; there’s no recovery flow.
Cookbook
Bootstrap, create a sub-wallet, sign a payment
import { createTreasury, Treasury } from "@agentagon/sdk/wallet";
const account = await createTreasury({
ownerType: "agent",
ownerId: "research-agent-1",
});
console.log("save this:", account.apiKey);
const t = Treasury.connect({ apiKey: account.apiKey });
const sw = await t.createSubWallet({ label: "default", chain: "eip155:8453" });
// ...top up sw.address with USDC out-of-band...
const signed = await sw.pay({
to: "0xSellerAddress",
amountUsd: "0.05",
capability: "research.company",
});
console.log("decision:", signed.decisionId);Sweep before rotate
import type { SubWallet } from "@agentagon/sdk/wallet";
async function rotateWithSweep(sw: SubWallet, coldStorage: string) {
const bal = await sw.balance();
if (BigInt(bal.balanceRaw) > 0n) {
await sw.transfer({ to: coldStorage, amountUsd: rawToUsd(bal.balanceRaw) });
}
await sw.rotate();
return sw;
}
function rawToUsd(raw: string, decimals = 6): string {
const units = BigInt(raw);
const denom = 10n ** BigInt(decimals);
const whole = units / denom;
const frac = units % denom;
return frac === 0n
? whole.toString()
: `${whole}.${frac.toString().padStart(decimals, "0").replace(/0+$/, "")}`;
}The orphan rule is hard: anything left on the old address after rotate is unreachable. Sweep first, always.
Handle a stale balance gracefully
import type { SubWallet, Balance } from "@agentagon/sdk/wallet";
async function freshBalance(sw: SubWallet, maxAgeSeconds = 30): Promise<Balance> {
const b = await sw.balance();
if (!b.stale || !b.updatedAt) return b;
const ageMs = Date.now() - new Date(b.updatedAt).getTime();
if (ageMs / 1000 <= maxAgeSeconds) return b;
throw new Error(`balance stale for ${(ageMs / 1000).toFixed(0)}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 fetch for tests
import { Treasury } from "@agentagon/sdk/wallet";
const t = Treasury.connect({
apiKey: "ag_pat_test",
baseUrl: "http://t.test",
fetch: async (input, _init) => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/v1/wallets/sw_1/balance")) {
return new Response(
JSON.stringify({
sub_wallet_id: "sw_1",
chain: "eip155:8453",
balance_raw: "1000000",
balance_pending_raw: "0",
stale: false,
updated_at: "2026-04-29T00:00:00Z",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
},
});The fetch you inject is responsible for everything: URL routing, headers, body parsing.
Rotate-then-pay flow
async function rotateAndResume(sw: SubWallet, opts: PayOptions) {
await sw.rotate();
// Top up sw.address with USDC out-of-band, then:
return await sw.pay(opts);
}rotate() mutates the SubWallet instance (updates address, status); the same handle keeps working.
Concurrent payments
async function fanOut(sw: SubWallet, batch: PayOptions[]) {
return Promise.all(batch.map((opts) => sw.pay(opts)));
}The same SubWallet handles concurrent calls cleanly: each request gets its own decisionId and signature. The tests pin this with a 5-way fan-out.
Custom proxy / mTLS
import { Agent, fetch as undiciFetch } from "undici";
const proxyAgent = new Agent({
connect: {
cert: process.env.MTLS_CERT,
key: process.env.MTLS_KEY,
},
});
const t = Treasury.connect({
apiKey: process.env.AGENTAGON_TREASURY_KEY!,
fetch: ((input, init) =>
undiciFetch(input as string, { ...init, dispatcher: proxyAgent })) as typeof fetch,
});Pass any fetch-shaped function. undici is the canonical pick for advanced transport in Node; the SDK is happy with any drop-in.
Errors
Every non-2xx throws TreasuryAPIError:
class TreasuryAPIError extends Error {
readonly statusCode: number;
readonly body: Record<string, unknown>;
}Catch with instanceof; never parse err.message.
import { TreasuryAPIError } from "@agentagon/sdk/wallet";
try {
await sw.pay({ to: "0x...", amountUsd: "100.00" });
} catch (err) {
if (!(err instanceof TreasuryAPIError)) throw err;
if (err.statusCode === 403 && err.body.reason === "daily_cap_exceeded") {
// backoff or raise the cap
} else if (err.statusCode === 402 && err.body.reason === "insufficient_balance") {
const topup =
BigInt(err.body.required_raw as string) - BigInt(err.body.available_raw as string);
} else {
throw err;
}
}Catalog
| Status | reason / error | When | Body fields |
|---|---|---|---|
| 402 | insufficient_balance | Sub-wallet balance below amountUsd | available_raw, required_raw |
| 403 | policy_denied | Capability cap, allowlist, blocklist, etc. | decision_id, reason |
| 403 | daily_cap_exceeded | Spend exceeds dailyCapUsd | decision_id, reason |
| 403 | transfer_not_permitted_with_capability_cap | transfer() against a policy with capabilityCapUsd | 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 | 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.
”I lost the apiKey after createTreasury”
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.
”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 capabilityCapUsd rule. Transfers carry no capability tag, so the rule can’t bound them; rejecting fail-closed is safer than letting an unbounded transfer through. Use perTxCapUsd + dailyCapUsd for dollar limits.
”I want to re-derive a SubWallet handle after a process restart”
Use treasury.getSubWallet(subWalletId):
const sw = await t.getSubWallet("sw_xxx");
const b = await sw.balance();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.createSubWallet accepts Solana CAIP-2 identifiers but signing operations will return 422 until implemented.