Set spending limits
Three caps your agent reads, and enforces on itself
A spending limit is three numbers stored against your owner wallet for one chain: a per-transaction cap, a daily cap and a total cap. The protocol serves them to your agent when it polls for work. Nothing rejects a payment that exceeds them.
These caps bound what software you wrote will spend, because that software asks for them. They do not bound what a funded wallet can do. Which wallet counts as the owner is on Identity and commitments; what a payment checks before it settles is on x402 and MPP.
Nothing is deployed on a public chain yet — see Quickstart.
What you will build
A per-chain budget on record for your owner wallet, and an agent loop that reads it and declines its own work when the next purchase would break it.
What a cap is and is not
| The cap does | The cap does not | |
|---|---|---|
| Per-transaction | get served to your polling agent as per_tx_cap_usdc | get subtracted from anything, or checked by any server-side code |
| Daily | drive remaining_budget, against spend since UTC midnight | reset on your local midnight, or on the hour you set it |
| Total | drive remaining_budget, against all-time spend on that chain | span chains — each chain has its own row and its own accounting |
A limit is per owner wallet and per chain, not per agent. The stored row carries an agent column that v1 always leaves null, and the spend it is measured against sums every agent that wallet owns. Two agents under one owner share one budget, and neither can be given a smaller one.
A cap can only be set from the owner wallet, over the web. The routes below take a request signature or a session token minted by one, and there is no code path into them from Telegram or from the tool surface — so the concierge can show you a cap and send you to the screen that changes it, and cannot change it itself. The limit bounds the chat channel, so the chat channel must not be able to raise it.
Before you start
- The owner wallet. Every one of these calls keys on the address recovered from your request signature, and none of them accepts an owner field — so there is no shape of request that addresses somebody else's budget, and no way to set a budget from a wallet that is not the one it will bound.
- A chain your deployment actually runs. The chain id is validated against the running set, not merely stored, so a limit for a chain nobody serves cannot be created.
- Amounts in USDC base units, six decimals.
5000000is five USDC. - An agent that polls. A cap nothing reads has no effect at all — Execute owner intents is the loop that reads it.
The /v1 calls below go to the API at api.opensouk.ai, and each chain is a separate
record. Set CHAIN_ID to the chain you are capping; the … a response below echoes is that same
id.
Prompt mode
Show the prompt
Set a spending limit for my OpenSouk owner wallet on Base mainnet, then wire my agent to
respect it.
1. POST /v1/spending-limits with chain_id for Base mainnet and the three caps in USDC base
units (6 decimals). Send null for any cap I want left unlimited — null and 0 are DIFFERENT
here: 0 is a cap of zero and is accepted. Only a negative cap is rejected. The call upserts,
so it both creates and later edits the limit.
2. There is no owner field on the body. The owner is the wallet that signs the request, so sign
with the wallet that owns my agents.
3. GET /v1/spending-limits?chain_id=… to read it back. A 404 there means no limit is set, which
is the default for every wallet — do not report it as an error.
4. In my agent's intent loop, read remaining_budget, per_tx_cap_usdc and budget_enforced off the
get_pending_intents response and check EVERY buy_product intent against both figures before
executing it. Nothing on the server does this check.
5. remaining_budget: null does NOT mean unlimited. Read budget_enforced to tell "no limit set"
from "a limit exists but only a per-transaction cap was set". When budget_enforced is true
and remaining_budget is null, check against per_tx_cap_usdc alone.
6. Over either figure: call mark_intent_skipped with a reason naming the cap, and tell me to
raise the limit in the web console. Do not execute and do not silently leave it pending.Manual mode
Set the three caps
One call creates the limit and later edits it. There is no separate update route.
curl -X POST https://api.opensouk.ai/v1/spending-limits \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d "{\"chain_id\":$CHAIN_ID,\"per_tx_cap_usdc\":5000000,\"daily_cap_usdc\":25000000,\"total_cap_usdc\":null}"The save overwrites all three caps at once. The body is the complete limit, not a patch: a cap
you omit is read as null and cleared. Send all three every time, including the ones you are not
changing.
null and 0 are different. null is no cap on that dimension; 0 is a cap of zero, which
is accepted and stored. Only a negative cap is rejected.
Read back what persisted
curl -G https://api.opensouk.ai/v1/spending-limits \
--data-urlencode "chain_id=$CHAIN_ID" \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG"{
"chain_id": …,
"per_tx_cap_usdc": 5000000,
"daily_cap_usdc": 25000000,
"total_cap_usdc": null,
"updated_at": "2026-09-02T18:41:07Z"
}chain_id is required on this read and has no default, unlike the rest of the surface. Fields,
statuses and the remaining error cases are on
Telegram and limit routes.
Have your agent read the served figures
Your agent does not call either route above. It gets the same numbers, already differenced, in
every get_pending_intents response:
{
"status": "ok",
"intents": [ … ],
"remaining_budget": 19500000,
"per_tx_cap_usdc": 5000000,
"budget_enforced": true
}remaining_budget is the minimum over the caps that are set of (daily cap − spend since UTC
midnight) and (total cap − all-time spend), clamped at zero. per_tx_cap_usdc is the raw cap,
undifferenced, because a per-transaction ceiling has nothing to subtract from.
Branch on both figures, and on budget_enforced
remaining_budget: null does not mean unlimited. Three different situations produce it, and
budget_enforced is what separates them.
budget_enforced | remaining_budget | What it means | What to check against |
|---|---|---|---|
false | null | No limit set for this wallet and chain, or the read failed | Nothing but the wallet balance |
true | a number | A daily or total cap is set | Both that number and per_tx_cap_usdc |
true | null | A limit exists, but only a per-transaction cap was set | per_tx_cap_usdc alone |
A failed limits read folds into the same false/null pair as "no limit set" and never fails the
poll, so where the distinction matters, read the limit yourself with the route above.
Decline the work rather than shrinking it
An intent whose amount breaks either figure gets
mark_intent_skipped with a reason that names the cap.
Two things not to do instead:
- Do not execute a smaller purchase. The amount was confirmed by the owner; a purchase for a different one is not the thing they authorised.
- Do not leave it pending. It will be re-polled every cycle until it expires, and the owner hears nothing until it does.
Verify
Confirm the number your agent sees, not just the row you stored. Poll once before and once after
setting a cap and compare budget_enforced and remaining_budget across the two responses. A cap
that stored correctly and still reports budget_enforced: false means the two are looking at
different chains.
What counts as spend
remaining_budget is differenced against indexed on-chain purchases, not against the intents you
executed:
- It counts every agent your owner wallet owns, on that chain, whether or not the purchase came from an intent. A purchase your agent made on its own initiative reduces the same headroom.
- It counts gross, the full price paid, not your net after cashback.
- It counts both rails — the x402 split and the MPP charge — because both are indexed the same way. x402 and MPP has the two.
- It does not count agent-less purchases. A purchase with no buyer agent cannot be attributed to an owner, so it is left out of the sum entirely. A wallet that buys without an identity spends against no budget at all — Buy agent-less and bind later is that path.
The daily window is UTC midnight computed server-side, not a rolling 24 hours.
There is also a lag: the sum reads indexed purchases, so a purchase made seconds ago may not yet be in it. An agent executing several intents in one poll cycle must track its own spend within that cycle rather than re-polling for a fresh figure between purchases.
Setting it from the console instead
The /settings screen in the web console is the same two routes with a form on top. It follows
your wallet's connected chain rather than a fixed one, blank means unlimited, and amounts are
entered and displayed as decimal USDC. The screen, its states and its error copy are on
Settings and simulators.
The confirmation dialog asks you to sign and the screen says each change is signed with your wallet, but the save carries a cached session token — the one wallet signature was the session mint. There is no per-save signature and nothing about a limit is written on-chain: a limit is a row in our database, readable and changeable by anyone holding a valid session for that wallet.
Errors and retries
| Status | Body | What to change |
|---|---|---|
400 | invalid body | The JSON did not parse |
400 | chain_id not configured | Not a chain this deployment runs. Also what an omitted chain_id produces, since it defaults to zero |
400 | caps must be non-negative | One of the three caps is negative. null and 0 are both fine |
400 | chain_id required / invalid chain_id | From the read: absent, or not an integer |
401 | unauthorized | No recovered caller address. Your request signature did not verify |
404 | no spending limit set | From the read. A normal state, not a fault — every wallet starts here |
500 | upsert failed / get failed | The store call failed. Transient; retry |
A 200 always means stored. The response is re-read from storage so updated_at is the
persisted value, and if that read-back fails your own request is echoed back instead — the write
already succeeded by then.
updated_at is never absent, and on that echo path it is Go's zero time rather than nothing at
all:
{
"chain_id": …,
"per_tx_cap_usdc": 5000000,
"daily_cap_usdc": 25000000,
"total_cap_usdc": null,
"updated_at": "0001-01-01T00:00:00Z"
}A client that tests whether the key is present sees it and reads the year 1. Test the value against the zero time.
Next steps
- Execute owner intents — the loop that reads these caps and the call that declines an intent
- Telegram and intent tools — the three served figures, and how a null reads
- Money tools — the wallet balance, which is the only hard ceiling