DEV Community

OpenClaw Cash
OpenClaw Cash

Posted on

Give your agent a spending cap its own code cannot bypass

Your agent has a wallet and a system prompt that says "do not spend more than 100 a day". That prompt is the only thing standing between the agent and your balance, and a prompt is not a limit. A long context, a tool result the model reads wrong, or a retry loop is enough to walk past it.

A spend limit that holds has to be enforced where the money moves, not where the text is.

The limit lives on the wallet, not in the prompt

OpenClawCash checks wallet governance policies on the server before a write is signed. A request that breaks one comes back as 403 with code: "policy_violation" and a policyType field naming which policy blocked it. Nothing is signed, and a denied request never reaches the chain.

The policy types the public docs list:

  • whitelist: only transfers to pre-approved addresses
  • spending_limit: max value per transaction
  • daily_spending_limit, weekly_spending_limit, monthly_spending_limit: rolling window caps
  • disallow_live_transactions: blocks anything that is not testnet
  • wallet_purpose: restricts what the wallet may be used for
  • checkout_access: gates escrow usage
  • venue_access: gates venue usage such as Polymarket
  • max_open_escrows: caps concurrent open escrows
  • trusted_counterparty_tags: restricts checkout counterparties by tag

Read the cap before you send

The same limits are readable through the API, so your agent can check itself before it calls anything:

curl "https://openclawcash.com/api/agent/policy?walletId=Q7X2K9P" \
  -H "X-Agent-Key: occ_your_api_key"
Enter fullscreen mode Exit fullscreen mode

The answer carries the wallet and its policies, and for the three rolling window types a usage block with what is already spent:

{
  "wallet": {
    "id": "Q7X2K9P",
    "label": "Trading Bot",
    "address": "0x14ae8d93...",
    "network": "sepolia",
    "chain": "evm"
  },
  "policies": [
    {
      "id": 31,
      "type": "daily_spending_limit",
      "config": { "amount": "100" },
      "createdAt": "2026-01-15T10:00:00.000Z",
      "usage": {
        "spent": "45.00",
        "limit": "100.00",
        "symbol": "USD",
        "decimals": 2,
        "window": "24h"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

usage arrives for daily_spending_limit, weekly_spending_limit and monthly_spending_limit. The other types come back without it. The amounts are strings, so parse them before doing arithmetic.

The preflight your agent should run

Two functions: read the caps, then refuse locally before the request leaves the process.

const BASE = "https://openclawcash.com";

async function walletCaps(walletId) {
  const res = await fetch(`${BASE}/api/agent/policy?walletId=${walletId}`, {
    headers: { "X-Agent-Key": process.env.OCC_API_KEY },
  });
  if (!res.ok) throw new Error(`policy read failed: ${res.status}`);
  const data = await res.json();
  return data.policies
    .filter((policy) => policy.usage)
    .map((policy) => ({
      type: policy.type,
      window: policy.usage.window,
      remaining: Number(policy.usage.limit) - Number(policy.usage.spent),
    }));
}

async function withinCap(walletId, amount) {
  const caps = await walletCaps(walletId);
  const daily = caps.find((cap) => cap.type === "daily_spending_limit");
  if (!daily) return true; // no rolling cap configured on this wallet
  return amount <= daily.remaining;
}
Enter fullscreen mode Exit fullscreen mode

Then the send path checks before it spends:

if (!(await withinCap("Q7X2K9P", 100))) {
  throw new Error("over the daily cap, not sending");
}

const transfer = await fetch(`${BASE}/api/agent/transfer`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Agent-Key": process.env.OCC_API_KEY,
  },
  body: JSON.stringify({
    walletId: "Q7X2K9P",
    to: "0xRecipientWalletAddress...",
    token: "USDC",
    amountDisplay: "100",
  }),
});

if (transfer.status === 403) {
  const blocked = await transfer.json(); // code: policy_violation, plus the policyType that blocked it
  console.log("blocked by", blocked.policyType);
}
Enter fullscreen mode Exit fullscreen mode

The 403 branch is not a retry. The docs mark the temporary cases as retryable: true (503 is always retryable, 500 quote_failed is too) and 403 policy_violation is not one of them: either the request changes or the cap does. A request that broke a cap will break it again in a loop, so log the policyType and stop.

Who can change the cap

There is no way to write a policy through the public agent API. GET /api/agent/policies and GET /api/agent/policy are the only policy routes published: the agent reads limits, you set them in the dashboard. That asymmetry is the whole point. An agent that can raise its own cap does not have a cap.

Two rough edges worth knowing. Rolling window usage is reported against the wallet rather than the API key, so two agents spending from one wallet draw on the same budget, and each of them has to read it instead of caching it at startup. And spending_limit caps a single transaction without any usage block, so your preflight cannot see that one coming: only the per transfer check at send time catches it.

The full reference, every policy type and the exact response fields, is at https://openclawcash.com/docs. The agent readable version of the same surface is at https://openclawcash.com/agentwalletapi/SKILL.md.

Top comments (0)