Inspiration
For twenty years the advice on spotting a scam has been the same: look for bad spelling, clumsy grammar, a greeting that gets your name wrong. That advice worked because scams were written by people who did not speak your language.
Since 2024 they are not. A language model writes flawless Czech, German or Vietnamese, personalises it with details scraped from a profile, and produces a message that reads better than most of your real mail. Every surface tell people were taught to look for is gone and the people who were taught those rules hardest are the ones most exposed: older users, children, small business owners.
What an attacker cannot remove is the structure of the manipulation. A scam has to create urgency, claim authority it does not have, cut you off from anyone who could check the story, or ask for something a real sender would never ask for by message. Those are not stylistic slips. They are the purpose of the message, and they survive any amount of polish.
Tells reads for that structure and shows you the exact words.
What it does
Open a message in Gmail, or paste one from anywhere, and Tells gives you a verdict in plain words, each manipulation tactic quoted from the message and highlighted, a concrete next step naming a channel ("call the number on your card, not any number in this message"), and the same explanation again for someone who has never heard the word phishing.
Tactics are named for what the sender is doing to you "Rushing you", "Keeping you from checking", "Asking for your password" not for the taxonomy underneath.
Two modes. Check when I ask is the default: nothing is read until you press the button. Watch every message runs the local rules in your own browser on everything you open, and only sends a message onward if something about it already looks wrong. In normal use, most mail never leaves the machine.
How I built it
Two layers, deliberately kept apart.
Deterministic checks run entirely in the page comparing what a link says against where it goes, spotting punycode and lookalike domains, catching a Reply-To pointing somewhere other than the sender. These are mechanical facts, they cost nothing, and they are what makes the automatic mode free and private.
The model is handed those facts as established, then asked for what rules cannot do: reading for manipulation and explaining it to a person. Its output is constrained to a fixed set of tactic ids, each requiring a verbatim quote no quote, no tactic, because the interface highlights that quote in the original message and a paraphrase would break it.
A verdict floor states combinations that must never come back as harmless a proven link mismatch alongside a request for credentials, isolation alongside a request for money. It can raise the model's verdict. It can never lower it. Nothing in it is tuned on the test set.
Stack: Chrome Extension (Manifest V3, vanilla JS, no build step) → FastAPI → Claude API.
Challenges
Structured output broke in a way I did not expect. Passing the analysis schema as a tool, the model intermittently leaked its own tool-call syntax into field values "tactics": "<parameter name=\"0\">{...}" with the remaining array elements hoisted to top-level keys. The content was always right; only the serialisation broke, and retrying did not help because the fault was in that layer rather than in the sample. I moved to a plain JSON object in a text block and validate everything on the way back. Neither enum nor required is enforced by the API, so that validation is not paranoia it is the only thing holding the contract.
False alarms were the real enemy, not missed scams. A tool that warns about everything gets ignored, and the warning that mattered is ignored with it. Five of the fifteen test cases exist only to catch over-warning: a real invoice with a deadline, a bank's genuine sign-in notice, a password reset the reader asked for, a sale with a countdown. Getting those to safe while keeping the scams at dangerous was most of the prompt work.
The rules reach less far than I assumed. I expected the deterministic layer to carry most of the load. It catches 6 of 10 scams. The four it misses all impersonate a person rather than a brand a boss, an IT helpdesk, an investor, a parent. There is no domain to compare against a list and no link that lies about itself. That finding reframed the whole project: the model is not a wrapper on top of rules, it is the part that handles the cases rules structurally cannot reach.
Getting a quote to survive the trip. The model writes typographic punctuation where the source has ASCII, so a literal match fails on quotes that are otherwise perfect. The same normalisation now exists in three places Python, the signal detector, the card renderer and has to stay identical in all three or a quote passes the eval and then fails to highlight in Gmail.
What I learned
That the interesting question was never "which languages does it support" but "which senders do we know about" and the answer is always a tiny fraction, which is exactly why the model carries the weight.
Also that the reader's language and the message's language are not the same thing. Scams are aimed at people reading in a language they are not fluent in; explaining the trick back to them in that same language helps nobody. The quotes stay in the original; everything written about them comes back in the reader's own language.
What's next
SMS, where iOS has a filtering API built for exactly this. Screenshots, which is the only realistic route into WhatsApp. And contact history the difference between catching scams that imitate famous brands and catching the ones aimed at you.
Built With
- anthropic
- chrome
- claude
- css3
- fastapi
- html5
- javascript
- llm
- manifest-v3
- pixel-art
- pydantic
- python
- rest-api
- uvicorn
Log in or sign up for Devpost to join the conversation.