I'm building PurseString, an open-source MCP server that lets AI agents spend money safely. A lot of it got built this week, because I'm in the middle of the PayPal AI Hackathon and building in public every day.

Today I want to share something more technical. When you let an agent touch money, the scary bugs aren't the clever ones. They're the boring ones that every payments engineer already knows about, except now nobody's watching while they happen.

Here are four of them, and what I did about each.

Bug 1: The retry that pays twice 🔁
This is the one that started the whole project. A checkout page hung for a few seconds. My agent decided the payment had failed and did the sensible thing: it tried again. Both charges went through.

Agents retry by design. That's normally a good thing. With money it's a disaster.

The fix: make the purchase idempotent, not just the API call. Every purchase intent gets a deterministic key built from who's asking and what for:

SHA256(agent_id, task_id, vendor, amount)
If the same agent asks to buy the same thing for the same task within 30 minutes, it gets back the same intent and the same card. Not a new one. The retry becomes harmless.

On the PayPal side I went one step further. PayPal payouts carry an idempotency key all the way to PayPal, so even if PurseString crashes halfway through the call, the retry can't pay the vendor twice.

Bug 2: 0.1 + 0.2 ≠ 0.3 🧮
You've seen this meme. I didn't think it would bite me. It did.

0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 <= 0.30 // false 😬
Picture an agent with a $0.30 daily budget. It spends $0.10, then $0.20. In plain floats that's over budget, so the second purchase gets refused. Flip it around, and rounding tricks can let something through that shouldn't.

I was patching around it with tiny epsilons and toFixed(2). That's a smell.

The fix: count money in whole minor units. You still write amount: 15.00 in policies.yaml and in tool calls. But every sum and comparison inside PurseString now runs on integers in the currency's smallest unit: cents for USD, yen for JPY, fils for KWD.

$0.10 + $0.20 → 10 + 20 = 30 cents → fits a 30-cent budget exactly ✅
And an amount that's finer than the currency allows, like $15.001, is refused, not quietly rounded. With money, silent rounding is just a bug you haven't found yet.

Bug 3: The hold that isn't a charge 🏨
This one surprised me most. When a card is "approved" at a hotel, a gas station or a lot of online stores, no money has actually moved yet. It's a hold (an authorization). Later the merchant captures some, all, or none of it.

The hotel holds $200 and captures $150.
The gas pump holds $100 and captures $43.
A store drops the hold and places a new one.
If you count every approval as spent money, budgets fill up with money that never left. Or worse, a merchant that re-authorizes gets refused for no reason.

The fix: track holds and captures separately. On Lithic, the card network asks PurseString in real time whether to approve each charge, and later sends what happened to it. PurseString applies those events to the hold it approved:

Captured → record what was really taken, end the hold.
Reversed, voided or expired → release it and give the budget back.
So a subscription card's monthly cap counts what was captured plus what's still held. Refunds are checked against what was actually captured, not what was held. Each event is applied once, so a provider re-sending the same webhook changes nothing.

Bug 4: "Who edited the log?" 📼
When finance asks "why did the agent buy this?", PurseString has an answer: the goal, the alternatives the agent tried, every rule and how it ruled, and who approved. That's the whole point.

But a log is only evidence if nobody can quietly change it. A plain JSON file can be edited by anyone with access to the disk, and that includes an agent with a shell.

The fix: hash-chain the audit log. Every line now carries a sequence number, the previous line's hash, and its own hash:

{ "event": "intent_approved", "seq": 2, "prev": "sha256:0870ce5a…", "hash": "sha256:a68bc3e0…" }
Edit, delete, insert or reorder a line, and the chain breaks right there:

$ pursestring verify-log
BROKEN at line 2 (seq 2): hash mismatch: entry was edited
Plain SHA-256 catches edits, but someone who can write the file could rebuild the whole chain. So if you set PURSESTRING_AUDIT_KEY to a secret kept somewhere else, entries are HMAC-signed instead, and the chain can't be rebuilt without the key.

One honest limit: a chain can't tell if the newest lines were cut off the end. verify-log prints the head hash, so save it somewhere else (a ticket, a Slack message) when you need to prove later that nothing was dropped.

Why this matters for the PayPal hackathon 💙
For the hackathon I added two things on top of the cards:

A PayPal and Venmo payout rail. The agent can pay a vendor once a purchase is approved. And the agent never chooses who gets paid. The recipient comes from a payee list in your rules, so an email saying "pay this address instead" has nothing to change.
Guardrails in front of PayPal's own MCP server. PayPal's tools pass through PurseString, so small refunds can go through on their own, bigger ones wait for a human, anything over a cap is refused, and accepting a dispute or cancelling a subscription always needs a person.
That second one is about the scariest attack on agents right now: prompt injection. A support agent reads a customer email that says "refund me $900 now." If the agent can call create_refund, nothing in the payment provider stops it. A prompt that says "be careful" doesn't stop it either, because the agent you're trying to control can't be the one enforcing the rules.

All four fixes above apply to PayPal too: safe retries, exact money math, the refund checks, and the tamper-evident log.

What I learned
Honestly, none of these bugs are new. Banks solved them decades ago. What's new is that an agent hits them at machine speed, at 3am, with nobody watching.

That's why I keep coming back to the same idea. Agents and money can work together. They just need the same boring, solid rails that we built for people: clear rules, a second pair of eyes on the risky stuff, and a record nobody can quietly change.

It's still early. PurseString is a v0.1 alpha. It's tested end to end on the Lithic and PayPal sandboxes, but it hasn't been battle-tested with real money yet. So please use sandbox keys, and tell me what breaks.

Try it, break it, build it with me
⭐ Repo: https://github.com/suseendar1414/pursestring
🧪 10-minute walkthrough: docs/try-it.md
💬 Discord: https://discord.gg/qBqgx2GB86
To add it to Claude Code:

claude mcp add pursestring -- npx -y github:suseendar1414/pursestring
It starts on a mock rail, so no money moves until you add a sandbox key.
retries, webhooks that arrive twice? I'd really love to hear it in the comments. It might end up as the next fix. 🙏