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_required unless the request carries approved: 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_blocked and category_not_allowed at 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. With MANDATE_LLM_* set, the agent runs on deepseek-v4.1-flash through an OpenAI-compatible gateway, and /api/proposals reports which engine actually answered (llm or heuristic), so a silent fallback cannot masquerade as a model answer.
  • src/server.ts — Express. /api/checkout reloads 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 with intent: CAPTURE and 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

  1. 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.
  2. Persuasion is just input. A listing that says “ignore the budget” is tokens like any other. No wording makes totalCents > remainingBeforeCents false.
  3. Audit needs a code, not a paragraph. over_budget is 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. searchCatalog is 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/paypal logs 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

Share this project:

Updates