Documentation

Developer docs

How to give an agent a wallet, a budget and a way to ask for money, and what stops it when it asks for too much.

On this page

Quickstart

From nothing to a first payment

You need a wallet to own the agent and some USDG or ETH to fund it. The first three steps happen in the dashboard and your wallet. The last one is code.

1. Create an agent

Open Create Agent Wallet and connect the wallet that will own the agent. AgentSpend works out a smart account address with your wallet as the root signer. The address commits to your wallet, so whoever relays the deployment cannot change who controls it.

2. Fund it

Send USDG or ETH to the agent's wallet address. Funds stay in your smart account, and the owner keeps ultimate control.

3. Issue a session key

Set the budget, the vendors and contracts the agent may use, the assets it may spend and an expiry date. Your wallet signs all of it as one EIP-712 message, and the rules go onchain.

The dashboard then shows the agent's credential once, and AgentSpend stores only its hash. It starts with as_live_, or as_test_ on testnet and local chains. Keep it with your agent's other secrets.

4. Make the first payment

Terminal
npm install @agentspend/sdk
pay.ts
import { AgentSpend } from "@agentspend/sdk";

const spend = new AgentSpend({
  agentId: process.env.AGENTSPEND_AGENT_ID,
  sessionKey: process.env.AGENTSPEND_API_KEY,
  baseUrl: process.env.AGENTSPEND_API_URL,
});

const payment = await spend.pay({
  vendor: "gpufarm",
  amount: "15",
  asset: "USDG",
  purpose: "compute",
});

if (payment.decision === "APPROVED") {
  const done = await spend.waitForExecution(payment.intentId);
  console.log(done.status, done.txHash); // EXECUTED 0x…
}
sessionKey is not a chain key. Despite the name, it is the agent's scoped API credential. Agents hold no chain key in this model. The constructor refuses anything that does not look like a credential, so a private key passed by mistake is never sent.

pay returns the policy's decision. A refusal is an answer, not an error, so nothing is thrown. Always read decision.

APPROVED
Queued for execution, not finished yet. waitForExecution tells you when it lands.
REQUIRES_APPROVAL
A person has to decide. The request stays open for 24 hours.
BLOCKED
The policy refused. Nothing was signed or spent, and sending it again will not change the answer.

Concepts

The five ideas behind every request

Smart account

Each agent gets its own ERC-4337 smart account, built on ZeroDev Kernel v3.1 on Robinhood Chain. Your wallet is the root signer. The AgentSpend policy module is installed next to it and is the only thing that can accept a session key.

Funds stay in your smart account, and the owner keeps ultimate control. You can call the account directly from your wallet at any time, with no AgentSpend service involved.

Session key

A session key is a signing key with a policy attached. AgentSpend's signing service generates it, stores it encrypted, and uses it only after a request has passed the policy. Your agent never sees it.

The agent holds a scoped API credential instead, which is what the SDK's sessionKey option takes. A credential reaches one agent's wallet, budget, approvals and expenses. It holds no chain key, so a stolen credential can only ask.

  • Every session key has an expiry date.
  • Rotating a key swaps the signer and nothing else. Expiry, limits, rules and budget counters stay as they are.
  • Freezing an agent revokes every key it has, onchain, with the key's own last signature, so no wallet prompt is needed. The key material is then destroyed.
  • Unfreezing never restores spending. The owner has to issue a new key, with a new signature.

Policy

A policy says what an agent may do. Part of it is enforced onchain by the module, against rules the owner signed. All of it is enforced by the policy engine before anything is signed.

Onchain and in the engine
Validity window, per-transaction, daily, weekly and monthly caps, transactions per day, gas per day, allowed contracts and selectors, allowed assets, allowed and blocked recipients, and whether arbitrary transfers or unlimited approvals are allowed.
In the engine only
Live USD valuation, simulation, slippage limits, blocked contracts, the approval threshold, how many approvers must agree, group budgets, and whether an unknown recipient is blocked or sent to a person.

Tightening a policy applies at once with no signature, because it can only reduce what the agent may do. The engine enforces a tightened policy, and the onchain rules stay as they were until the owner signs a new version. Anything that widens a policy stays a draft until the owner signs it. Changing a policy never refills a spent budget.

Budget windows

Budgets run in UTC, and the module and the engine use the same windows.

Daily
Starts at 00:00 UTC.
Weekly
Starts on Monday at 00:00 UTC.
Monthly
A calendar month, starting on the 1st at 00:00 UTC.

The engine reserves a spend against every window at the moment it approves the request, so simultaneous requests cannot overspend. If the request then fails, the reservation is released.

Onchain, an operation that passes validation is charged during validation. If its execution then fails, that charge stays until the window rolls over. Failing closed is deliberate.

Rate snapshot

Onchain caps are in USD, but a contract cannot read a market price while it validates an operation. So each allowed asset carries a fixed rate that the owner signs at issuance, usd6PerUnitE18. This is what makes a daily cap one cap across all assets rather than one per asset.

That rate does not move with the market. The policy engine values each request at the live price, using Chainlink feeds for ETH and USDG and Robinhood quotes for Stock Tokens, and it takes the larger of what the calldata declares and what simulation shows leaving the wallet. A request has to pass both. If an asset moves far from its signed rate, sign a new one.

USD amounts in the API come in two forms: micro-dollars as an integer string, where "15000000" is $15, and dollars as a decimal string, "15".

API reference

Eight endpoints, one credential

Requests go to your AgentSpend host. The SDK takes it from baseUrl, then from AGENTSPEND_API_URL. Bodies and responses are JSON.

Authentication

Send the agent's credential as a bearer token. A credential reaches one agent. Any other agent's id gets the same 404 as an id that does not exist.

Every request
curl "$AGENTSPEND_API_URL/v1/agents/$AGENT_ID/budget" \
  -H "Authorization: Bearer as_live_…"

Errors

A decision is not an error. APPROVED, REQUIRES_APPROVAL and BLOCKED all come back as 200. Everything else that goes wrong comes back as JSON with one plain sentence.

{ "error": "This Idempotency-Key was already used for a different request" }
400
The request is malformed. The message names the field. Unknown fields are refused.
401
The credential is missing, wrong or revoked.
404
Not found, or it belongs to another agent. The API does not say which.
409
The Idempotency-Key was used for a different request, its first request is still being evaluated, or that attempt was stopped and a new key is needed.
429
A rate limit. Retry-After says how many seconds to wait.
503
AgentSpend could not finish the request and failed closed. The message says whether to try again or to read the intent first.

Rate limits

Limits are per agent, in fixed windows.

300 requestsper minute
Every authenticated request.
60 spend requestsper minute
Calls to POST /v1/payments and POST /v1/agents/:id/intent.
20 blocked requestsper 5 minutes
Requests the policy blocked. Once full, new spend requests are paused until the window ends.
20 approval requestsper hour
Requests sent to a person. Once full, new spend requests are paused until the window ends.

Idempotency-Key

Both POST routes accept an Idempotency-Key header of 1 to 255 visible ASCII characters, scoped to the agent. Repeating a request with the same key returns the recorded decision. It never runs the policy again, it cannot pay twice, and it does not count against the spend limits. The same key on a different request gets a 409.

The SDK sends a key with every pay and requestTransaction call, and reuses it when it retries. A new call gets a new key, so if your agent can crash and start the same payment again, pass your own idempotencyKey, derived from the work it belongs to.

Endpoints

POST/v1/agents/:id/intent

Asks to run one call, or a batch that runs together. Use this for anything that is not a plain vendor payment.

Body

targetaddress
The contract or wallet to call.
calldatahex, optional
The call data, up to 64 KB. Defaults to empty.
valuewei string, optional
ETH to send with the call. Defaults to 0.
callsarray, optional
Up to 16 calls of target, data and value, sent instead of the three fields above.
reasonstring, required
Why the agent wants this, up to 1,000 characters. Approvers see it.
purposestring, optional
Used to categorize the expense.
One call
POST /v1/agents/7c1e4a52-…/intent
Authorization: Bearer as_live_…
Idempotency-Key: data-2291
Content-Type: application/json

{
  "target": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  "calldata": "0xa9059cbb…",
  "value": "0",
  "reason": "Pay for 40 papers of data"
}
A batch
{
  "calls": [
    { "target": "0x5fc5…d168", "data": "0x095ea7b3…" },
    { "target": "0x9c1f…22ab", "data": "0x38ed1739…", "value": "0" }
  ],
  "reason": "Approve, then swap"
}

POST/v1/payments

Asks to pay a vendor. The agent names a vendor and an amount, and the server builds the transaction, so the agent never handles addresses.

Body

agentIdstring, required
Must be the credential's own agent.
vendorIdstring, required
A vendor slug or id.
amountdecimal string, required
Whole units of the asset, such as "15". More decimal places than the asset allows is refused, so it never pays a rounded amount.
currencystring, required
An approved asset symbol, such as USDG or ETH.
purposestring, required
What the money is for.
reasonstring, optional
Why the agent wants this. Approvers see it.
Request
POST /v1/payments
Authorization: Bearer as_live_…
Idempotency-Key: job-2291-gpu
Content-Type: application/json

{
  "agentId": "7c1e4a52-…",
  "vendorId": "gpufarm",
  "amount": "15",
  "currency": "USDG",
  "purpose": "compute",
  "reason": "Run a 40-minute GPU job"
}
Approved, shortened
{
  "intentId": "c41d7f06-8b13-4e2a-b5f4-6a90e17d3c28",
  "status": "APPROVED",
  "decision": "APPROVED",
  "estimatedUsdValue": "15000000",
  "estimatedUsd": "15",
  "policyChecks": [
    { "id": "recipient_allowed", "label": "Recipient valid", "status": "PASS" },
    { "id": "daily_budget", "label": "Under daily budget", "status": "PASS", "detail": "$8.20 used + $15.00 of $250.00" },
    { "id": "onchain_policy", "label": "Onchain rules agree", "status": "PASS" }
  ],
  "risk": "LOW",
  "findings": []
}
Blocked, shortened
{
  "intentId": "6f1c0a52-8f3e-4f6a-9d1b-2f7f0c3f8e11",
  "status": "BLOCKED",
  "decision": "BLOCKED",
  "reason": "Recipient not approved. Onchain rules would reject this.",
  "estimatedUsdValue": "50000000",
  "estimatedUsd": "50",
  "policyChecks": [
    { "id": "recipient_allowed", "label": "Recipient valid", "status": "FAIL", "detail": "0x7a00…0091 is not on the allowlist" },
    { "id": "onchain_policy", "label": "Onchain rules agree", "status": "FAIL", "detail": "Recipient is not on the onchain allowlist" }
  ],
  "risk": "HIGH",
  "findings": ["First payment to this counterparty"]
}

The decision

Both POST routes return the same object.

intentId
The request's id. Use it to read its status or its approval.
status
Where the request is now. APPROVED means queued for execution, not finished.
decision
APPROVED, REQUIRES_APPROVAL or BLOCKED.
reason
Why it was blocked, or why a person has to decide.
estimatedUsdValue, estimatedUsd
The USD value in micro-dollars and in dollars.
policyChecks
One row per rule: id, label, status of PASS, FAIL, WARN or SKIP, and an optional detail.
risk
LOW, MEDIUM or HIGH.
findings
Things worth a person's attention that did not change the decision.
simulation
What the transaction would do: token movements, approval changes and gas.

GET/v1/agents/:id/balance

The wallet's balances in every approved asset. amount is in base units and formatted is in whole units.

Response
{
  "agentId": "7c1e4a52-…",
  "wallet": "0x81…3A1",
  "balances": [
    { "asset": "0x5fc5…d168", "symbol": "USDG", "name": "Global Dollar", "decimals": 6, "kind": "stablecoin", "amount": "96000000", "formatted": "96" }
  ]
}

GET/v1/agents/:id/budget

Each budget window as it stands. spentUsd is settled, and reservedUsd is approved but not settled yet.

Response, one window shown
{
  "agentId": "7c1e4a52-…",
  "budgets": [
    {
      "period": "day",
      "limitUsd": "250",
      "spentUsd": "8.2",
      "reservedUsd": "15",
      "remainingUsd": "226.8",
      "limitUsd6": "250000000",
      "settledUsd6": "8200000",
      "reservedUsd6": "15000000",
      "remainingUsd6": "226800000",
      "windowStart": "2026-11-07T00:00:00.000Z"
    }
  ]
}

GET/v1/agents/:id/vendors

The vendors this agent's organization knows. verified means a person in the organization confirmed the address, and discovered means it was learned from a payment. A vendor with no addresses on this chain cannot be paid here.

Response
{
  "vendors": [
    {
      "id": "e2a7c1d4-…",
      "name": "GPUFarm",
      "slug": "gpufarm",
      "category": "Compute",
      "website": null,
      "verified": true,
      "discovered": false,
      "addresses": [{ "address": "0x1000…0a02", "kind": "wallet" }]
    }
  ]
}

GET/v1/agents/:id/expenses

The agent's expenses, newest first.

Query

limit1 to 100
Defaults to 25.
cursorexpense id
The nextCursor from the page before. It is null on the last page.
Response
{
  "expenses": [
    {
      "id": "1b7e…",
      "intentId": "c41d7f06-…",
      "description": "API credits",
      "vendor": { "name": "OpenAI", "slug": "openai" },
      "counterparty": "0x1000…0a01",
      "category": "AI/API",
      "asset": "0x5fc5…d168",
      "assetSymbol": "USDG",
      "amount": "4",
      "amountBaseUnits": "4000000",
      "valueUsd": "4",
      "valueUsd6": "4000000",
      "gasUsd": null,
      "sponsored": false,
      "state": "CONFIRMED",
      "chainId": 4663,
      "txHash": "0x4be1…",
      "userOpHash": "0x9d02…",
      "timestamp": "2026-11-07T12:42:08.000Z"
    }
  ],
  "nextCursor": null
}

GET/v1/intents/:id

A request as it stands now, including what happened after the decision. decision and risk are null only while the first evaluation is running.

Response
{
  "intentId": "c41d7f06-…",
  "status": "EXECUTED",
  "decision": "APPROVED",
  "estimatedUsdValue": "15000000",
  "estimatedUsd": "15",
  "policyChecks": [],
  "risk": "LOW",
  "findings": [],
  "kind": "payment",
  "purpose": "compute",
  "userOpHash": "0x9d02…",
  "txHash": "0x4be1…",
  "createdAt": "2026-11-07T12:42:06.000Z",
  "updatedAt": "2026-11-07T12:42:08.000Z",
  "expiresAt": "2026-11-07T12:52:06.000Z"
}

Status

EVALUATING
The policy is running.
PENDING_APPROVAL
Waiting for a person, for up to 24 hours.
APPROVED
Queued for execution. It must start within 10 minutes.
SIGNING, SUBMITTED
On its way to the chain.
EXECUTED
Done. This is the only status where money moved.
BLOCKED
The policy refused it.
REJECTED
A person said no.
EXPIRED
It ran out of time, or its key or agent was stopped first.
FAILED
It could not be completed. error says why.

GET/v1/approvals/:intentId

The state of a request that needs a person. status is NOT_REQUIRED, PENDING, APPROVED, REJECTED or EXPIRED.

Approval does not skip the checks. They run again, so an approved request can still be blocked. intentStatus is the field that says whether it went ahead.

Response
{
  "intentId": "0b9f6d1e-…",
  "status": "APPROVED",
  "intentStatus": "EXECUTED",
  "rule": "Human approval required above $100.00",
  "requiredVotes": 1,
  "votes": 1,
  "mode": "ONCE",
  "expiresAt": "2026-11-08T09:12:00.000Z",
  "resolvedAt": "2026-11-07T09:40:11.000Z"
}

MCP setup

Give a model the same requests as tools

@agentspend/mcp lets a model use AgentSpend over stdio. It is built on the SDK, so it can do what an agent credential can do and nothing more: read one agent's finances and ask for spending. No tool touches a key, approves anything or changes a limit.

Configuration

Add the server to your MCP client's config. Each server reaches one agent, so add one entry per agent, such as researchbot and traderbot. It needs Node 20 or newer.

MCP client config
{
  "mcpServers": {
    "researchbot": {
      "command": "npx",
      "args": ["-y", "@agentspend/mcp"],
      "env": {
        "AGENTSPEND_API_URL": "https://your-agentspend-host",
        "AGENTSPEND_AGENT_ID": "7c1e4a52-…",
        "AGENTSPEND_API_KEY": "as_live_…"
      }
    }
  }
}
AGENTSPEND_API_URL
Your AgentSpend host.
AGENTSPEND_AGENT_ID
The agent's id.
AGENTSPEND_API_KEY
The same scoped credential the SDK calls sessionKey. It is not a chain key, and the server refuses to start with anything that does not look like a credential.

Tools

get_balance
Wallet balance in ETH and every approved asset.
get_budget
Daily, weekly and monthly budgets in dollars.
list_vendors
The vendors in the agent's organization.
request_payment
Asks to pay a vendor.
request_transaction
Asks to run one call or a batch.
get_approval_status
Where an earlier request stands, including whether a person decided.
get_expenses
Recorded expenses, newest first.

request_payment and request_transaction return the policy's decision. BLOCKED and REQUIRES_APPROVAL are normal answers, not tool errors, and the tool tells the model to report them rather than retry around them.

Security model

What holds, and who holds what

Two locks

Every spend passes two independent checks. The first is the policy module on your smart account, which enforces the rules you signed. These hard limits hold even if AgentSpend itself is down or compromised. The second is the policy engine, which runs before anything is signed and adds live USD valuation, simulation, approvals and anomaly alerts.

The engine also asks the module for its verdict on every request, so the two agree before a key is touched.

The policy module has not had an external audit yet. It is built on ZeroDev Kernel v3.1, and its test suite runs against the exact bytecode live on Robinhood Chain.

Who holds what

The owner
Holds the owner key in their own wallet. Only the owner can issue a key, widen a policy or move funds at will. The agent never gets the owner key.
The agent
Holds a scoped API credential that reaches one agent's wallet, budget, approvals and expenses. It holds no chain key, so a stolen credential can only ask.
AgentSpend
Holds session keys encrypted in its signing service, and credentials as hashes. A session key can do only what the owner signed. The relayer that pays for transactions can authorize nothing.
The funds
Stay in your smart account, and the owner keeps ultimate control.

What a session key can never do

  • Sign on the account's behalf. The module refuses every ERC-1271 request, so session keys can never sign Permit approvals.
  • Call the smart account itself, the policy module or the EntryPoint, apart from revoking itself or rotating its own signer.
  • Run a delegatecall. Only single and batch calls reach the account.
  • Move value through another door. Calls such as transferFrom, permit, setApprovalForAll, transferOwnership and contract upgrades are refused, even on an allowlisted contract.
  • Grant an unlimited token approval, unless the owner allowed that in the policy.
  • Widen its own limits. Rotating a key changes the signer and nothing else.

Failing closed

  • If there is no reliable USD price, the request is blocked.
  • If simulation fails or is unavailable, the request is blocked.
  • If the chain cannot confirm the onchain rules, the request is blocked.
  • If the signing service is down, nothing is signed, so nothing is spent.

People and approvals

  • An approval is a wallet signature, not a button press. The server only records what a member's wallet signed, so nobody with database or admin access can approve anything quietly.
  • An approval never skips the checks. The whole pipeline runs again against current state, so an approved request can still be blocked.
  • Approvers see the agent's reason, but the policy never relies on it.
  • Only the owner and approvers can approve. Issuing a key or raising a limit takes the owner's signature, so an admin account cannot do either.

Stopping and recovery

Freezing an agent stops autonomous spending the moment it commits, because the worker reads the agent's state again right before every signature. Requests waiting for a signature or an approval are cancelled. A request already submitted to the chain cannot be recalled.

The owner can recover funds directly, with no AgentSpend service involved: your wallet calls execute on the smart account. Your wallet can also revoke a session key or freeze the account at the module itself.

Identity

An agent can carry an optional ERC-8004 identity. It says who an agent is. It has nothing to do with what the agent can spend.