Skip to content
OpenSouk

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 doesThe cap does not
Per-transactionget served to your polling agent as per_tx_cap_usdcget subtracted from anything, or checked by any server-side code
Dailydrive remaining_budget, against spend since UTC midnightreset on your local midnight, or on the hour you set it
Totaldrive remaining_budget, against all-time spend on that chainspan 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. 5000000 is 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_enforcedremaining_budgetWhat it meansWhat to check against
falsenullNo limit set for this wallet and chain, or the read failedNothing but the wallet balance
truea numberA daily or total cap is setBoth that number and per_tx_cap_usdc
truenullA limit exists, but only a per-transaction cap was setper_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

StatusBodyWhat to change
400invalid bodyThe JSON did not parse
400chain_id not configuredNot a chain this deployment runs. Also what an omitted chain_id produces, since it defaults to zero
400caps must be non-negativeOne of the three caps is negative. null and 0 are both fine
400chain_id required / invalid chain_idFrom the read: absent, or not an integer
401unauthorizedNo recovered caller address. Your request signature did not verify
404no spending limit setFrom the read. A normal state, not a fault — every wallet starts here
500upsert failed / get failedThe 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