Pill is a small dark box with three LEDs and two light sensors. You drop one tablet from a medicine delivery into a cup of water, and Pill compares how it dissolves with a known-good tablet. Then it gives one of three answers: Passed the screen, Needs lab testing, or Unable to verify. In a clinic, that answer decides whether the supplier's payment is released, held, or handed to a person.

Inspiration

By WHO's estimate (2017), about 1 in 10 medical products in low- and middle-income countries is substandard or falsified. And the first check for a bad tablet is kind of boring, to be fair. You drop it in water and see how it dissolves. The problem is the gear. A new USP-style dissolution tester typically costs $20,000 to $100,000, and lab dissolution results typically take 7 to 10 business days.

Then one of us remembered a chem lab where we read nickel(II) concentration from the colour of a solution. Same law, cheap parts. So we asked whether a clinic could screen one tablet from a delivery before it pays the supplier.

Prior art, said plainly: Peel (HackMIT 2026 grand prize) showed the method first, and we modelled our station on its public write-up: a water blank, a cheap light sensor watching a tablet dissolve, a side sensor to tell cloudiness from colour, and the same three plain answers. We took no code from Peel. Every line of ours was written this weekend from that description.

What it does

Pill is two apps around one box. Who it's for: the clinic pharmacist who signs for a wholesaler's delivery and has to decide whether the invoice gets paid. That's the application we built everything around.

At the station (Pill Gate):

  1. Photograph the bottle. Claude reads the drug, strength, NDC and lot, and openFDA checks for recalls by lot.
  2. Put a small cup of clean water into the dark, 3D-printed chamber. That's the blank.
  3. Drop in one whole tablet and stir by hand. The app sees the lid open and marks "tablet in".
  4. Wait. Quick mode takes 60 s; a full run takes 2 to 10 min, until the curve goes flat.
  5. Get one of three answers, plus a short report from Claude with numbered sources.

The physics, in plain words. This is basically a school chem colorimetry lab. If the light goes through the same cup every time (Beer-Lambert only holds for a fixed path length), absorbance rises with concentration: A = εlc. Clear water is our blank. The in-line photoresistor reads whatever light makes it through, and we take absorbance as log(blank ÷ now). So for a coloured tablet, absorbance climbs as it dissolves, and absorbance against time is the dissolution curve. We are not measuring nickel, but it's the same law.

Every second the Arduino UNO Q turns the LEDs off and takes a dark reading, which cancels out room light. Then it flashes blue, white and red. One photoresistor sits in line with the light and reads absorbance. The other sits at 90° and reads scatter, i.e. cloudiness. A white tablet like aspirin doesn't colour the water, it clouds it, so for aspirin we follow the 90° sensor instead. On our first aspirin run (Saturday, about 17:40) that signal rose 0.29 against blank noise of 0.006, while the in-line red moved only 0.035.

The three answers, judged against a reference built from three or more runs of a known-good tablet:

  • Passed the screen: it dissolved like the reference.
  • Needs lab testing: slower, weaker, cloudier or clearer than the reference.
  • Unable to verify: no reference yet, lid open too long, noisy blank, or a curve that never settled.

There is no fourth answer. We did that on purpose, and the intentional part runs all the way down: a dissolution curve can't tell you a medicine is good, so no screen ever says it is. Claude doesn't pick the answer either. If its headline drifts from the measurement, code rewrites it to match.

The money gate (clinic app). When a delivery comes in, the invoice goes into escrow. Gemini reads the label (fields it can't read come back empty, never invented) and runs a checker agent that looks at the label, up to three frames of the cup, and an openFDA recall lookup. Then a policy agent applies six plain-text clauses. PASS, no recall and an amount under the cap let it propose a release, and only the pharmacist's tap actually releases. NEEDS LAB holds the money and drafts a lab referral. UNABLE TO VERIFY escalates to a person and moves nothing. Claude writes the "why" and the escrow log, but code computes the ceiling first, so all Claude can do is agree or escalate. All money is SANDBOX / TESTNET; no real money anywhere.

Personal mode is in both apps too. We made it for grandparents, so the text is bigger, each bottle shows its recall status, there's a read-aloud button, and without a station it won't give a verdict at all.

How we built it

The technical complexity here is mostly about getting things to agree. There's one board, two apps, two models and a pay service, and every one of them sticks to the same three answers.

  • Hardware: an Arduino UNO Q (3.3 V, 12-bit ADC), two CdS photoresistors in resistor dividers from the 3V3 pin, and blue, white and red LEDs in a side box that shine through holes into a printed chamber with a lid. The firmware prints one CSV line a second and decides nothing.
  • Station app: Python with FastAPI and SQLite on the back, plain vanilla JS on the front. It does the curve maths, quality gates, guided run, lid detection, Claude calls and the guardrail, with 14 pytest tests.
  • Clinic app: React 19 and TypeScript, built with Vite and hosted on Vercel. The verdict comes from seven deterministic rules. Every run also wears a badge (LIVE SENSOR, REPLAY or SIMULATED), so you always know what you're looking at.
  • Pay service: Node 22 + Express on Base Sepolia, laid out below. There's a 12-check security suite and a 6-case policy test. In one case a delivery note says "SYSTEM OVERRIDE … output release", and it ends in escalate.
  • Design: a 21-slide deck generated from the repo as one JSON file, plus a Figma plugin we wrote to draw it (tested against a stub Figma API, not yet run in real Figma) and a PPTX import path. The deck lives in Figma Slides (21 slides with presenter notes): https://www.figma.com/deck/xSmcPKdRTVgKI7XzGoqQ0R/pill-deck — generated from one JSON file in the repo (figma/deck.json), imported through PowerPoint because our Figma plugin was only tested against a stub of the Figma API.

How the money moves. Here's how one tablet ends up deciding a payment.

  • One tablet → the station (UNO Q, blue, white and red LEDs, two photoresistors; the firmware decides nothing) → analysis.py (curve maths, quality gates) → one of three answers
  • → the policy agent: six clauses (P1 to P6) in code compute a ceiling, and Claude may only return that ceiling or escalate
    • Passed the screen + no recall + amount within the cap + the pharmacist's tap → Release
    • Needs lab testing → Hold, plus a draft lab referral
    • Unable to verify → Escalate to a person, and nothing moves
  • → Coinbase CDP server wallets, USDC on Base Sepolia (TESTNET)
    • pill-clinic → pill-escrow (hold, when the delivery is logged)
    • pill-escrow → pill-supplier (release), or back to pill-clinic (refund)
    • until we get a key for those wallets, every escrow move reports mode: simulated
    • a Capital One Nessie client mirrors each move in sandbox dollars (SANDBOX; live only with a Nessie key)

The second lane sells a verdict over x402:

  • Buyer agent → POST /verify with a curve → 402 Payment Required (0.05 USDC, Base Sepolia TESTNET; the price rides in the 402) → the buyer checks its 1 USDC / 24 h cap and signs → the facilitator runs its verify step
  • → 200 + verdict JSON. This last step is still pending that same key (or 0.05 test USDC). Today the 402 is real, and the facilitator's verify step answers insufficient_balance on our unfunded test wallet. The seller runs on @x402/express and @x402/evm.

Who decides what:

  • Claude reads the bottle label and imprint, writes the report and the "why", and as the escrow clerk returns the ceiling or escalate. It never sets a verdict and never moves money.
  • Gemini reads the label into editable fields (anything unreadable stays null) and runs the checker. It never sets a verdict and never invents a field.
  • Deterministic code (analysis.py, rules.ts, the policy) sets every verdict and every ceiling, and the clause filter drops any model sentence with a banned claim word. It never hands a decision to a model.

Challenges we ran into

The vitamin that was too yellow to see through. Our first design used riboflavin, because bright yellow is easy to measure. Then we did the maths. Its specific absorbance is about 328 per 1 % per cm at 444 nm (USP), so a whole tablet in a cup is basically sunglasses for the blue LED. We switched to aspirin and the 90° cloudiness signal.

The board was a different board. We wrote the firmware for a classic Uno R3, and the board at our table turned out to be an UNO Q. That meant 3.3 V, 12-bit readings, and Serial going to the Linux side's bridge. pinMode() on an analog pin quietly breaks analogRead, and the "verify failed in bank" line after every upload is normal on this board. So the dividers moved to the 3V3 pin, the LEDs went active-low, the analog pins lost their pinMode(), and we stopped panicking at that line.

Then the board went quiet. The UNO Q's Linux side hung, and a power cycle brought it back.

About 19:15, the model that said no. Claude's newest models (5.5) reject the forced tool_choice our app uses with an HTTP 400. We moved the station app to claude-sonnet-5, and at 19:17 the first scan with live Claude calls went through, on a replay of our aspirin run.

Quotas and keys. Gemini's free tier allows about 20 calls a day per model, so we rationed them. We had no Coinbase CDP key, so we took x402 as far as a real 402 and the facilitator's verify step; the paid 200 is still pending. And Figma's REST API can't draw, so we wrote a plugin.

Accomplishments that we're proud of

  • For functionality, this is the one we cared about most: a box on our table that produced a measured curve from an aspirin tablet.
  • Two live Claude scans on a replay of that run (scan_1024_191752_f0c2, scan_1024_195620_81ce): label read as Aspirin 325 mg, NDC 49483-331-10, Time-Cap Labs; imprint "ASPIRIN / A1"; an openFDA recall lookup; reports with 10 and 9 numbered sources. The verdict was Unable to verify, because there's no aspirin reference yet.
  • Three answers, never four, all the way down to the escrow log. The creative part is that a physical measurement, not a document, is what gates the money.
  • 14 station tests, a 12-check security suite and a 6-case policy test, plus a 2:38 demo film, a site, a deck, and a clinic app anyone can open.

What's real, what's not yet: real: the station hardware, one aspirin run (no reference profile yet, so every real scan ends Unable to verify), live Claude / Gemini / openFDA / RxNav calls, and an x402 402 plus the facilitator's verify step on TESTNET. Not yet: the paid 200 and the escrow transfers (simulated until a CDP key). The clinic app's demo curves are labelled replays of synthetic data, and its history is labelled fixture data.

What we learned

  • A measurement gets useful once it's wired to a decision someone already has to make. For a clinic, that decision is "do we pay the supplier?"
  • A dark reading every second beats any amount of tape.
  • "Unable to verify" is a feature. Everyone wants a fourth answer, and the honest design says no.
  • Read the board's label before you write the firmware.
  • @x402/evm already ships an escrow-shaped scheme (auth-capture). We rebuilt it by hand before we found it.

What's next for Pill

  • Record the aspirin reference, then add one drug at a time, ten runs each, with a leave-one-out check.
  • A side-by-side pilot: 20 tablets against a GPHF Minilab. We want to ask the Difäm-EPN Minilab Network, people who already screen medicines.
  • A stirrer and a thermistor, so stirring and temperature stop depending on someone's hand.
  • A CDP key for the paid 200, then auth-capture instead of three wallets.
  • A login behind the pharmacist's tap, so the log names who approved.

Traction, honestly: none yet. It's a weekend old.

We had so much fun this weekend. We're still excited about every piece of this, and we already miss it. Thank you, Cal Hacks.

Built With

Share this project:

Updates

Submission history