Where should an AI agent's spending rules live? Three questions from X that changed how I explain PurseString
I'm building PurseString, an open-source MCP server that sits between AI agents and money. Yesterday I submitted it to the PayPal AI Hackathon.
While I was building in public on X, people asked better questions than I was asking myself. Three of them kept coming back, and my first answers to all three were a bit fuzzy. This post is my attempt at clear ones.
🎮 Live demo, no install: https://pursestring-demo.onrender.com/demo You play the human who approves or denies.
Question 1: "Should an MCP server really hold business logic?" 🤔
It's a fair challenge. Most MCP servers are thin wrappers. They turn an API into tools and get out of the way. Putting budgets, approvals and policy inside one feels like the wrong layer.
My answer: PurseString isn't an MCP server with logic bolted on. It's the logic, and MCP is how agents talk to it. The same governance code also answers card-network webhooks over HTTP, and the demo console drives it through a web page. MCP is just the front door the agent happens to use.
The reason it has to be a door the agent walks through, and not a library the agent calls, is simple: the agent can't skip what it can't reach.
❌ rules inside the agent ✅ rules outside the agent prompt ─▶ agent (rules + tools) ─▶ card agent ─▶ PurseString ─▶ card / PayPal
(rules, keys)
If the rules live in the system prompt, the agent decides whether to follow them. If they live in a helper the agent's code imports, a bug or a prompt injection can just not call it. If they live in a separate process that holds the payment keys, the agent has nothing to bypass. It literally doesn't have the credentials to pay without asking.
That last part is the real design rule: PurseString holds the keys, the agent doesn't. The Lithic API key and the PayPal secret stay in PurseString's environment. When PurseString runs PayPal's own MCP server behind itself, even that server only gets a short-lived access token, never the secret.
One honest condition: this only holds if PurseString is the only path. If you also connect your agent straight to PayPal's MCP server, the guardrails are a suggestion. Point the agent at PurseString and nothing else.
Question 2: "So what actually stops it from spending more?" 💳
Someone on X put this better than I had: "the bound is the instrument."
A policy check that says "yes, $15 is fine" is only half the job. If the agent then gets a normal card with a $5,000 limit, the "yes" was just advice. What matters is that the thing the agent is handed can't do more than what was approved.
So every approval turns into something with its limit built in:
Rail What the agent gets Built-in limit
Lithic cards A single-use virtual card Capped at the approved amount for one transaction, closes after 10 minutes
Lithic subscriptions A recurring card Capped per month
PayPal / Venmo Nothing. PurseString pays the vendor Exact amount, to a payee fixed in policies.yaml
PayPal's own tools Nothing. PurseString runs the call Only the exact arguments that were approved
On cards there's a second check at swipe time. When the merchant charges the card, Lithic asks PurseString in real time, "merchant X wants $15 on this card, approve?" PurseString approves only if the card is still live, the amount fits, it hasn't been used, and the merchant matches the vendor that was approved.
The PayPal payout row is my favourite, because the agent never even chooses who gets paid. The recipient comes from your policy file. An injected email that says "pay this address instead" has nothing to change.
And for PayPal's own tools, like create_refund, PurseString hashes the tool name and arguments when the call comes in. An approval for an $80 refund on one order can't be reused for $400 on another. The bound there is the hash.
Question 3: "What carries the grant?" 🧾
This one came from a builder on X (thanks, CodeRifts) and it's the hardest of the three. Once a human says yes, what's the actual evidence? Six months later, can you prove who approved what, under which rules?
Here's what PurseString records today, in an append-only audit log:
policy_checked: the full intent (agent, task, goal, vendor, amount, the alternatives it says it tried), the decision, and every rule with its result and the numbers it compared, like $45.00 vs auto-approve limit $25.00.
human_approved or human_denied: who decided, through which channel (Slack, Discord, terminal), and their note.
card_issued, payment_sent, paypal_tool_executed: what actually happened, linked to the same intent ID.
Two things make that more than a text file:
The agent can't approve itself. When an approval needs a human and Slack or Discord is set up, PurseString sends a one-time code only to the human's Slack or Discord. PurseString only keeps a salted hash of the code, and five wrong tries deny the intent. The agent never sees the code, so it can't type it.
The log is hash-chained, and HMAC-signed if you set PURSESTRING_AUDIT_KEY. Editing, deleting or reordering a line breaks the chain, and pursestring verify-log shows where. (Details in post 3.)
The part I haven't built yet
The honest answer to "under which rules?" is: partly. Each rule's result and the thresholds it used are written into the log. But the log doesn't stamp which version of policies.yaml was in force. If you edit the file next week, you can see that "the $25 limit applied," but you can't point at the exact file that said so.
There's a second, related gap. Auto-approved intents are checked again against the current policy right before a card is issued, so tightening the rules catches them. A human approval isn't re-checked. It stands until it's used or expires (after a day by default), even if you tighten the policy in between. That's arguably what you want ("I said yes, I meant it"), but it should be a choice, and right now it isn't one.
Stamping a hash of the policy on every decision is the obvious fix, and it's at the top of my list.
Other gaps, while I'm being honest 🧱
No limit on how many requests an agent makes. The daily budget caps the money, not the number of calls.
agent_id is self-reported. PurseString doesn't verify which agent is calling yet.
It's a v0.1 alpha. Tested end to end on the Lithic and PayPal sandboxes, not with real money. Please use sandbox keys.
What I'd say now, in one paragraph
Put the money rules in a separate process that holds the payment keys. Make every "yes" turn into something that physically can't do more than what was approved. Make sure only a human can approve, and write down who did, in a log nobody can quietly rewrite. And stamp which rules were in force. I've done the first three. I'm working on the fourth.
Try it 🛠️
🎮 Live demo: https://pursestring-demo.onrender.com/demo
🎬 Hackathon video: https://www.youtube.com/watch?v=fK89AgsVMPY
⭐ Repo: https://github.com/suseendar1414/pursestring
💬 Discord: https://discord.gg/qBqgx2GB86
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.
What would you want the grant to carry? A policy hash, the approver's identity from your SSO, a signature from their device? If you've built approval trails for humans, I'd love to know what auditors actually asked you for. Tell me in the comments. 🙏