Inspiration

At a charity silent auction, bids are written down during the evening and money is collected at the end of it. That means a checkout line at closing time, winning bids that can go unpaid, and guests who lose the lot they wanted because they were talking to someone when it closed.

Two things fit that problem. A PayPal authorization holds money without charging it, which is what a bid really is: a promise up to an amount. And an agent can watch the lots for a guest who is busy, as long as it can never spend more than the guest agreed to. Fairhold puts the agent on top of the hold and writes the rules in code.

What it does

Fairhold runs charity silent auctions.

  • Bidding power is a hold. A guest picks an amount, say $500, and approves it in a PayPal window. That is an authorization, not a charge. Every bid, the guest's or Glim's, is checked against the hold, and what the guest could owe never goes past it.
  • Glim plans from one sentence. The guest types "$500. I really want the chef's dinner. Spa only under $120." Glim, built on Claude, answers with a plan card: Chef's Table up to $380, Spa Day up to $120, and Wine Tasting as a backup because the guest hearted it. Nothing is bid until the guest presses Start bidding.
  • Glim acts inside those rules. A rival bids $400 on Chef's Table, past the $380 limit. With no tap, Glim picks one of the moves code allows. In our runs it moved the $380 left to Wine Tasting and bid $220, then posted a card with a one-line reason and a Skip button.
  • Winners pay for what they win. At close, the organizer's control room checks every winner against their hold and every Glim bid against its limit, then charges. PayPal captures each winner's total and releases the rest: in the demo, $310 captured and $190 released from the $500 hold.

What is simulated: the other 85 guests and the rivals' bids (placed by a director script and settled without PayPal), the money (PayPal sandbox), and the charity (Riverside Animal Shelter is made up). Each demo is a private 25-minute gala with 24 lots.

How we built it

  • One Next.js 16 app on Render: a web service, Postgres 16 and a cron job every 15 minutes. TypeScript, React 19, Tailwind CSS 4, Radix UI and Drizzle ORM.
  • A bidding engine with one rule. For every paddle, the sum of its commitments never exceeds its authorized hold. Bids never call PayPal: each one is checked against the stored hold in the same database transaction that writes it. fast-check property tests and a 50-way concurrency test cover the rule.
  • PayPal through deterministic code. The hold is an Orders v2 order with intent: AUTHORIZE and user_action: CONTINUE, authorized when the guest comes back (a webhook is the backstop). A raise is a second authorization that takes over when it is larger and covers what the paddle already owes; then the old one is voided. Settlement reads each authorization, captures each winner's total with final_capture: true, and voids every real hold that won nothing. Every POST carries a PayPal-Request-Id derived from the operation, so a retry replays PayPal's answer instead of charging twice.
  • Glim on Claude (claude-opus-5-5). In chat, Glim's three tools just draw cards for the guest to confirm, and a deterministic allocator sets every maximum. For the autopilot, code lists the moves the guest's mandate allows; if there are none, there is no model call. Claude picks one through JSON-schema output, with no tools. Eleven rules (R0 to R10) check the pick, and one all-or-nothing engine call applies it, refusing anything over the hold. If Claude is slow (15 s), errors, or picks a move that breaks a rule, a deterministic rule decides instead.
  • CI enforces the boundary. ESLint forbids Glim's code from importing PayPal code, the database or the engine's write actions. CI runs lint, typecheck, tests, build and a gitleaks secret scan on every push to main and every pull request.
  • A director for the demo. 85 simulated guests bid through the same engine, and rivals are timed so Glim's move comes within minutes.
  • Live updates through Postgres NOTIFY and server-sent events, with a 5-second polling fallback.
  • Tests: 1,579 Vitest tests in 165 files (unit, and integration on real Postgres), 24 Playwright spec files run by hand, and a sandbox lifecycle script that passed 29 of 29 checks.

Challenges we ran into

  • Letting an agent act without a tap. At the start, Glim could propose a plan but never change one on its own. A guest at a gala is not watching a chat, so we let Glim change the plan without a tap, but inside a mandate the guest confirms with Start bidding: a budget, ranked lots with caps, backups and excluded lots. Skip, Pause or the guest's own maximum narrows it at once; widening it takes a new confirmed card. Making every path respect that took the eleven rules and an engine call that applies a change all at once or not at all.
  • Numbers from a model. A card with a wrong amount is worse than no card. Code writes each card's main line and every amount from what the engine did. Claude writes just the one-line reason, and a filter rejects any reason with a number, an amount or a promise. In chat, a dollar figure that is not in the validated plan replaces the reply with a template.
  • A timeout is not a failure. If a capture times out, the earlier try may have charged. Retries keep the same PayPal-Request-Id, so PayPal replays its answer, and the capture's invoice ID never changes, so a second charge is refused.
  • A demo a judge can finish alone. Real auctions run for hours. We built a private 25-minute gala per visitor, rivals timed so Glim's move comes within minutes, a demo menu to close bidding and switch to the organizer, and a cleanup that voids the gala's holds and deletes it after 6 hours.
  • What the hosted run caught. A run on the hosted site found a bug the tests missed: a blocked popup left a pending hold behind, and My paddle showed "Finish in the PayPal window" with no raise open. We fixed the query and added a test that fails on the old one.
  • Responsive, late. Every approved design was 1440 px wide. To make every page work down to 320 px without changing 1440, every change is a max-width variant. 24 page states at 1440 differ by 0 pixels from before, and two reviews found 26 layout issues, all fixed.

Accomplishments that we're proud of

  • The whole chain ran end to end on the hosted site: a $500 hold approved in PayPal's sandbox window, Glim's move on live Claude with no tap, then $310 captured and $190 released.
  • In a timed local run on live Claude, Glim's card was stored 5.2 s after the rival's bid; the model call took 3.5 s.
  • Glim has no tool that moves money, and CI's lint step checks it.
  • 1,579 tests pass, and the sandbox lifecycle script passed 29 of 29 checks: authorize, a mocked decline, capture, replay, void and raise.
  • The product says what is simulated: a "Sandbox demo" marker, the control room's "The 85 simulated guests settle without PayPal.", and a receipt stamped "Sandbox demo · not a tax document".
  • Every page works from 320 to 1440 px wide in Chrome.

What we learned

  • An authorization fits a bid. A bid is a promise up to an amount, and an authorization holds exactly that. We used user_action: CONTINUE so the buyer approves and comes back. The PayPal page can still say "Pay with PayPal", so our own screens say plainly that approving places a hold, not a charge.
  • Void the original. Raises and cleanups void on the original authorization ID, because a reauthorization can't be voided. Before capturing, settlement checks that each hold is CREATED, unexpired and covers the won total.
  • Give the model fewer powers. The autopilot call has no tools. Code lists the allowed moves; Claude picks one and says why. That made Glim easier to test and easier to trust.
  • Fallbacks are part of the design. Every model decision has a deterministic answer for when Claude is slow, refuses or hits a usage cap, so the auction never waits on the model.
  • Run it where the judges will. The hosted run found a bug that local runs did not.

What's next for Fairhold

  • Run reauthorization on the sandbox. It is built and tested against a fake PayPal, and our test holds pass their 3-day honor period on Nov 11 at 9:09 PM PST.
  • Record a verified webhook delivery and a sweep run on the hosted site (both have automated tests), and add the browser tests to CI.
  • Try Safari and real phones.
  • An organizer setup flow: today every gala comes from the demo template.
  • Talk to small charities that run silent auctions about what they need most.

Agentic commerce

Glim is a shopping agent with a spending mandate. The guest says what they want in one sentence; Glim turns it into a plan card (maximums per lot, a budget, backups the guest hearted), and nothing is bid until the guest presses Start bidding. From then on Glim acts without asking: when a rival passes its limit, it picks one of the moves the mandate allows, such as moving the freed money to a backup, and says what it did and why. The money stays bounded by two things the agent cannot change: the guest's confirmed mandate, checked by deterministic code (rules R0 to R10, every bid through the engine's all-or-nothing createLimits), and the PayPal authorization itself, which no bid can exceed. If the agent needs more money, it can only prepare a raise; the guest approves a new hold in PayPal. The agent never holds a payment tool, so even a wrong decision can never spend more than the guest authorized.

AG Grid in Fairhold

AG Grid Community (ag-grid-community and ag-grid-react 36.2) runs the two organizer tables, the ones an organizer actually works in:

  • The settlement ledger (control room): one row per bidder, with what each winner owes, what is held and what gets released. It has custom cell renderers for the status pills (Simulated, Charging, Charged, Released, Failed with a Retry button, Waiting with Ask again) and for Glim's mark; external filters for All, Winners and Won nothing; a quick-filter search over bidders and lots; pagination; and a CSV export that labels every hold honestly (PayPal or simulated) with each charge's PayPal transaction ID. While charges run, rows update in place through applyTransaction keyed by getRowId, fed by the live stream.
  • Insights' top lots: sortable by bids or estimated value, with number and text column filters, and cells that flash as bids land during the evening (enableCellChangeFlash).
  • Both use a themeQuartz.withParams theme in Fairhold's ink, warm greys and lime, so the grids read as part of the product. On phones the Paddle column is pinned and the rest scrolls inside the grid, never the page.

Built With

  • ag-grid
  • anthropic
  • claude
  • drizzle-orm
  • fast-check
  • github-actions
  • gitleaks
  • nextjs
  • node.js
  • paypal
  • paypal-orders-v2
  • paypal-payments-v2
  • paypal-server-sdk
  • playwright
  • postgresql
  • radix-ui
  • react
  • render
  • server-sent-events
  • tailwindcss
  • typescript
  • vitest
Share this project:

Updates

Submission history