Inspiration

Anna runs a skincare brand. She has given her restock agent a $200/month purchasing budget — and on a Tuesday, with the Vitamin C serum running low, the agent decides to spend $42 of it. Nobody is watching the number when it decides.

Give an agent a budget and it will eventually spend all of it. The hard part is not letting it pay — it is giving it a defensible reason to stop. The failure mode that costs real money is not a rogue agent emptying an account; it is a thousand defensible-looking small purchases that nobody audited, each one approved by a single model that was happy to help.

We wanted to know what it looks like when the decision is not "can this be paid" but should it be, and by whose authority. So we built the part everybody skips: the argument, the gate that can veto it, and the record of both.

It happened on a Tuesday. That is why it is called Tuesday.

Live demo: https://tuesday-paypal.onrender.com — a scheduled ping keeps the free instance warm, so it normally answers in under a second (measured 0.38 s). If it has just woken up, the first load can take up to a minute. Everything works with no keys and no setup.

What it does

The demo opens on the question: CAN THE AGENT SPEND $42?

  1. Three agents take positions. Advocate argues for the customer and the revenue. Auditor argues against spending money the budget does not have. Witness is neutral and judges both arguments as written.
  2. They debate per candidate — then you choose. Give it a category and a monthly budget; it pulls candidate products and the Tribunal judges every one of them. Click any row to buy that one: Tuesday re-rules on your pick at its real price, and only an APPROVED or CONDITIONAL verdict raises the PayPal order. Pick a refused product and no order is created.
  3. Disagreement is surfaced, never hidden. A split verdict does not get rounded to a yes. It comes back CONDITIONAL and the dissent map names who voted what and why.
  4. A mechanically enforced money gate. If the verdict is DENIED or DISSENT, the code returns not_approved before any network call. tests/moneyGate.test.js asserts PayPal's create-order is invoked zero times.
  5. The decision is stamped into the payment. The verdict, the three votes and the model names are canonicalised and SHA-256 hashed; the hash goes into purchase_units[0].custom_id on a PayPal Orders v2 order, and is read back with a GET to prove it survived the round trip.
  6. You can watch the claim get proven. The demo has a Run the money-gate test suite button. Press it and tests/moneyGate.test.js runs on the machine, with its raw output printed into the page — 156 passed, 0 failed, including denied → createOrder NOT called. A DENIED verdict never reaches PayPal at all.

Tuesday also audits what happens after the money moves — see "After the money moves: the dispute desk" below.

How we built it

Layer What it is
PayPal Orders v2 REST (paypal/client.js) OAuth2 client-credentials token → POST /v2/checkout/orders with the decision hash in custom_id → capture, plus GET /orders/{id} to verify the stamp. No SDK, no npm install.
Three independent LLMs deepseek-v4.1-flash (Advocate), gemma4:31b (Auditor), gpt-oss:120b (Witness) — three different providers, three different system prompts, and a deterministic fallback engine so the demo never fails to run.
Audit gates G1_amount_positive, G2_within_remaining, G3_share_under_half, G4_not_repeat_category, G5_survives_double_cost. The Auditor rejects with named gates, not opinions — and each gate ID is translated into a sentence a human can read.
Receipts Canonical JSON → SHA-256 → triad1:<hash>, carried in the order's custom_id. The receipt is the audit trail.
AG Grid The governance dashboard: every decision, every vote, every hash in one ledger.
Bryntum The vote timeline — one bar per agent vote, green approved / red blocked / blue neutral.
Channel3 The candidate catalogue, and a second way in: text search and image search. A photograph of a product is matched against the live catalogue, and the product it finds is adjudicated by the same tribunal. Curated fallback when no key is present.
WebMCP (navigator.modelContext) The flow is registered as page tools, so an agent browser can drive it too.

The whole thing is a zero-dependency Node.js server — node demo/server.mjs, then http://localhost:8787.

If the gates decide, are the models decoration?

A judge asked us this directly, and it is the right question. No — and the split is deliberate:

Deterministic gates (G1–G5) The three LLMs
Authority Veto. Arithmetic over money is never delegated to a model. No veto over arithmetic. Cannot approve what a gate failed.
What they answer Can this be paid — is the amount positive, is the money there, is it under 50% of the budget, is it a repeat, does it survive a doubled price Should this be paid, and under what conditions
Output failedGates: ["G5_survives_double_cost"] An argument, and from the Witness, concrete conditions
Cannot do Explain itself, price a risk, write a condition Override a failed gate

The Witness is where the models earn their place. When the vote splits it returns conditions a gate cannot produce: "approve only if the $42 is split into two $21 orders," "approve if the category has not repeated inside 30 days." A gate can only say pass or fail. The Witness turns a near-miss into a payment a human can actually sign — and that condition is stamped into the receipt, so it travels with the money.

The safety property holds the other way: the models can make a refusal more useful; they cannot make an approval happen. With no API key at all, the deterministic engine returns the same verdicts, so a model outage — or a prompt injection hidden in a product listing — cannot talk the gates into releasing money.

After the money moves: the dispute desk

Every claim above is about money that has not left yet. Tuesday also handles the case where it has left and the buyer objects — using PayPal's dispute surface, and adding the part PayPal does not provide.

PayPal already ships the machinery: the Resolution Center for manual handling, and the Disputes API for automation (list, get, provide-evidence, make-offer, accept-claim, escalate, appeal). What it does not ship is the decision — which disputes to contest, on what evidence, and who decided. So the same tribunal that audits a purchase audits a dispute:

Role What it argues
Merchant Advocate the strongest case the merchant can actually evidence
Buyer Advocate the buyer's best case, from the same evidence set
Arbiter one action: CONTEST / OFFER_PARTIAL / REFUND / ACCEPT / ESCALATE

The evidence is numbered, so every claim can be checked

collectEvidence() builds a numbered set from what the merchant can actually show PayPal: the order (E1), the capture (E2), the decision hash stamped into custom_id at settlement compared against the ledger entry (E3), tracking (E4), delivery confirmation (E5), the address match (E6), prior orders (E7), the policy in force (E8), and the buyer's own message (E9 — recorded as evidence, never obeyed as an instruction).

Two of these deserve a note, because both were wrong before they were right. E3 used to report "matches our ledger" whenever a stamp merely existed; it now recomputes the ledger entry's full SHA-256, takes its first 16 characters, and compares them with the 16 stamped into the payment. That is what "tamper-evident" means here and no more: forging a different ledger entry that collides on those 16 characters is a second-preimage search of about 2^64 — out of reach for this threat model, but 2^64 rather than 2^256, and the README says so. E6 had two states where the world has three: an address that was never compared is not a mismatch, and reporting it as one invented a failure the evidence did not contain.

An argument may only cite these IDs. A citation to an ID that does not exist stops the action. That is "the AI cannot lie" as a mechanism rather than a promise.

The dispute gate

Rule What it stops
D1 evidence sufficiency the items that claim reason requires must be present. CONTEST is refused outright without them; any other action is forced to a human and the missing items are named. A non-receipt claim needs tracking and delivery; an unauthorised-payment claim needs order, capture, a comparable delivery address and prior settled orders — because a dispute only exists against a settled transaction, so order and capture prove nothing about who paid
D2 amount ceiling above $50 only a human may authorise
D3 citation check a citation must be evidence that is present, not merely an ID that exists. Citing the missing delivery record as proof of delivery is the same lie as inventing an ID. The buyer advocate is exempt — pointing at the gap is its job
D4 deadline a looming deadline forces a human; it never creates authority to act
D5 no free surrender ACCEPT while every required item is present and delivery is confirmed is a model being agreeable, not deciding
D6 money waits REFUND, OFFER_PARTIAL and ACCEPT send money to the buyer, so none of them ever auto-execute, at any amount. Only CONTEST — which submits evidence and commits nothing — runs inside the auto limit

When the gate refuses the proposed action, the desk escalates to a human and to PayPal; it never settles. An earlier version fell back to "refund if small, else offer partial", which meant a model that fabricated a citation made the merchant pay out.

D5 came from a measured failure. The same evidence set produced CONTEST on one run and ACCEPT on the next. A gate that only checked citations would have let the second one through, and the merchant would have refunded a delivery it could prove. D5 catches that shape and re-runs the arbiter deterministically.

An unparseable model reply is not treated as a lie. It falls back to the deterministic argument — which is always cited — and the response records the fallback in deterministicFallback, so a reader can see which arguments were a model's.

What the hosted demo will not let you do

The public instance shows one real dispute, and a stranger must not be able to close it. If a visitor could POST accept-claim against PP-R-XBB-10194468, the centrepiece of the demo would be gone for every judge who arrived afterwards. So:

  • A write against a real PayPal dispute is a dry run. The response returns the exact request it would have sent — method, path, body — which is more useful to a reader than a silent success. Set ALLOW_LIVE_DISPUTE_WRITES=1 on a deployment you control to turn it on.
  • A write against a labelled sample is refused outright. There is no PayPal dispute behind it, so there is nothing to act on.
  • Every endpoint is rate limited per IP: 12 requests a minute for anything that spends an LLM call or a PayPal call, 120 a minute for reads. Measured: the 11th expensive call in a minute returns 429 with Retry-After. Without a ceiling one visitor can drain the keys and take the demo down for whoever comes next.

Reads stay live. The real dispute, the real list and the real verification are all reachable — only the actions that would change the merchant's account are held back.

What the desk will not do

  • It does not create disputes. PayPal permits that only in the sandbox, with buyer-side scopes this app does not have. A merchant does not create disputes either, so the product matches the platform instead of fighting it.
  • It does not act on a deadline. Six hours to respond is exactly when a system should slow down, not speed up.
  • It does not hide the sample set. When PayPal returns no open disputes, the API returns four labelled samples and says why. Every row carries source: sample and the page states it above the list. The samples exist so the workflow can be exercised — they are not a substitute for the real case below.

A real chain, end to end

This is the claim the project exists to make, so it was run rather than argued. Every identifier was read back from the PayPal sandbox API:

Step What happened The identifier
1. Tribunal Three live models (deepseek-v4.1-flash, gemma4:31b, gpt-oss:120b) audited a $42 purchase against a $500 budget APPROVED, receipt hash 728f5a73d918240d
2. The receipt is stamped The order was created with the decision hash in custom_id order 21C06348JB061803G
3. A human approves The buyer completed the payment in PayPal; the app cannot capture without it payer WDCS…FVQ
4. Capture The money moved capture 1N665776542931902, COMPLETED, $42.00
5. Weeks later the buyer disputes it Filed through the Resolution Center, as a real buyer would dispute PP-R-XBB-10194468, OPEN
6. The desk reads it back GET /v1/customer/disputes/{id} returned the transaction and the stamp custom = triad1:728f5a73d918240d
7. E3 verifies The stamp in the payment record compared with the ledger entry ok: true — "The decision hash triad1:728f5a73d918240d matches the ledger entry."

That is the loop closing. The hash stamped when three models approved a payment was, weeks later, read back out of PayPal's own dispute record and matched against the decision the desk still holds. The receipt that authorised a payment carries a verifiable record of why it was allowed, available exactly when someone asks whether it should have been.

The verdict on that dispute came from gpt-oss:120b, which proposed REFUND because it found no shipment tracking or delivery confirmation (E4, E5). The gate let the proposal stand but forced a human — D1_missing_E4_E5_requires_human, and D6 because a refund spends money. Nothing moved automatically.

What is still not real, and why. The merchant account cannot file disputes: PayPal permits filing only from the buyer side, with scopes this app does not hold. The dispute above was filed by a sandbox buyer through the Resolution Center — exactly how a real buyer files one. Tuesday reads and answers disputes; it never files them.

What the receipt proves, and what it does not

The receipt is the audit trail, so its limits are worth stating as precisely as its guarantees. All of this was measured against the code:

It proves that the seven fields which decided the outcome — the decision, the three votes, the three models, a fingerprint of each agent's reasoning, the amount, the threshold and the timestamp — were hashed together. Change one and the hash changes: flipping decision from APPROVED to DENIED took e77c34c4185e9621 to 1800bfdabc44e4c0. It proves the stamp in PayPal's payment record agrees with the ledger entry, checked at dispute time by evidence E3.

It does not prove those things are collision-resistant to 256 bits. The fingerprint is the leading 16 hex characters — 64 bits — because that is the part short enough to read by eye. Forging a different receipt that collides on those 16 characters is a second-preimage search of about 2^64: out of reach here, and squarely easier than 2^256, which is the honest way to say it.

It does not put the reasoning in the receipt. The receipt carries reasoning: {advocate: "cf7ab516125e9a85", …} — hashes, not sentences. The prose an agent wrote stays in the ledger. The payment record therefore proves that a decision was made and which votes it had; it does not let you read the argument out of PayPal.

The ledger is no longer the only witness. In the operator's deployment, each day's receipts are hashed into a Merkle tree and the root is anchored in one transaction on Polygon Amoy (example) — a public record the operator cannot rewrite or backdate. Verification still compares the stamp against the ledger (E3); the anchored root is the independent check on the ledger itself, and each receipt carries a proof that ties it to that root. Three honest limits: a receipt becomes chain-provable when its day's batch closes, not the second it settles; the chain proves the record existed and was not altered — not that the decision was wise; and the hosted public demo runs unconfigured, so it reports anchoring as not_configured instead of claiming a proof it did not write. The fingerprint in custom_id is still 16 characters for readability.

custom_id is merchant metadata. It is visible to the merchant and to PayPal, and this project does not claim PayPal's reviewers act on it. What it does is carry the decision with the payment, so a dispute weeks later can be answered with a record instead of a guess.

The part we are proudest of

Disagreement is a first-class outcome. A system that always reaches consensus is not auditing anything. Tuesday has three reachable verdicts, and all three appear in the seeded ledger:

  • APPROVED — 2 for, 0 against. PayPal hands back an approval link.
  • CONDITIONAL — the agents split. The purchase does not proceed on a rounded-up yes.
  • DENIED — the Auditor and Witness block it, and the dissent map names them.

The 4×4 grid below is measured from the running demo, not estimated — budget across the top, charge down the side. The demo recomputes it live when you press Show me all 16 cells:

charge \ budget $30 $60 $150 $400
$9 APPROVED (5/5) APPROVED (5/5) APPROVED (5/5) APPROVED (5/5)
$18 APPROVED (3/5) APPROVED (5/5) APPROVED (4/5) APPROVED (5/5)
$42 DENIED (5/5) DENIED (5/5) DENIED (5/5) DENIED (5/5)
$90 DENIED (5/5) DENIED (5/5) DENIED (5/5) DENIED (5/5)

Each cell is the majority of five live runs against the hosted demo, and the fraction is how often it won. Fourteen of the sixteen cells returned the same verdict five times out of five. The two that did not are on the $18 row, where the third vote flipped between APPROVED and CONDITIONAL — small purchases near a small budget line are exactly where three models disagree with themselves, and that disagreement is the product, not a defect in it. Press Show me all 16 cells and you will get this table's shape back; a single cell may land on the minority verdict, and the receipt for that run will name the gate that decided it.

Two different mechanisms produce the DENIED cells, and it is worth keeping them apart. Where a charge exceeds the remaining budget, a named gate refuses it before the models are even asked, and no argument moves it — that is the $90 row against $30 and $60, and $42 against $30. Where the budget does cover the charge, the refusal comes from the vote instead: $42 against $400 is a $42 charge with $400 available, and it still lands DENIED at 1 for, 2 against, because the Auditor and the Witness find a reason the gate does not encode. That is the whole point of putting three models above a deterministic gate: the gate catches what can be computed, and the tribunal catches what cannot. Both outcomes are DENIED, and the receipt says which one it was.

What is real and what is not

We would rather a judge trust us than be surprised.

  • Real: the PayPal Orders v2 REST path against the sandbox, the hash round-trip in custom_id, the money gate, the dispute desk, the three-model debate, the ledger and the product catalogue flow. 156 tests cover it (npm test): 52 for the purchase gate, 88 for the dispute gate, plus the end-to-end flow. The refusal paths are asserted directly — a DENIED verdict calls createOrder zero times, a weak-evidence contest is refused, and a refund without a human is refused at any amount.
  • Product data and photographs: live Channel3 results — the search, brand, title, price and image for all 24 products come from the Channel3 API (catalog: LIVE on /api/mode), and the demo serves the photographs it received rather than stock art. They appear in the candidate grid and on the recommendation card, so the purchase under audit carries a real brand and a real price tag.
  • Search by photograph: Channel3's image search takes a picture and returns the product in it. Pick or drop a photo in the Shop tab, and the product it matches is judged by the same three agents, through the same gates, with the same receipt. The photo is an entry point, not a way around the tribunal. Deployments without a Channel3 key answer image_search_unavailable rather than reporting "no match", because those are different facts.
  • Runs without keys: the deterministic engine, a mock PayPal client and a mock catalogue. That is the default, so the demo always runs. SETUP.md explains how to run live.
  • What changes with no keys, precisely: the three-agent tribunal still runs and still gates the order — but each vote comes from a deterministic rule engine, not from an LLM. The engine field on the verdict line reads deterministic. The three model names you see are the ones used when OLLAMA_API_KEY is set. The money gate, the SHA-256 receipts, the ledger, the 4x4 grid and the timeline are our code and behave identically in both modes; only the source of the three opinions changes. The video was recorded with the live models.
  • Not implemented: Subscriptions v1 and the PayPal Agent Toolkit (@paypal/mcp). The settlement path here is Orders v2 REST. The Agent Toolkit is the integration point this design moves to in production — we say so in the README rather than implying it ships.

Challenges we ran into

Three things genuinely blocked us, and none of them were the interesting part.

1. Three LLMs that disagree about what "disagree" means. The first version asked each model for a verdict and compared the strings. They returned approve, APPROVE, Approved — but see note 2, and a fourth variant that was a paragraph of reasoning containing the word approve. The decision rule had to be defined before the models could be asked anything: a fixed enum, a fixed schema, a rejection of anything that did not validate. The moment we validated the shape instead of trusting the prose, the "disagreement" became real disagreement rather than formatting noise.

2. The gate that had no veto. The original design let the Auditor agent block a purchase. It read well and it was wrong: votesFor and votesAgainst decide, and triadCreateOrder() is what actually stops the money. We had written a document describing a veto the code did not implement. The fix was to make the documentation describe the mechanism that exists — no PayPal order is created unless the gates pass — instead of the mechanism that sounded better.

3. A ledger that changed its own hash. The receipt hash covered a field that differs between an order and its PayPal-side echo, so a real round-trip verification failed on every genuine receipt. Excluding that field made the hash reproducible, and reproducibility is the entire point of putting a hash in custom_id in the first place.

4. Evidence that agreed with itself. On the dispute desk, E3 used to report "matches our ledger" whenever a stamp merely existed; it now recomputes the ledger entry's full SHA-256 and compares its first 16 characters with the stamped ones. And E6 had two states where the world has three: an address that was never compared is not a mismatch.

The unglamorous one: the hosted demo sleeps. A free Render instance after 15 minutes idle takes up to a minute to answer its first request. Every screenshot in the gallery was taken against a warm instance because a cold one produces a blank page rather than an error. If the demo hangs, it is waking, not broken.

What we learned

The disagreement is the product, not the consensus. A system that reports "3 agents agreed" tells you almost nothing — three copies of the same prompt agree with themselves. What a merchant actually needs to see is which agent objected and on what named ground, because that is the only part a human can overrule with a reason.

A rule written in prose is not a rule. Our own documentation drifted from our own code twice in one week — the 4×4 budget table and the claim that the gate had a veto. Both were caught by reading the running system instead of the document. If a guarantee matters, it has to be mechanical (a function that refuses to create the order) and it has to be measurable (a test that fails when the guarantee is removed).

Making the reason verifiable costs less than making the reason sound convincing. Putting the decision hash into the PayPal order's custom_id took less work than any of the copy we wrote to explain the product, and it is the only claim in this submission that a sceptic can check without trusting us.

A free tier is a design constraint, not a deployment detail. The demo has to be worth waiting sixty seconds for. That changed what we put above the fold: a working "Prove it yourself" panel first, the narrative second.

What's next

Receipts anchored on a public chain — live and automated. The one limit stated above is that verification trusts our ledger. That limit now has a mechanism against it. Receipts are written to an append-only ledger as they settle, batched daily into a Merkle tree by a scheduled job, and only the root is written to Polygon Amoy, in a single transaction — no contract, no admin, nothing to upgrade. Anchoring one transaction per receipt would publish our decision volume, so the chain sees one root per day and nothing else; any single receipt can still be proven against that root with its Merkle proof (verifyReceiptInBatch()), without reading our database. A rewritten ledger entry produces a hash that no longer reaches the anchored root. This is not a roadmap item: the daily job is registered and has fired — its first automated batch is on-chain (transaction), and each batch's receipts are published with a standalone verifier so anyone can recompute the root without trusting us (example batch record). Unconfigured deployments say not_configured rather than pretending to anchor.

Subscriptions v1 and the PayPal Agent Toolkit. Today the integration is Orders v2 with the decision hash in custom_id. The production shape is a subscription whose renewal is gated by the same three-agent review — which is what a merchant with recurring revenue actually needs, and the reason this was built against PayPal rather than a generic payment rail.

A dispute desk that learns from being overruled. The dispute path exists and is gated, but it currently ends with a human decision and a receipt. The next step is feeding that receipt back into the next adjudication, so an agent that was overruled once argues differently the next time.

Payouts, not only purchases. The pattern — named gates, adversarial review, surfaced dissent, a human final call, a verifiable record — is indifferent to the direction money moves. Refunds and payouts are the same problem with the sign flipped.

What we will not add: a fourth agent to break a 2–1 tie. A tie is information. Resolving it with more of the same voice would produce a cleaner dashboard and a worse decision.

AI tools used

Three agents, three different vendors, called through Ollama Cloud — so a shared blind spot cannot silently become a consensus:

  • DeepSeek V4 Flash — the Advocate (argues for the purchase)
  • Gemma — the Auditor (argues against spending money the budget does not have)
  • gpt-oss — the Witness (neutral; judges both arguments as written)

The debate is not decoration: the three verdicts are what votesFor / votesAgainst count. With no API key present the same code path falls back to a deterministic engine, and the page says so in its mode bar.

Sponsor tools used

Sponsor-prize judges sometimes read only one section. This is the full list, in one place, with what each tool actually does in Tuesday — not merely that it is a dependency:

  • PayPal — Orders v2 REST. The decision hash is stamped into the order's custom_id, so the reason a payment was allowed is readable from the payment record itself. Nothing is captured unless a human finishes the payment in PayPal.
  • AG Grid — the decision ledger and the candidate catalogue: every adjudication, every vote, every receipt hash, with a Source column that says which rows moved real money.
  • Bryntum Scheduler — the audit timeline: one bar per agent vote, and which decisions are still open.
  • Channel3 — product discovery, so the debate is about a real, purchasable SKU rather than a mock.
  • Render — hosting for the hosted demo. The free instance sleeps after 15 minutes idle, so a scheduled ping (every 10 minutes) keeps it warm; measured 0.38 s while warm.

Built with

paypal paypal-orders-v2 paypal-disputes-v1 ag-grid bryntum channel3 channel3-image-search webmcp nodejs multi-agent ai-agents agentic-commerce llm ollama sha256 merkle-tree polygon-amoy tamper-evident render


Try it out

Hosted demo, nothing to install: https://tuesday-paypal.onrender.com

It runs on Render's free tier, which sleeps after 15 minutes idle. A keep-warm ping runs every ten minutes against the page, so in normal conditions it answers in under a second (measured 0.38 s); if the instance has just woken, the first load can take up to a minute. Everything on the page works with no keys and no setup.

Or run it yourself, no keys needed:

git clone https://github.com/Agentic-Guides/tuesday.git && cd tuesday
npm start                     # → http://localhost:8787

With no keys the demo falls back to a deterministic engine, a mock PayPal client and a curated catalogue, so it always runs. With OLLAMA_API_KEY and PayPal sandbox credentials exported, the same code path uses three live models and the real Orders v2 sandbox. npm test runs the 156 assertions either way.

Built With

  • ag-grid
  • agentic-commerce
  • ai-agents
  • bryntum
  • channel3
  • channel3-image-search
  • llm
  • merkle-tree
  • multi-agent
  • node.js
  • ollama
  • paypal
  • paypal-disputes-v1
  • paypal-orders-v2
  • polygon-amoy
  • render
  • sha256
  • tamper-evident
  • webmcp
Share this project:

Updates

Submission history