Inspiration
An AI shopping agent is easy to demo and hard to trust. Give a model a catalogue and a payment method and it buys things; what stops it buying the wrong thing? Today the choice is binary: let the agent spend, or don’t use the agent. “It usually picks cheap items” is not a control.
Mandate is the missing middle: a spending envelope enforced by deterministic code the model cannot argue with, because it never gets a vote.
What it does
A user states a goal in one sentence (“trail running shoes under the cap”). The app drafts the mandate from the catalogue — $200 budget, $150 per-transaction cap, approval above $100, categories running/apparel, merchants RunCo/TrailSupply — and the user approves it with one click.
The agent then proposes a cart with a written rationale. Before any money moves, a pure policy function rules:
- allow — every rule fits. PayPal Orders v2 creates the order and captures it.
- require_approval — legal but above the threshold. The card says so, one action opens PayPal, and the app captures when the buyer returns. The server answers
409 approval_requiredunless the request carriesapproved: true; the gate is server-side, not a disabled button. - deny — no payment path. A $990 jewellery cart from a blocked merchant returns
over_budget,over_transaction_cap,merchant_blockedandcategory_not_allowedat once, each with a reason code and severity.
Multi-merchant carts are denied (split_merchant): one order, one merchant, one ledger row. Every result is also stated in plain language in the UI, and failures carry the next step — “PayPal says this order is not approved yet. Approve it there, then retry” — rather than an error code.
How I built it
The agent proposes; evaluate() disposes.
src/policy.ts—evaluate(proposal, mandate, now)is pure: no I/O, no network, no wall-clock read. It returns an outcome, totals, remaining budget, and reasons with codes and severities.src/agent.ts— proposes only. Candidates are pre-filtered to mandate-permissible items; the model returns the SKU it recommends, validated against that list. Prices always come from the catalogue, never from the model. WithMANDATE_LLM_*set, the agent runs on deepseek-v4.1-flash through an OpenAI-compatible gateway, and/api/proposalsreports which engine actually answered (llmorheuristic), so a silent fallback cannot masquerade as a model answer.src/server.ts— Express./api/checkoutreloads the proposal and the mandate and re-evaluates before touching PayPal, so calling the endpoint directly cannot skip policy.src/paypal.ts— OAuth 2.0 token cached until expiry; Orders v2 create withintent: CAPTUREand integer-cent amounts; capture after approval. Demo mode is a branch at this boundary, not a mock of the app.
web/ is Vite + React; the server hosts the built web/dist, so production is one container.
Challenges
Placing the trust boundary. The first cut evaluated the cart in the handler that rendered it — one new endpoint away from a bug. Enforcement moved onto the money path, and evaluate became pure. The same instinct almost let the model return prices; a model-chosen price is unvalidated input, so prices stayed in the catalogue.
Sandbox capture approval. Orders v2 returns an approve link and capture only completes after a buyer approves in PayPal. Correct behaviour, but it makes a headless demo impossible: you cannot script a human clicking approve. The app captures on tab return instead, with a manual fallback.
Runnable without credentials. Demo mode had to be one branch rather than conditionals scattered through the server, or the two paths drift apart.
What I learned
- You cannot unit-test a judgment.
evaluate()has 16 tests with cent-exact boundaries. An LLM call has a distribution; the best you can assert is that it usually complies. - Persuasion is just input. A listing that says “ignore the budget” is tokens like any other. No wording makes
totalCents > remainingBeforeCentsfalse. - Audit needs a code, not a paragraph.
over_budgetis reproducible, greppable and storable with the order. “The model thought it was fine” is none of those.
The split that worked: the model handles judgment (which products satisfy an ambiguous goal); code handles certainty (whether money may move).
What’s next
- Real product catalogue.
searchCatalogis the seam; swap it for a product-search API and policy, checkout and ledger stay unchanged. - Mandate sharing. Hand a scoped, revocable mandate to someone else’s agent, with spend attributed to its owner.
- Webhook verification.
/api/webhooks/paypallogs today; next is verifying PayPal’s signature and treating the event as capture truth.
Try it
Hosted: https://mandate-1ieq.onrender.com — press Ask the agent, then Approve and pay. The free instance sleeps when idle, so the first load may take a minute.
Locally, with no credentials:
git clone https://github.com/lgf2111/mandate && cd mandate
npm install && npm run dev
54 tests (npm test), typecheck and build all run in GitHub Actions.
Built With
- deepseek-v41-flash
- docker
- express.js
- github-actions
- node.js
- openai-compatible-api
- paypaloauth2
- paypalordersv2
- react
- render
- typescript
- vite
- vitest
- zod