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é:
- Extracts the vendor, account, routing, amount, and reply-to from the raw text
- Resolves the claimed vendor against the vendor master
- 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"
- Holds any payment-detail change, by policy, not by score
- Dials the number already on file and asks the vendor to confirm
- Judges the transcript:
confirmed,denied, orunclear→ 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-01xxnumber - 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_schemamode 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
unclearcases - 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
Log in or sign up for Devpost to join the conversation.