Trouvé mark

Trouvé

The email is the attack, so we don't use email to check it.

Built in one day at SteelHacks XIII · University of Pittsburgh · September 19–20, 2026


Inspiration

Business email compromise is the least glamorous fraud on earth and one of the most expensive. Nobody hacks anything. Someone emails accounts payable pretending to be a vendor you already pay, says "we switched banks," and waits.

Every AP training deck says the same thing: call the vendor back at the number you already have. Everyone agrees. Nobody does it, because it's a boring phone call on a busy afternoon.

So we asked: what if the phone call wasn't optional? What if the software made it?

That's Trouvé.

What it does

An invoice email comes in. Trouvé:

  1. Extracts the vendor, account, routing, amount, and reply-to from the raw text
  2. Resolves the claimed vendor against the vendor master
  3. Scores the request against that vendor's history — new bank account, look-alike domain, mismatched reply-to, "I'm in meetings all day, please don't call"
  4. Holds any payment-detail change, by policy, not by score
  5. Dials the number already on file and asks the vendor to confirm
  6. Judges the transcript: confirmed, denied, or unclear → a human

The cost model is deliberately lopsided. Let $c_{FP}$ be a two-minute phone call and $c_{FN}$ be the invoice. We hold when

$$P(\text{fraud}) \cdot c_{FN} \;>\; \bigl(1 - P(\text{fraud})\bigr) \cdot c_{FP}$$

and since $c_{FN} \gg c_{FP}$, the threshold is loose on purpose. Being wrong costs a phone call. Being wrong the other way costs the money.

How we built it

ingest → extract → resolve → score → hold → call → judge → resolve
Piece What it does
Nemotron ×3 Email → structured JSON · risk rationale · transcript judge
ElevenLabs TTS for the agent's question, STT for the vendor's answer
Twilio Places the real outbound call, when enabled
Python / FastAPI Pipeline and hold-queue backend
Vercel Hosting the AP clerk's queue UI
evals/ Labeled synthetic emails and transcripts, so we could measure instead of vibe

The extractor uses constrained json_schema decoding after the model kept freelancing on plain prompts. The judge is a separate model call with a separate prompt, because "did this transcript say yes" is a different job from "is this email sketchy."

Challenges we ran into

The free tier fought back

Our commit log at 12:53 just says "Survive NVIDIA's flaky free tier." By 17:06 it's "Cut Nemotron failures by measuring what actually fails," and by 17:58 it's "Report requests, not attempts; cut the rate limiting that caused them." We spent a real chunk of the day learning that our own retry logic was what was getting us rate-limited.

We also discovered a fallback model that reliably failed the prompt, and we were quietly routing to it whenever the primary hiccupped. Our fallback was a failure generator. Ripped it out.

Every call came back "inconclusive"

The judge returned unclear on everything, including a transcript that literally said "no, we did not send that." One line in the prompt was confusing the model. Deleting it fixed it. 13:43.

Serverless has no memory

Deployed to Vercel and watched demo state evaporate between requests: every request landed on a different instance, and the seed script ran on every page view and wiped the queue. Two commits, 14:13 and 14:19, to make state survive instance hopping.

Concurrency in a demo reset

Clicking Reset twice fast lost rows. Fixed at 13:08, which is early enough that we're proud of it.

Not dialing real people

Once Twilio was wired up, the scariest bug in the repo became "an inbound email triggers a real phone call to whoever." So:

  • every seed vendor points at an unroutable 555-01xx number
  • live calling sits behind four separate config flags
  • the number always comes from the vendor master — nothing in an email can ever choose who gets dialed

Renaming at 8pm

The project was called Callback until 20:06 on Saturday, when we decided that was a function name, not a product. Trouvé — found — because that's what happens to the attack.

What we learned

  • Constrained decoding beats prompt-begging. json_schema mode made the extractor boring in the best way.
  • Measure before you retry. Half our "model failures" were self-inflicted.
  • A fallback that fails is worse than no fallback, because it hides the real error.
  • Demo state is production state. If the reset button lies, the judges can tell.
  • The best fraud signal isn't fancy. The account differs from the last four payments. Legit vendors just don't switch banks that often.

What's next

  • Real ERP hooks (QuickBooks, NetSuite) so the vendor master isn't a JSON file
  • A proper escalation queue for unclear cases
  • A bigger, nastier labeled attack set, and doing the eval work properly

False positives cost a two-minute phone call. False negatives cost the invoice.

Built With

  • chatgpt
  • claude
  • css
  • elevenlabs-api
  • elevenlabs-scribe-stt
  • elevenlabs-turbo-v2.5-tts
  • fastapi
  • git
  • html
  • httpx
  • javascript
  • json-schema-constrained-decoding
  • mail.tm-api
  • nvidia-nemotron-3-nano
  • nvidia-nemotron-3-super
  • nvidia-nemotron-3.5-lightning
  • nvidia-nim-api
  • python-dotenv
  • sqlite
  • thon
  • twilio-voice-api
  • uv
  • uvicorn
  • vercel
  • vercel-serverless-functions
Share this project:

Updates

Submission history