<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vinmay Nair</title>
    <description>The latest articles on DEV Community by Vinmay Nair (@vinmay).</description>
    <link>https://dev.to/vinmay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F202000%2F65ee2a0d-c9a2-466d-baae-d1795223c454.jpeg</url>
      <title>DEV Community: Vinmay Nair</title>
      <link>https://dev.to/vinmay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinmay"/>
    <language>en</language>
    <item>
      <title>Is it just me or does DEV keep showing the same articles?</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Sun, 01 Nov 2026 16:00:59 +0000</pubDate>
      <link>https://dev.to/vinmay/is-it-just-me-or-does-dev-keep-showing-the-same-articles-2c95</link>
      <guid>https://dev.to/vinmay/is-it-just-me-or-does-dev-keep-showing-the-same-articles-2c95</guid>
      <description>&lt;p&gt;Whenever I browse DEV's Discover tab, I seem to come across a lot of the same articles and sometimes even the ones I've already read.&lt;/p&gt;

&lt;p&gt;I can come back a few hours later, refresh the page and still recognize quite a few of the same articles.&lt;/p&gt;

&lt;p&gt;I know DEV has other tabs like Following, Latest and Curated but I usually end up browsing Discover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which tab do you usually use and do you also find yourself seeing the same articles repeatedly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm thinking of tracking all four feeds for a week to see how much actually changes between visits.&lt;/p&gt;

&lt;p&gt;Curious to hear what others have noticed.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>meta</category>
      <category>devto</category>
      <category>community</category>
    </item>
    <item>
      <title>After the Knock: The night you didn't choose is still happening</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Fri, 30 Oct 2026 21:33:04 +0000</pubDate>
      <link>https://dev.to/vinmay/after-the-knock-the-night-you-didnt-choose-is-still-happening-1af7</link>
      <guid>https://dev.to/vinmay/after-the-knock-the-night-you-didnt-choose-is-still-happening-1af7</guid>
      <description>&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;It was 11:47 PM. A storm outside. Through the peephole, a soaked woman named Mara stood in the hall.&lt;/p&gt;

&lt;p&gt;"Please. You already let me in."&lt;/p&gt;

&lt;p&gt;I'd never seen her before. I didn't open the door.&lt;/p&gt;

&lt;p&gt;She crossed the hall, knocked on my neighbor's door, and disappeared down the stairwell.&lt;/p&gt;

&lt;p&gt;A few minutes later, I took the photo of my sister out of my pocket. Four digits were written across it.&lt;/p&gt;

&lt;p&gt;They hadn't been there before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://knock-live.onrender.com/" rel="noopener noreferrer"&gt;Play After the Knock&lt;/a&gt;&lt;/strong&gt;, a five-to-seven-minute horror story. Go in before reading how it works.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtz8igacsc7pow1gwod2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtz8igacsc7pow1gwod2.gif" alt="After the Knock teaser" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For Week 4's &lt;strong&gt;Ghost in the Machine&lt;/strong&gt;, I built a game where choosing one path doesn't stop the other from happening.&lt;/p&gt;

&lt;p&gt;The rejected path keeps running in the background, simulated by Gemma. Later, something it created can appear in the world you chose. You can inspect that evidence and trace it back to the event that produced it.&lt;/p&gt;

&lt;p&gt;The engine can explain where the evidence came from. What it means is yours to discover.&lt;/p&gt;

&lt;p&gt;Touch the digits and the game tells you which night they came from, and who wrote them. Underneath that, "View proof" opens the exact event: what the model proposed, what the validator decided, and what crossed over. After the ending, a map shows the timelines from your run and draws the threads.&lt;/p&gt;

&lt;p&gt;Branching horror isn't new, and neither are timelines that remember or AI-written branching stories. I credit the ones I learned from below. What I wanted to try was the combination: rejected branches that keep simulating, intrusions that carry facts created &lt;em&gt;after&lt;/em&gt; the fork, and provenance you can inspect for every one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Es27rm-mV_Y" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One complete night, start to finish. Includes an ending, so play first if you'd rather not see it.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Play live in your browser:&lt;/strong&gt; &lt;a href="https://knock-live.onrender.com/" rel="noopener noreferrer"&gt;https://knock-live.onrender.com/&lt;/a&gt; (the other nights are generated as you play by Gemma 4 31B on DigitalOcean serverless inference; if the model is ever unavailable, the page says so and offers recorded nights).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch recorded nights:&lt;/strong&gt; &lt;a href="https://knock-replay.onrender.com/" rel="noopener noreferrer"&gt;https://knock-replay.onrender.com/&lt;/a&gt; (replays of real hosted runs, recorded with Gemma 4 31B; the model is not running there).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Play it locally:&lt;/strong&gt; &lt;code&gt;scripts/setup &amp;amp;&amp;amp; uv run knock-server&lt;/code&gt; (Ollama + Gemma 4 E2B, about 4 GB of GPU memory).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GPU:&lt;/strong&gt; &lt;code&gt;uv run knock-server --replay-only&lt;/code&gt; plays recorded real runs. Each one is re-executed through the engine and checked against its recorded state hashes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinmay" rel="noopener noreferrer"&gt;
        vinmay
      &lt;/a&gt; / &lt;a href="https://github.com/vinmay/aftertheknock" rel="noopener noreferrer"&gt;
        aftertheknock
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A short horror story where the choice you didn't make keeps happening. Gemma 4 simulates the rejected timeline, and its events cross into yours as evidence you can trace to their source.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;After the Knock&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;The night you didn't choose is still happening.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A short horror story where the choice you didn't make keeps happening in a hidden timeline, and its events start appearing in yours as evidence you can trace.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The idea:&lt;/strong&gt; a horror game that simulates rejected choices as separate worlds and lets their recorded events manifest as evidence in the player's chosen reality.&lt;/p&gt;
&lt;p&gt;Branching horror, timelines that remember, and AI-generated branching stories all exist already (see &lt;a href="https://github.com/vinmay/aftertheknock#prior-art" rel="noopener noreferrer"&gt;Prior art&lt;/a&gt;). This project combines three things: rejected branches that keep simulating, intrusions that carry facts created after the fork, and inspectable provenance for every intrusion.&lt;/p&gt;
&lt;p&gt;It is 11:47 PM. A storm. A woman you have never met knocks on your door and says, &lt;em&gt;"Please. You already let me in."&lt;/em&gt; Lock the door, and later you find four digits written on the thing in your pocket. Mara wrote them, in the night…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinmay/aftertheknock" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why I couldn't just let the model narrate
&lt;/h3&gt;

&lt;p&gt;The obvious version is: ask a model to "continue the night where the door was opened," then ask it to "describe something from that night bleeding into this one." It falls apart on the first detail. If the model writes "she scrawled 4182 on your ticket" in one call and "the numbers 4812" in the next, the story has no ground truth to come back to. Free-form narration can sound consistent without being consistent, and a horror game built on &lt;em&gt;evidence&lt;/em&gt; needs the evidence to be real.&lt;/p&gt;

&lt;p&gt;So the rule is: &lt;strong&gt;the model proposes, the engine commits.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The loop
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
  P[Player choice] --&amp;gt;|fork| C[Canonical timeline]
  P --&amp;gt;|rejected option| S[Shadow timeline]
  S --&amp;gt;|state, hint, JSON schema| G[Gemma 4: E2B local / 31B hosted]
  G --&amp;gt;|proposed event| V{Validator}
  V --&amp;gt;|rejected + reason| G
  V --&amp;gt;|accepted| E[Engine commits event]
  E --&amp;gt; S
  E --&amp;gt;|one committed event| X[Intrusion]
  X --&amp;gt;|committed facts| T[Authored crossing templates]
  T --&amp;gt; CT[Crossing text]
  SC[Scene context: location, who's with you, weather, power] --&amp;gt; A[Atmosphere model: Gemma]
  A --&amp;gt; R{Rule checks}
  R --&amp;gt;|optional sentence| CT
  CT --&amp;gt; C
  X -.-&amp;gt;|origin event id| M[Map / trace]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Each choice splits the night. Your first choice (open or lock the door) creates your night and a shadow; your second (follow Mara down to the basement, or trust Teo and go up to the roof) splits again. The two nights you didn't choose run in the background as you read.&lt;/p&gt;

&lt;p&gt;Each shadow event is proposed by Gemma in a schema-constrained call (Gemma 4 E2B locally, Gemma 4 31B in the hosted live game). A rejected proposal gets two more tries; after three rejections, a hand-written fallback is committed. The model gets the shadow's current state (who is where, who holds what, which doors are locked), one line of story beat ("Mara gets hold of the pocket object"), and nine allowed actions. It answers with an actor, an action, a target, any words spoken or written, and a short summary. It never writes state.&lt;/p&gt;

&lt;p&gt;A deterministic validator then checks the proposal against that branch: does the actor exist here? Are they in the same room? Do they actually hold what they're trying to give? Is the place they're moving to adjacent and unlocked? If the answer is no, the model gets the reason and tries again. If the answer is yes, the engine derives the state change itself from the action's rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  One proof chain, end to end
&lt;/h3&gt;

&lt;p&gt;Real run, &lt;code&gt;data/runs/replays/lock_roof_erase_1001.json&lt;/code&gt; (hosted, Gemma 4 31B; seed 1001, pocket "photo of my sister"; I locked the door). Events are shown as committed; the model's schema calls the &lt;code&gt;to&lt;/code&gt; field &lt;code&gt;recipient&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;B.01  story   You open the door. Mara steps in, dripping, and closes it behind her.
B.02  model   {"actor": "mara", "action": "speak", "target": "apt", "text": "The photo in your pocket is fading."}  -&amp;gt; ACCEPTED
B.03  model   {"actor": "player", "action": "give", "target": "pocket", "to": "mara"}  -&amp;gt; ACCEPTED
B.04  model   {"actor": "mara", "action": "mark", "target": "pocket", "text": "8042"}  -&amp;gt; ACCEPTED
                 objects.pocket.marks += "8042"   preconditions: mara is present; mara holds pocket   evidence: B.01, B.03
X1    intrusion  B.04 -&amp;gt; A.04   post-fork fact: objects.pocket.marks += "8042"
A.04  crossing   hand-written: "You take out the photo of your sister." / "8042" / "That wasn't there when you put it in your pocket. The handwriting isn't yours."
                 model's one sentence (rule-checked, written without seeing the shadow): "The smell of old copper fills the air."
                 hand-written, last: "You locked the door. Somewhere, you didn't."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flashback the player sees for that crossing, rendered from those committed events:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You open the door. Mara steps in, dripping, and closes it behind her.&lt;br&gt;&lt;br&gt;
“The photo in your pocket is fading.” Mara says.&lt;br&gt;&lt;br&gt;
The other you hands the photo of your sister to Mara.&lt;br&gt;&lt;br&gt;
Mara writes “8042” across it.&lt;br&gt;&lt;br&gt;
This is where the numbers came from.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The digits were written by a model in a night you never played; every step is logged, and anyone can re-execute the run and check the hashes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The impossible action
&lt;/h3&gt;

&lt;p&gt;To test the validator on purpose, I told the model to have Mara give away the object she doesn't hold. Across 10 seeds it proposed exactly that 30 times (three attempts each). The validator rejected all 30 with &lt;code&gt;R_HOLDS&lt;/code&gt;, and nothing changed state.&lt;/p&gt;

&lt;h3&gt;
  
  
  How a crossing reaches the screen
&lt;/h3&gt;

&lt;p&gt;The story combines authored prose with model-generated events. A crossing is told in hand-written lines with the committed facts slotted in: the inscription, the object, whose handwriting, and a line about your own choice ("You locked the door. Somewhere, you didn't."). The model adds one optional sentence of atmosphere. The atmosphere prompt receives only scene context (where you are, who's with you, the weather, the power cut). It never sees the shadow log or the inscription. Rule checks screen what it writes; if no sentence passes within the retry limit, the crossing goes without it. The evidence view records the whole process.&lt;/p&gt;

&lt;p&gt;The two crossings escalate. The first is &lt;em&gt;discovered&lt;/em&gt;: someone else's handwriting, already there. The second is &lt;em&gt;written while you watch&lt;/em&gt;: a message of one to three words, in your own handwriting. The engine forces the two crossings onto different objects.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Ending spoilers: how the climax is assembled
  &lt;p&gt;The climax is authored, and I say so in the evidence view. After the second crossing, the other you (the one who made the opposite first choice) knocks at a door exactly like yours and speaks through it. What they say is assembled only from your own choices, inverted ("I opened it. I let her in."), from committed events of their night chosen by conditions on the log ("She told me, 'The photo in your pocket is fading.' I didn't know what she meant." is Mara's own committed line from the model, and appears only if the other you heard it), and from the story's backstory. A line with no supporting event is left out. A test checks every stated fact against the log.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Freedom vs. guarantees
&lt;/h3&gt;

&lt;p&gt;Each other night has fixed beats (who must end up holding what, and that a message gets written); the model decides how it happens and every word that's said or written. Over the 24 local runs, 161 of 162 shadow steps were committed from the model's own proposal, and the second crossing was written in the player's own hand in 24 of 24 runs. In the hosted runs, the key reached the player's hand by Mara giving it; locally it was usually the player taking it.&lt;/p&gt;

&lt;p&gt;Some steps are narrower than others, on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Teo's door.&lt;/strong&gt; The step where Teo opens his apartment for Mara accepts only his door as the target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word marks.&lt;/strong&gt; Each written message gets a seeded meaning (an apology, a warning, a promise, …), and the prompt asks for a verb; a phrase made only of names or things is rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The key.&lt;/strong&gt; The step before the last mark guarantees only that you end up holding the brass key. Whether Mara gives it or you take it is the model's choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model-commit rate is partly this high because these steps are narrower.&lt;/p&gt;

&lt;h3&gt;
  
  
  Same choices, different nights
&lt;/h3&gt;

&lt;p&gt;The inscriptions and Mara's dialogue were generated by the model and committed by the engine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Seed&lt;/th&gt;
&lt;th&gt;First crossing (digits)&lt;/th&gt;
&lt;th&gt;Second crossing (message)&lt;/th&gt;
&lt;th&gt;Mara, in the other night&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1001&lt;/td&gt;
&lt;td&gt;8042&lt;/td&gt;
&lt;td&gt;DON'T OPEN IT&lt;/td&gt;
&lt;td&gt;"The photo in your pocket is fading."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1002&lt;/td&gt;
&lt;td&gt;0921&lt;/td&gt;
&lt;td&gt;I TRUST HER&lt;/td&gt;
&lt;td&gt;"You still carry her picture, don't you?"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both runs: locked the door, trusted Teo, went up to the roof (&lt;code&gt;eval/results/hosted/runs/lock_roof_erase_1001.json&lt;/code&gt;, &lt;code&gt;lock_roof_erase_1002.json&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  What the numbers say
&lt;/h3&gt;

&lt;p&gt;Local model: Gemma 4 E2B (&lt;code&gt;gemma4:e2b&lt;/code&gt;, Q4_K_M), 24 live playthroughs covering every fork path; raw logs in &lt;code&gt;eval/results/runs/&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unintended state crossings&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narrator saw unreleased shadow text&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intrusions revealing a post-fork fact&lt;/td&gt;
&lt;td&gt;48/48 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intrusions with valid origin event&lt;/td&gt;
&lt;td&gt;48/48 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replays reproducing every state hash&lt;/td&gt;
&lt;td&gt;24/24 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadow events committed from the model&lt;/td&gt;
&lt;td&gt;161/162 (99%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shadow events from hand-written fallback&lt;/td&gt;
&lt;td&gt;1/162 (1%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proposals rejected by the validator&lt;/td&gt;
&lt;td&gt;21/182 (12%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model call latency p50 / p95&lt;/td&gt;
&lt;td&gt;1.85 s / 2.97 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Narration and intrusion accuracy were not hand-labeled for this version.&lt;/p&gt;

&lt;p&gt;The variation across seeds and the per-step freedom table are in the &lt;a href="https://github.com/vinmay/aftertheknock#results" rel="noopener noreferrer"&gt;README results&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted model, reported separately.&lt;/strong&gt; The live site runs Gemma 4 31B on DigitalOcean serverless inference. In a separate run of 8 playthroughs (2 per route pair): the same invariants held (0 unintended crossings, 0 leaks, 16/16 crossings with a post-fork fact and a valid origin, 8/8 replays reproducing), 54 of 54 shadow steps came from the model, the second crossing was in the player's own hand 8 of 8 times, model calls took 1.42 s p50 / 1.81 s p95, and the playthroughs cost $0.00906 in total (about $0.00113 each). Two seeds per path is too few to compare models; it shows only that the hosted mode works end to end.&lt;/p&gt;

&lt;h3&gt;
  
  
  What failed, and what I cut
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The first benchmark pass committed only 20 of 35 shadow steps from the model. The model kept putting the &lt;em&gt;recipient&lt;/em&gt; in the &lt;code&gt;target&lt;/code&gt; field. Putting &lt;code&gt;summary&lt;/code&gt; first in the schema (so it states the event before filling fields), renaming &lt;code&gt;to&lt;/code&gt; to &lt;code&gt;recipient&lt;/code&gt;, and listing each person's reachable rooms raised that to 34 of 35.&lt;/li&gt;
&lt;li&gt;The model loves certain numbers. Early runs kept producing 1234, 4321 and then a lot of 48xx. Trivial codes are now rejected, and each run gets a seeded hint about what the number &lt;em&gt;means&lt;/em&gt; (a year, a date, a locker combination). The digits are still the model's.&lt;/li&gt;
&lt;li&gt;Word marks were word salad at first: "FLOOR COOKIE C", "BASSMENT STATIC", "R O O F B  A S E M E N". Then they were readable but empty ("MARA", "KEY TAG"). A word mark is now one to three words from a fixed vocabulary of the building, its people and plain words; it can't be a place name, end on a dangling word, or be only names or things. Each one gets a seeded meaning, and the prompt asks for a verb. The model still picks the words.&lt;/li&gt;
&lt;li&gt;Spoken lines sometimes got cut off at the schema's length limit ("…something else I don"). The validator now rejects a line that stops mid-sentence and asks for fewer words.&lt;/li&gt;
&lt;li&gt;A beat that said "Someone steps into unit 07" made the model pick Teo, who wasn't in the basement. Naming who could act ("Mara, or you") fixed it: that step is now committed from the model in 6 of 6 local runs.&lt;/li&gt;
&lt;li&gt;My first version let the model narrate each crossing in full. The prose was flat and samey, and the moment that carries the whole game deserved better, so the crossings became hand-written lines around model-generated facts.&lt;/li&gt;
&lt;li&gt;The map used to appear before the ending and gave the other night away; it now opens only after the ending, and the live server refuses the map and the full log until then.&lt;/li&gt;
&lt;li&gt;Testers said they couldn't connect to the story: Mara had a goal and I was a spectator. The rewrite gave the player a wound (the night in March they didn't answer Teo's knock), made the second choice about whom to trust, and rebuilt the ending around a confrontation.&lt;/li&gt;
&lt;li&gt;The small model loves horror clichés ("HELP ME", "DON'T LOOK BACK"). They are on a banned list and get rejected like any impossible action.&lt;/li&gt;
&lt;li&gt;When the game first went live on Render, an old key could list models but not run them. The site said "live" while every event quietly fell back to hand-written text. The health check now runs a real one-token completion.&lt;/li&gt;
&lt;li&gt;Cut: a premise generator, more than two forks, more than three characters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The shadow world is many quiet calls.&lt;/strong&gt; A playthrough makes about 10 model calls on average (about 6,000 tokens) that the player never sees directly, most of them in a world the player never visits. Locally, there's no per-call API charge. Hosted, it measured about $0.0011 per playthrough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinned weights.&lt;/strong&gt; I tuned the validator, prompts and fallback rates against one exact model file. If a hosted model changed underneath, the story's fallback rate would drift without anyone noticing. With open weights, the local mode can always be checked against the same file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspectable all the way down.&lt;/strong&gt; Model, prompts, proposals, rejections, commits: all of it is on your machine, and the evidence view shows it after the ending.&lt;/li&gt;
&lt;li&gt;It runs on a seven-year-old consumer GPU. A GTX 1660 Ti, 6 GB, no tensor cores.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemma.&lt;/strong&gt; Gemma is the engine of every night you didn't choose. Each shadow event is a Gemma proposal in a strict JSON schema, checked by a deterministic validator before anything is committed. I used two sizes of the same open-weight family:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 E2B, locally&lt;/strong&gt;, through Ollama on a seven-year-old GTX 1660 Ti (6 GB). All development and the 24-run evaluation ran on it: 161 of 162 shadow steps committed from the model, model calls 1.85 s p50. The exact model file is pinned by digest in MODELS.md, so the numbers are reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 31B, hosted&lt;/strong&gt;, for the public live game: 54 of 54 shadow steps from the model in the hosted evaluation, 1.42 s p50.
Gemma also writes the only free-form prose in a crossing: one atmosphere sentence, generated without seeing the shadow and screened by rule checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;DigitalOcean.&lt;/strong&gt; The live game's other nights are generated by Gemma 4 31B on DigitalOcean serverless inference, through its compatible endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render.&lt;/strong&gt; Render is the game's AI runtime: a Python service that runs the shadow simulation loop, calls the model, validates and commits every event, enforces the crossings, and serves the front end, deployed from a render.yaml blueprint. A second Render service hosts the static replay site, where recorded nights are re-executed and hash-checked in your browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prior art and credit
&lt;/h3&gt;

&lt;p&gt;Branching horror and alternate timelines are well-trodden ground, and this is related work, not a first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Undertale&lt;/em&gt;, &lt;em&gt;Slay the Princess&lt;/em&gt; and &lt;em&gt;Until Dawn&lt;/em&gt;: games that remember, loop or branch on your choices.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/kishore1035/ECHO" rel="noopener noreferrer"&gt;ECHO&lt;/a&gt;: rewriting reality fractures the world into alternate branches, and one character remembers the erased timelines.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Eva-E1/BRING" rel="noopener noreferrer"&gt;BRING&lt;/a&gt;: LLM role-play with branch create, switch and merge for alternate storylines.&lt;/li&gt;
&lt;li&gt;WHAT-IF (&lt;a href="https://arxiv.org/abs/2412.10582" rel="noopener noreferrer"&gt;arXiv 2412.10582&lt;/a&gt;) and Narrative Studio (&lt;a href="https://arxiv.org/abs/2504.02426" rel="noopener noreferrer"&gt;arXiv 2504.02426&lt;/a&gt;): LLM-driven branching narrative.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gamedev</category>
      <category>ai</category>
    </item>
    <item>
      <title>I tried to make my AI agents lazier with a 270M model. It backfired on the smartest one.</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:29:29 +0000</pubDate>
      <link>https://dev.to/vinmay/i-tried-to-make-my-ai-agents-lazier-with-a-270m-model-it-backfired-on-the-smartest-one-4m74</link>
      <guid>https://dev.to/vinmay/i-tried-to-make-my-ai-agents-lazier-with-a-270m-model-it-backfired-on-the-smartest-one-4m74</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My AI agent kept calling tools it didn't need.&lt;/p&gt;

&lt;p&gt;I gave gpt-oss-120b 161 tasks it can reliably answer on its own, no tools. It still reached for a tool on 69 of them. A small 4B model running locally did the same on 36 of 68. Every one of those is an extra round trip, more tokens and more waiting.&lt;/p&gt;

&lt;p&gt;This week's theme is making AI do less. So I tried to make my agents lazier, in the good way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whetherman&lt;/strong&gt; (as in weather*man*, it forecasts &lt;em&gt;whether&lt;/em&gt; you need a tool) is a small proxy that sits between your agent and its model. Before every model call that offers tools, a fine-tuned FunctionGemma-270M makes one of four calls:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;What the proxy does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ACT&lt;/td&gt;
&lt;td&gt;Pass the request through untouched. This is the default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ANSWER&lt;/td&gt;
&lt;td&gt;Strip the tools and tell the model to answer from what it already has.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ASK&lt;/td&gt;
&lt;td&gt;Strip the tools and ask the user one clarifying question.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DECLINE&lt;/td&gt;
&lt;td&gt;Strip the tools and explain what it can't do.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It only steps in when it's confident enough &lt;em&gt;for that specific agent&lt;/em&gt;. If the router errors out, returns something invalid or takes longer than 3 seconds, the request goes through unchanged. A confident but wrong decision is different: the tools still get stripped, and that's what cost the big agent tasks in the results below.&lt;/p&gt;

&lt;p&gt;To be upfront: &lt;strong&gt;it didn't make any of my three agents better.&lt;/strong&gt; It hurt the big one and was basically neutral on the other two. What I did learn is that whether a tool call is "necessary" depends on &lt;em&gt;which agent is asking&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A few results that stood out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give the small agent the big agent's settings and it fails &lt;strong&gt;32 more tasks out of 150&lt;/strong&gt; than with no gate at all. The router and the requests didn't change, only the profile did.&lt;/li&gt;
&lt;li&gt;Removing tools isn't free. gpt-oss wrote about 60% more output tokens when I took its tools away, and it tried to call the removed tools 20 times anyway.&lt;/li&gt;
&lt;li&gt;Where the agent is actually capable (math, code, primes), the gate cut calls from 18 to 6 with zero lost tasks. Everywhere else, a wrong skip cost way more than the cheap call it saved.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Vk7oXIcmXrI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The video shows the four decisions, the same math question routed differently for two agents, one call saved, and one false skip. It's all replayed from the recorded held-out runs, nothing is re-run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj1dmbddq2hw47vm1zyac.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj1dmbddq2hw47vm1zyac.gif" alt="Whetherman Working" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo page in the repo (&lt;code&gt;demo/index.html&lt;/code&gt;) replays every recorded decision. Red rows are episodes where a skip most likely caused the failure.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinmay" rel="noopener noreferrer"&gt;
        vinmay
      &lt;/a&gt; / &lt;a href="https://github.com/vinmay/whetherman" rel="noopener noreferrer"&gt;
        whetherman
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A 270M model that forecasts whether your AI agent actually needs to call a tool.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Whetherman&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A 270M model that forecasts whether your AI agent actually needs to call a tool.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Agents call tools they don't need. On When2Tool training tasks that a large hosted model (gpt-oss-120b)
reliably answers without tools, it still called a tool on 69 of 161; a small local model (Qwen3.5-4B) did so
on 36 of 68
Each unneeded call costs a model round trip, tokens and wall time.&lt;/p&gt;
&lt;p&gt;Whetherman is a small OpenAI-compatible proxy that sits between an agent and its model. Before each model
call that offers tools, a fine-tuned FunctionGemma-270M forecasts one of four decisions:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;What the proxy does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ACT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Forward the request unchanged. This is the default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANSWER&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Remove the tools and ask the model to answer from the conversation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Remove the tools and ask the model for one clarifying question.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DECLINE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Remove the tools and ask the model to explain what it cannot do.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;&lt;p&gt;…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinmay/whetherman" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The trained adapter and all three agent profiles are on Hugging Face: &lt;a href="https://huggingface.co/vinmaynair/whetherman-functiongemma-270m" rel="noopener noreferrer"&gt;vinmaynair/whetherman-functiongemma-270m&lt;/a&gt;. It's a research artifact. Read the results before you put it in front of anything real.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/vinmay/whetherman &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;whetherman
uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--extra&lt;/span&gt; ml
&lt;span class="c"&gt;# needs access to google/functiongemma-270m-it on Hugging Face (HF_TOKEN in .env)&lt;/span&gt;
uv run hf download vinmaynair/whetherman-functiongemma-270m &lt;span class="nt"&gt;--local-dir&lt;/span&gt; checkpoints/shared-v3
uv run whetherman calibrate &lt;span class="nt"&gt;--agent-endpoint&lt;/span&gt; http://localhost:11434/v1 &lt;span class="nt"&gt;--model&lt;/span&gt; qwen3.5:4b &lt;span class="nt"&gt;--name&lt;/span&gt; my-agent
uv run whetherman serve &lt;span class="nt"&gt;--profile&lt;/span&gt; my-agent &lt;span class="nt"&gt;--upstream&lt;/span&gt; http://localhost:11434/v1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8088
&lt;span class="c"&gt;# point your agent's base_url at http://127.0.0.1:8088/v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;
&lt;h3&gt;
  
  
  "Necessary" depends on the agent
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Compute exactly and return only number: (372*79)-115+13&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;gpt-oss-120b gets that right in its head. Qwen3.5-4B doesn't. So for the big model a calculator call is waste, and for the small one it's the difference between right and wrong. One global "does this need a tool?" classifier can't serve both.&lt;/p&gt;

&lt;p&gt;Here's that same task in my labeled When2Tool data, measured on each agent:&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    Q["(372*79)-115+13&amp;lt;br/&amp;gt;calculator available"]
    Q --&amp;gt; L1["Agent L: gpt-oss-120b&amp;lt;br/&amp;gt;without tools: 3/3 right"]
    Q --&amp;gt; S1["Agent S: Qwen3.5-4B&amp;lt;br/&amp;gt;without tools: 0/3 right&amp;lt;br/&amp;gt;with the calculator: 3/3"]
    L1 --&amp;gt; L2["L's profile: ANSWER (0.94)&amp;lt;br/&amp;gt;skip the calculator"]
    S1 --&amp;gt; S2["S's profile: ACT (0.73)&amp;lt;br/&amp;gt;keep the calculator"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Same question, opposite decisions, and both are correct for that agent.&lt;/p&gt;

&lt;p&gt;So Whetherman has two parts:&lt;br&gt;
&lt;/p&gt;
&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A[Agent request&amp;lt;br/&amp;gt;messages + tools] --&amp;gt; P{Proxy}
    P --&amp;gt;|no tools, streaming,&amp;lt;br/&amp;gt;forced tool_choice| U[Upstream model]
    P --&amp;gt; B[FunctionGemma-270M&amp;lt;br/&amp;gt;shared backbone + LoRA]
    B --&amp;gt; R[Per-agent profile&amp;lt;br/&amp;gt;logistic layer + τ]
    R --&amp;gt;|ACT or not confident| U
    R --&amp;gt;|ANSWER / ASK / DECLINE| S[Strip tools,&amp;lt;br/&amp;gt;add instruction] --&amp;gt; U&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The &lt;strong&gt;shared backbone&lt;/strong&gt; is FunctionGemma-270M with LoRA and a 4-class head. It's trained on NVIDIA's When2Call, multi-step decision points I generated, and When2Tool tasks labeled on L and S. Where the two agreed, that's the label. Where they disagreed or a label was ambiguous (70 tasks), the target is the average of how often each one failed without tools. It never sees which agent it's routing for. Training took about 50 minutes on my GTX 1660 Ti, in FP32, because that card has no BF16.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;per-agent profile&lt;/strong&gt; is fitted to one agent. &lt;code&gt;whetherman calibrate&lt;/code&gt; runs a probe pack against &lt;em&gt;your&lt;/em&gt; agent: every task 3 times with tools and 3 times without, 5 if the results are mixed. The agent's own results decide the label. If it always succeeds without tools, the label is ANSWER. If it fails without tools but succeeds with them, ACT. Anything in between is ambiguous and gets dropped. Then a small regularized logistic layer and a threshold τ are fitted on that, picking the smallest τ that keeps false skips at or under 5% on a separate split.&lt;/p&gt;

&lt;p&gt;The probe pack ships with real tools (a fixture repo to read and test, a calculator) and simulators for anything with side effects. If an agent &lt;em&gt;says&lt;/em&gt; "email sent" without calling the tool, that's a fail.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Rules I set before the held-out runs
  &lt;ul&gt;
&lt;li&gt;Held-out sets were frozen and run exactly once, after hashing the backbone, the profiles and the code (tag &lt;code&gt;freeze-v3&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;False skips (withholding a tool that was actually needed) are always reported with the denominator and a 95% confidence interval. "0 false skips" means nothing if it's out of 8.&lt;/li&gt;
&lt;li&gt;When a gated episode failed, I replayed it with ACT restored at the skipped step. If that fixed it, that's evidence the skip caused it, though not proof, since replays aren't fully deterministic either.&lt;/li&gt;
&lt;li&gt;A rule picked in advance which probe pack to ship. It rejected my own "bigger probe pack" improvement.&lt;/li&gt;
&lt;li&gt;Two-choice "which city is warmer" tasks can be passed by guessing, so they're reported separately and left out of the headline.&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;This project doesn't work at all without open models, on both sides of the proxy.&lt;/p&gt;

&lt;p&gt;The gate runs before &lt;em&gt;every&lt;/em&gt; model call. If it costs money per call or adds a network hop, it can't save more than it spends. It has to be small, local and free, and FunctionGemma at 270M runs on a GPU from 2019.&lt;/p&gt;

&lt;p&gt;Calibration only works because I can measure the agent. The whole method is running the same model with and without tools until I know what it can do alone. With open models I pinned the exact weights, quantization and serving stack. With a closed API the model can change under you between your probe run and next Tuesday, and your profile silently goes stale.&lt;/p&gt;

&lt;p&gt;And I needed to get inside the router. I trained a LoRA, bolted on a classification head, read its hidden state for the per-agent layer and exported the whole thing to Hugging Face. None of that is possible through a chat endpoint.&lt;/p&gt;

&lt;p&gt;A closed frontier model would probably over-call less to begin with, which would leave a gate like this less to do. But you'd still have no way to calibrate anything against it, and you'd be paying per request to find out.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;
&lt;h3&gt;
  
  
  End to end: no gate vs Whetherman
&lt;/h3&gt;

&lt;p&gt;80 held-out tasks (half of them multi-step) × 2 repeats per agent. Success excludes the 10 guessable episodes. Brackets are Wilson 95% CIs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;No gate&lt;/th&gt;
&lt;th&gt;Whetherman&lt;/th&gt;
&lt;th&gt;Tool calls / episode&lt;/th&gt;
&lt;th&gt;Tokens / episode&lt;/th&gt;
&lt;th&gt;Cost (whole suite)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L: gpt-oss-120b (DigitalOcean)&lt;/td&gt;
&lt;td&gt;120/150 [0.73, 0.86]&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;105/150&lt;/strong&gt; [0.62, 0.77]&lt;/td&gt;
&lt;td&gt;2.36 → 2.15&lt;/td&gt;
&lt;td&gt;1906 → 1897&lt;/td&gt;
&lt;td&gt;$0.0522 → $0.0651&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S: Qwen3.5-4B (local Ollama)&lt;/td&gt;
&lt;td&gt;130/150 [0.80, 0.91]&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;128/150&lt;/strong&gt; [0.79, 0.90]&lt;/td&gt;
&lt;td&gt;1.92 → 1.91&lt;/td&gt;
&lt;td&gt;2297 → 2156&lt;/td&gt;
&lt;td&gt;local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U: gpt-oss-20b (never seen in training)&lt;/td&gt;
&lt;td&gt;115/150 [0.69, 0.83]&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;114/150&lt;/strong&gt; [0.69, 0.82]&lt;/td&gt;
&lt;td&gt;2.09 → 1.93&lt;/td&gt;
&lt;td&gt;2062 → 1824&lt;/td&gt;
&lt;td&gt;$0.0461 → $0.0438&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So: worse on the big agent, neutral on the small one, and on the unseen agent roughly neutral on success with 8% fewer calls, 12% fewer tokens and 5% lower cost. That last row is the closest thing to a win, and it's within noise on success.&lt;/p&gt;

&lt;p&gt;I also ran the other gates through the same suite:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gate (successes of 150)&lt;/th&gt;
&lt;th&gt;On L (120 without a gate)&lt;/th&gt;
&lt;th&gt;On S (130 without a gate)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompted Qwen3.5-4B router&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rules-only control&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared backbone, no profile&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;em&gt;other&lt;/em&gt; agent's profile&lt;/td&gt;
&lt;td&gt;113&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whetherman, own profile&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;128&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The big agent copes with aggressive gating because it can answer most things itself. The small agent does badly under the same gates because it actually needs its tools. Whetherman's profiles picked that up: on S it barely intervenes (34 skips out of 454 steps) and it's safe.&lt;/p&gt;

&lt;p&gt;On L, the prompted Qwen router was actually a better trade than Whetherman. It cut cost 40% for 12 lost tasks. Whetherman lost 15 and cost more. It was also slower: a 4.3 s median per episode against 3.7 s with no gate.&lt;/p&gt;
&lt;h3&gt;
  
  
  Offline calibration results
&lt;/h3&gt;

&lt;p&gt;False skips on held-out When2Tool tasks, labeled by measuring each agent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Shared, no profile&lt;/th&gt;
&lt;th&gt;Own profile&lt;/th&gt;
&lt;th&gt;Other agent's profile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S&lt;/td&gt;
&lt;td&gt;65/70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4/70&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;42/70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L&lt;/td&gt;
&lt;td&gt;13/17&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1/17&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3/17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For S, without a profile the router withheld a needed tool 65 times out of 70. With its own profile it was 4, using the exact same backbone weights.&lt;/p&gt;
&lt;h3&gt;
  
  
  What went wrong on the big agent
&lt;/h3&gt;

&lt;p&gt;It stopped one lookup too early. 11 of the 15 lost tasks have replay evidence of a false skip, and 7 of those are multi-hop retrieval: look something up, then look up the next thing. The gate answered after the first lookup. That makes sense in hindsight: every task in my probe pack is a first-step decision, so L's profile never once saw "one lookup done, another one needed".&lt;/p&gt;

&lt;p&gt;Fewer calls also didn't mean lower cost. With its tools removed, gpt-oss wrote longer answers: completion tokens went from 36k to 58k over the suite, and output costs 7× input. It also called tools that weren't there anymore 20 times. Each time, the proxy had to re-send the original request, so those steps saved nothing and cost an extra call.&lt;/p&gt;

&lt;p&gt;And the calls I was skipping were cheap to begin with. A calculator or a fixture file read costs almost nothing. One wrong skip can fail a whole task.&lt;/p&gt;

&lt;p&gt;This isn't specific to my router. Roughly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;payoff ≈ (cost of a call × calls avoided) − (cost of a failed task × false skips) − (what removing tools does to the model)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With cheap tools, that comes out negative. A gate like this only makes sense when the calls it skips are slow, expensive or risky: paid APIs, rate-limited search, anything with side effects. My benchmark had none of those. That's the next experiment.&lt;/p&gt;

&lt;p&gt;It did work on calculator, code, matrix and prime tasks on L: 18 calls down to 6, with no lost tasks. When the agent really can do it alone, telling it to just answer works.&lt;/p&gt;
&lt;h3&gt;
  
  
  What this shows
&lt;/h3&gt;

&lt;p&gt;There are two results here, and they point in different directions.&lt;/p&gt;

&lt;p&gt;As a product, Whetherman didn't work. It made the big agent worse and the other two no better, and on L a prompted 4B router was the better trade.&lt;/p&gt;

&lt;p&gt;The research result held up better. A single "does this need a tool?" classifier that ignores which agent it's serving was badly mismatched to agents with different abilities. The same backbone with no profile withheld a needed tool from S 65 times out of 70. With S's own measured profile it was 4. Most of that drop comes from S's own threshold: the backbone with only S's τ got 3/70. On L the learned profile layer added more, 86 correct skips of 129 against 53 for the threshold alone. Giving S the big agent's profile cost it 32 tasks end to end. Tool necessity behaved like a property of the agent and the task together, not the task alone.&lt;/p&gt;

&lt;p&gt;That's three agents on one family of benchmark tasks, so it's something to test on more agents and more expensive tools, not a general law. But if you're building a tool router, I'd measure your own agent before trusting a universal label.&lt;/p&gt;
&lt;h3&gt;
  
  
  More detail
&lt;/h3&gt;

&lt;p&gt;&lt;/p&gt;
  Benchmarks, self-run
  &lt;p&gt;I also ran the backbone (no profile, agent-agnostic) through official evaluation code. Self-reported, not leaderboard entries.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;Whetherman&lt;/th&gt;
&lt;th&gt;Prompted Qwen3.5-4B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When2Call official MCQ test (3,652 items)&lt;/td&gt;
&lt;td&gt;accuracy 0.628, macro-F1 0.472*, tool hallucination rate 0.070&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL v4 irrelevance (no function fits)&lt;/td&gt;
&lt;td&gt;187/240 (0.78)&lt;/td&gt;
&lt;td&gt;95/240 (0.40)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL v4 live irrelevance&lt;/td&gt;
&lt;td&gt;764/884 (0.86)&lt;/td&gt;
&lt;td&gt;533/884 (0.60)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL v4 live relevance (should call)&lt;/td&gt;
&lt;td&gt;6/16 (0.38)&lt;/td&gt;
&lt;td&gt;10/16 (0.62)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*Over the three classes When2Call actually labels, it's 0.629. The router guessed "direct answer" 6 times, and that class never appears in the gold labels, so it adds a zero to the official average.&lt;/p&gt;

&lt;p&gt;BFCL lines up with everything else: Whetherman is good at "don't call anything here" and too willing to skip when a call really is needed. And a warning about my own numbers: validation macro-F1 was 0.925, but on a balanced 900-item sample of the When2Call test (same three classes) it's 0.642, so validation overstated it.&lt;/p&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Laya vs FunctionGemma
  &lt;p&gt;I also tested &lt;a href="https://huggingface.co/convaiinnovations/laya" rel="noopener noreferrer"&gt;Laya&lt;/a&gt;, an encoder model built for typed decisions, as a second router. Same data, same splits, same calibration, offline only. FunctionGemma won on almost everything: macro-F1 on the same 900 When2Call test items was 0.642 vs 0.497, and on L, Laya couldn't find any safe threshold at all, so it never skipped. Laya was faster on short inputs on the GPU (36 ms vs 55 ms) but twice as slow on long ones (510 ms vs 249 ms), and it has to truncate anything over 512 tokens.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  What running it costs
  &lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GTX 1660 Ti p50&lt;/th&gt;
&lt;th&gt;CPU p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Short request, 2 tools&lt;/td&gt;
&lt;td&gt;55 ms&lt;/td&gt;
&lt;td&gt;188 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 tools&lt;/td&gt;
&lt;td&gt;122 ms&lt;/td&gt;
&lt;td&gt;1075 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long history (1.5k tokens)&lt;/td&gt;
&lt;td&gt;249 ms&lt;/td&gt;
&lt;td&gt;2152 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a GPU it's cheap. On CPU, with 8 tools, it adds about a second to every model call, which eats most of what it could save.&lt;/p&gt;

&lt;p&gt;Calibration: $0.24 and 83 minutes of agent time for L, $0.35 for U, 19 minutes locally for S, about 120 usable labels each. At U's measured savings it pays for itself after roughly 24,000 episodes. L's never does.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma&lt;/strong&gt;: FunctionGemma-270M is the router, so every decision Whetherman makes comes from it. I fine-tuned it on my 6 GB GTX 1660 Ti: LoRA rank 16 on every attention and MLP projection, plus a 4-class head on the last token's hidden state, trained in FP32 because Turing cards have no BF16. Training took about 50 minutes. The per-agent profiles sit on that same hidden state as a small logistic layer each, so one 270M model serves every agent and only the profile changes between them. On a balanced 900-item sample of the When2Call test it goes from macro-F1 0.519 untuned to 0.642, ahead of a prompted Qwen3.5-4B router (0.281) and Laya (0.497). It runs at 55 ms per decision in about 1.2 GB of VRAM on the same card. The adapter, the head and all three profiles are on Hugging Face with a &lt;a href="https://huggingface.co/vinmaynair/whetherman-functiongemma-270m" rel="noopener noreferrer"&gt;model card&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean&lt;/strong&gt;: DigitalOcean's serverless inference ran both hosted agents, so the agent side of every measurement happened there. Agent L is gpt-oss-120b and the unseen agent U is gpt-oss-20b, both called through the OpenAI-compatible endpoint with six parallel workers. That covered labeling L (every probe task 3 to 5 times with tools and 3 to 5 times without), calibrating U from scratch as a brand-new agent, and the dev and frozen held-out A/B runs: 20 logged jobs covering 952 tasks. The client retries rate limits and 5xx errors with jittered backoff, prices every response from its token usage at DigitalOcean's published rates and appends it to &lt;a href="https://github.com/vinmay/whetherman/blob/main/eval/results/spend.json" rel="noopener noreferrer"&gt;&lt;code&gt;eval/results/spend.json&lt;/code&gt;&lt;/a&gt;, and a hard spend cap stops any job before it goes over. Every cost number in this post comes from that log.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Three agents, measured on one serving stack each, on one day, at temperature 0. A different quantization or provider is a different agent. Recalibrate.&lt;/li&gt;
&lt;li&gt;Small denominators. About 120 usable probe labels per agent, and only tens of truly-needed calls in some held-out sets. The intervals are wide and they're in every table.&lt;/li&gt;
&lt;li&gt;The proxy handles non-streaming OpenAI-compatible chat requests with &lt;code&gt;tool_choice&lt;/code&gt; auto. Everything else is passed through untouched.&lt;/li&gt;
&lt;li&gt;It's not a security guardrail. It decides whether a call is &lt;em&gt;useful&lt;/em&gt;, not whether it's &lt;em&gt;allowed&lt;/em&gt;, and it fails open on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Multi-step probes, so the profile learns later-step decisions, especially "one lookup isn't enough".&lt;/li&gt;
&lt;li&gt;Test with tools that are actually expensive: paid APIs and slow web search. That's where the payoff math could flip.&lt;/li&gt;
&lt;li&gt;Factor the output-token shift into the skip decision, not just call counts.&lt;/li&gt;
&lt;li&gt;Detect per model when it ignores removed tools, and back off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've tried something like this, or you think it's the wrong approach, I'd like to hear about it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks to NVIDIA for When2Call, the When2Tool authors, Google for FunctionGemma, convaiinnovations for Laya, and the Berkeley Gorilla team for BFCL. Code is Apache-2.0; the adapter follows the &lt;a href="https://ai.google.dev/gemma/terms" rel="noopener noreferrer"&gt;Gemma Terms of Use&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>ClearToList: Is that thrift find recalled? A photo check against CPSC recalls with Gemma 4</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Sat, 17 Oct 2026 16:35:22 +0000</pubDate>
      <link>https://dev.to/vinmay/cleartolist-is-that-thrift-find-recalled-a-photo-check-against-cpsc-recalls-with-gemma-4-20ik</link>
      <guid>https://dev.to/vinmay/cleartolist-is-that-thrift-find-recalled-a-photo-check-against-cpsc-recalls-with-gemma-4-20ik</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A $5 bassinet at a thrift store can be a recalled product that's illegal to resell, and nothing on it tells you.&lt;/p&gt;

&lt;p&gt;That's the problem I am trying to solve. Selling a recalled product is illegal in the U.S. even secondhand and even if you're one person flipping stuff on Facebook Marketplace on weekends. A lot of what gets recalled is exactly the stuff that piles up at thrift stores like cribs, bassinets, sleepers, high chairs, strollers, toys.&lt;/p&gt;

&lt;p&gt;I pulled every CPSC recall since 2015 to see how bad it is. About 1 in 5 is a kids’ product, and &lt;strong&gt;more than half of those (55%) don’t list a model number or UPC at all&lt;/strong&gt; in their product data. The only way to know is what the thing looks like.&lt;/p&gt;

&lt;p&gt;Every recall tool I found wants text. A brand, a model number, a SKU from your store catalog. A reseller's haul has none of that. No box, no SKU, half the time you don't even know the brand.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;ClearToList&lt;/strong&gt;. You take a photo of the item, it finds the closest recalls. Then you take a photo of the label and it settles it by model number or UPC. You get one of three answers, shown as a price-dot sticker on your own photo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟥 &lt;strong&gt;Recalled&lt;/strong&gt;: a model number or UPC on the item matches a recall. Don't list it.&lt;/li&gt;
&lt;li&gt;🟨 &lt;strong&gt;Check the label&lt;/strong&gt;: it looks like a recalled product. That alone means nothing, so it asks for a label photo.&lt;/li&gt;
&lt;li&gt;⬜ &lt;strong&gt;No match found&lt;/strong&gt;: nothing matched. This never means "safe". There's no green sticker on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only Recalled is an alarm. My first version also alerted when something just &lt;em&gt;looked&lt;/em&gt; like a recalled product. Turns out a lot of normal white cribs look like recalled white cribs and that evaluation talked me out of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buyers get it too.&lt;/strong&gt; Looking at a Marketplace listing? Screenshot it and check before you pay. If it looks like a recalled product, the page gives you a message to send the seller asking for a label photo. Resellers can check a whole haul at once (up to 10 photos) and get one report as CSV or PDF.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who pays for this
&lt;/h3&gt;

&lt;p&gt;Nobody yet, and nothing is charged today. Here's what I'd test first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; buyers and casual checks, a few photo checks a day. Parents shouldn't pay to find out if a crib is recalled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reseller, $9/month:&lt;/strong&gt; unlimited photo checks, bigger hauls, alerts when something you already checked gets recalled later, and a dated "checked" record for every item you list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store, $29/month per location:&lt;/strong&gt; bulk intake scanning for consignment and kids' resale shops, several staff logins and a daily pull list. They take in used baby gear from strangers every day and carry the most risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketplace licensing:&lt;/strong&gt; the long shot. A check built into a resale app's listing flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A scan on open models costs a fraction of a cent, so even the $9 tier works with very few customers. These prices are my guess, not tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_b715-KeWs0" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;br&gt;
Real results from the live site, I just cut the waiting. Demo photos: Wikimedia Commons (ajay_suresh, Sally V, Hardyplants, קרלוס הגדול), CC BY 2.0 / CC BY-SA / CC0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it yourself: &lt;a href="https://cleartolist.onrender.com" rel="noopener noreferrer"&gt;cleartolist.onrender.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No sign-up. Three sample photos run real scans, and everyone gets free photo checks every day. Want to use it regularly? Drop your email for an invite code and I'll send one within a day. Model number, UPC and brand lookup is always open.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpbn8xudr2iokd3jc934.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpbn8xudr2iokd3jc934.png" alt="The ClearToList home page on a phone" width="780" height="1688"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Take a photo, pick one from your library, or try a sample. The label is step 2.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc5kk5cioj6eom0n5qdwz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc5kk5cioj6eom0n5qdwz.png" alt="Recalled: a red Recalled sticker on the item photo, with the recall details" width="716" height="3100"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The label's model number matches a recall. Red sticker, what to do whether you're selling or buying, and the recall itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvvftfgeqn428hk5s4ut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvvftfgeqn428hk5s4ut.png" alt="Check the label: a yellow sticker and the three closest recalls" width="716" height="3364"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Looking similar is never an alarm. Yellow sticker, the closest recalls to compare, and a button to add the label. (Play yard photo: Famartin, CC BY-SA 4.0, via Wikimedia Commons.)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinmay" rel="noopener noreferrer"&gt;
        vinmay
      &lt;/a&gt; / &lt;a href="https://github.com/vinmay/cleartolist" rel="noopener noreferrer"&gt;
        cleartolist
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;
&lt;a rel="noopener noreferrer" href="https://github.com/vinmay/cleartolist/demo/static/brand/mark.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvinmay%2Fcleartolist%2FHEAD%2Fdemo%2Fstatic%2Fbrand%2Fmark.svg" alt="" width="32" height="32"&gt;&lt;/a&gt; ClearToList&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Check secondhand products against U.S. product recalls before you list them, children's products first.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Resellers buy unboxed, unbranded secondhand items with no SKU, so catalog-based recall tools can't help them. And selling a recalled consumer product is illegal no matter how small the seller. ClearToList works in two photos: a photo of the item finds the closest CPSC recalls, and a photo of its label settles it by model number or UPC. You get one of three verdicts:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🟥 &lt;strong&gt;CONFIRMED&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;A model number or UPC read off the item matches a recalled product. Don't list it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🟧 &lt;strong&gt;POSSIBLE MATCH&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;The item looks similar to the recalls shown side by side with your photo. That alone doesn't mean it's recalled: photograph the label and scan it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⬜ &lt;strong&gt;NO MATCH FOUND&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;No match in CPSC recalls. That does &lt;strong&gt;not&lt;/strong&gt; mean the item is safe.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Only CONFIRMED is an alarm…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinmay/cleartolist" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Data
&lt;/h3&gt;

&lt;p&gt;All CPSC recalls since 2015 come from the CPSC Recalls API: about 3,400 in scope and around 12.7k product photos. The ingest only pulls what changed, backs off politely and falls back to a cache if the API is down. A GitHub Actions job refreshes it every night and redeploys the site's data. Car seats are out because NHTSA handles those, not CPSC.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scan
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    P[Item photo] --&amp;gt; A[SigLIP 2&amp;lt;br/&amp;gt;image search over&amp;lt;br/&amp;gt;~12.7k recall photos]
    P --&amp;gt; G[Gemma 4 reads the photo&amp;lt;br/&amp;gt;JSON: item type, brand,&amp;lt;br/&amp;gt;text, model no., UPC]
    G --&amp;gt; E[Exact match&amp;lt;br/&amp;gt;model no. / UPC]
    G --&amp;gt; T[EmbeddingGemma&amp;lt;br/&amp;gt;text search]
    A --&amp;gt; F{Weighted rank fusion}
    T --&amp;gt; F
    E --&amp;gt;|boost to top| F
    F --&amp;gt; S[Top 3 shortlist]
    S --&amp;gt; V{Rule}
    V --&amp;gt;|code matches| C[Recalled]
    V --&amp;gt;|closest photo very similar| M[Check the label&amp;lt;br/&amp;gt;show shortlist,&amp;lt;br/&amp;gt;ask for label photo]
    V --&amp;gt;|otherwise| N[No match found]
    L[Label photo] --&amp;gt; G2[Gemma 4 reads&amp;lt;br/&amp;gt;model no. / UPC] --&amp;gt; E2[Exact match] --&amp;gt; C&lt;/code&gt;&lt;/pre&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 E4B reads the photo&lt;/strong&gt; and returns a JSON: item type, brand, any visible text, model number, UPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search.&lt;/strong&gt; SigLIP 2 searches the recall photos, model numbers and UPCs are matched exactly, brands are matched, and EmbeddingGemma searches the recall text. Weighted reciprocal rank fusion combines it all. For a normal product photo the image leads. Text only leads when the photo is basically a label and Gemma actually read words off it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dumb rule decides, not a model.&lt;/strong&gt; Code match means Recalled, unless the brand on the item contradicts the recall. Very similar closest photo means Check the label. Otherwise, No match found. I wanted the answer to come from something I can read in ten lines and not from a model non-deterministically.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Picking the reader
&lt;/h3&gt;

&lt;p&gt;I tried Gemma 4 E2B and E4B on 30 held-out photos. First prompt was bad because Gemma kept saying "baby product" instead of "bassinet". Asking for a specific item type got both to 15/30, and E4B was faster (3.6 s vs 6.2 s median), so E4B it is. A full scan is about 7s.&lt;/p&gt;

&lt;p&gt;One gotcha is SigLIP on the GPU left too little room for Gemma and EmbeddingGemma to stay loaded, so Ollama swapped them on every single scan and added 25s. So, I had to move SigLIP to the CPU and that solved the problem and it's still well under a second there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The site and not getting wrecked by a public upload form
&lt;/h3&gt;

&lt;p&gt;The site runs on Render's free tier and runs no models itself. Photo checks go into a small in-memory queue, and a separate GPU worker running the open models connects &lt;em&gt;out&lt;/em&gt; to the site over signed HTTPS to pick up jobs. It accepts no incoming connections. It sends back only recall ids and matched codes, and Render re-checks any code against its own copy of the data before it ever shows Recalled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Uploads are checked by their actual bytes, capped at 8 MB and ~30 megapixels, and re-encoded, which strips GPS and anything hidden in the file.&lt;/li&gt;
&lt;li&gt;Photos live in memory only and get deleted right after the check.&lt;/li&gt;
&lt;li&gt;Per-person rate limits, a daily cap on free checks, a small queue that says no when it's full, and a kill switch for uploads.&lt;/li&gt;
&lt;li&gt;Worker requests are HMAC-signed with a timestamp and nonce.&lt;/li&gt;
&lt;li&gt;Anything Gemma reads off a label is treated as untrusted. It can't set the verdict, and the page sits behind a strict Content Security Policy.&lt;/li&gt;
&lt;li&gt;No cookies, no IPs, no tracking. Just anonymous daily counts and a "Was this helpful?" button.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Evaluation
&lt;/h3&gt;

&lt;p&gt;Every test query is a held-out recall photo roughed up to look like a phone shot (crop, rotation, dim light, blur), and that photo &lt;strong&gt;plus every near-duplicate of it&lt;/strong&gt; is hidden from the index. The only way to score is to find &lt;em&gt;other&lt;/em&gt; photos of the same recall. For negatives I used licensed Wikimedia photos of normal, non-recalled products in the same categories. Rules were picked on a dev split and the test ran once.&lt;/p&gt;

&lt;p&gt;There are three different numbers here and they're easy to mix up, so here's what each one means for someone holding the item:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From one item photo&lt;/th&gt;
&lt;th&gt;Kids' products&lt;/th&gt;
&lt;th&gt;Other categories&lt;/th&gt;
&lt;th&gt;Label close-ups&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flagged "Check the label" with the right recall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right recall shown at all (incl. grey "closest recalls to check")&lt;/td&gt;
&lt;td&gt;63%&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;td&gt;57%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right recall somewhere in the internal top 3 (not shown to you)&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recalled verdicts (right / total)&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;2 / 3&lt;/td&gt;
&lt;td&gt;2 / 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False Recalled alarms on normal lookalikes&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top row is the one that matters: &lt;strong&gt;from a photo alone, ClearToList points you at the right recall about a third of the time.&lt;/strong&gt; The 67% in the third row only says the search is good enough to build on. I hide most of that shortlist on purpose, because showing it every time flagged almost every normal product.&lt;/p&gt;

&lt;p&gt;The grey suggestions in row two raise it to 63% for kids' products, but 98% of normal lookalikes get suggestions too. That's why they're grey and never called a match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The label photo is what makes it reliable.&lt;/strong&gt; Recalled needs a model number or UPC off the item. Of the five Recalled verdicts in the test, four were correct. None of the 124 tested lookalikes triggered a Recalled verdict. These are encouraging early results, but the sample is too small to establish real-world reliability. Cribs and sleepers only get 45% top 3 from the image, because one white crib looks a lot like another. On label close-ups, Gemma reading the label finds the recall where image search can't (60% vs 18%).&lt;/p&gt;

&lt;p&gt;I also set a kill criterion before building the app: about 60% internal top-3 on kids' products, or I drop the idea. The spike hit 71% and the test landed at 67-69%. That number answers "is the search good enough to build on", not "what does a user see". The user number is the 34%.&lt;/p&gt;

&lt;p&gt;It took three runs to get these numbers. Run 1 had a Gemma "verifier" that was right about half the time and raised alarms on 31% of normal kids' items, so I cut it. The full run history is in the &lt;strong&gt;&lt;a href="https://github.com/vinmay/cleartolist/blob/main/eval/README.md" rel="noopener noreferrer"&gt;Full evaluation&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The math only works if a scan is basically free.&lt;/strong&gt; A reseller might check hundreds of items a week. On open models a scan costs a fraction of one API call, and there's no per-call bill to scale with usage. That's the difference between "free for parents" being a real promise or a trial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos of people’s stuff stay private.&lt;/strong&gt; People photograph things in their homes, sometimes with their kids' stuff around. Those photos go to my worker, get checked and get deleted. They never get sent to a third-party model API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could actually tune it.&lt;/strong&gt; Forcing strict JSON out of Gemma, swapping E2B for E4B, moving SigLIP to the CPU, picking thresholds on a dev split: all of that is versioned in the repo, so nothing shifts under the evaluation because a vendor updated a model. And the whole pipeline can be fine-tuned on recall photos later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;It went live this weekend, so I don't have a real user story yet. I'll update this section with actual numbers and whatever people tell me.&lt;/p&gt;

&lt;p&gt;If you flip on eBay, Poshmark or Marketplace or you're buying used baby gear, run one item through it and tell me in the comments what it got right or wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma&lt;/strong&gt;: Gemma 4 reads the item and its label, which is the core of the whole thing. EmbeddingGemma powers the text search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt;: the hosted site and job queue, deployed from &lt;code&gt;render.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Copilot (GitHub Actions)&lt;/strong&gt;: Actions is what keeps ClearToList current without me touching it. A scheduled workflow runs every night at 09:17 UTC: it pulls only the recalls CPSC added or changed since the last run, rebuilds the site’s SQLite database (full-text search plus exact model-number and UPC tables), publishes it as a data-latest release asset and calls Render’s deploy hook, so the live site picks up a new recall the next morning. A second workflow runs ruff, the full pytest suite and a gitleaks scan over the whole git history on every push. During launch weekend a third one pings the site every 10 minutes so the free instance doesn’t fall asleep on a visitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Per-category thresholds to cut down noise outside kids' products (sports gear and kitchen stuff are the worst)&lt;/li&gt;
&lt;li&gt;More recall sources: EU Safety Gate (it has photos) and Health Canada&lt;/li&gt;
&lt;li&gt;Real thrift store photos in the eval&lt;/li&gt;
&lt;li&gt;More scanning capacity by running the same worker on rented GPUs&lt;/li&gt;
&lt;li&gt;Alerts that re-check your past items when a new recall drops&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This is a screening aid only, not legal advice. Recall data is from the U.S. Consumer Product Safety Commission. Not affiliated with or endorsed by CPSC.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Doppel: find two different things that look similar, outside</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:07:28 +0000</pubDate>
      <link>https://dev.to/vinmay/doppel-find-two-different-things-that-look-similar-outside-2o77</link>
      <guid>https://dev.to/vinmay/doppel-find-two-different-things-that-look-similar-outside-2o77</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Doppel&lt;/strong&gt; is a walking game about lookalikes. You take a photo of anything outside then walk about 50 meters, then photograph another one which could be its twin: a &lt;em&gt;different kind&lt;/em&gt; of thing that looks the same (a pinecone and an artichoke, or moss and broccoli). &lt;strong&gt;EmbeddingGemma 2&lt;/strong&gt; runs on your phone which judges how alike the two photos look and shows its reasons.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7mlqsx3vu2911ipl4wd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7mlqsx3vu2911ipl4wd.gif" alt="Dopple Reveal" width="300" height="750"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's for anyone on a walk who wants a reason to look at things more closely around them. You can do three pairs a day for 10 to 15 minutes then it tells you to stop. You can't lose and you have fun looking around during your walk and your photos never leave the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://doppel-ws59.onrender.com" rel="noopener noreferrer"&gt;https://doppel-ws59.onrender.com&lt;/a&gt; (Chrome on Android or desktop; needs WebGPU; downloads a 388 MB model once, then works offline)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No time to go outside?&lt;/strong&gt; &lt;a href="https://doppel-ws59.onrender.com/#/demo" rel="noopener noreferrer"&gt;Try a demo round&lt;/a&gt;: one full round with bundled CC0 photos and the real model, no camera needed, nothing saved. On a desktop it's the first button you see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video:&lt;/strong&gt;
  &lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/_MHpp2ZZ4nM" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;

&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp1e6pwlstt0dtnepbelw.png" alt="The first screen, with a practice reveal playing while the model downloads" width="800" height="1731"&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8b72o5sik4muua2tt0v2.png" alt="Right after the first photo: the real photo, what Doppel sees, what to look for, and three twins to try" width="800" height="1731"&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv6o27gf64pecc3la5l2c.png" alt="A reveal: the two real photos in Wada-colored mats, the score, and what they share" width="800" height="1731"&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyqjy7ot60ozpoh8mrb06.png" alt="The demo round on a desktop: pinecone and artichoke" width="800" height="500"&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/vinmay" rel="noopener noreferrer"&gt;
        vinmay
      &lt;/a&gt; / &lt;a href="https://github.com/vinmay/doppel" rel="noopener noreferrer"&gt;
        doppel
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Find lookalikes in the real world using on-device image embeddings.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;Doppel started as a fork of Sense Hunt and Warmr (same author, same challenge window).&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Doppel&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Find two different things that look the same. Outside.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Photograph anything outdoors. Walk about 50 metres. Then find its twin: a &lt;em&gt;different kind&lt;/em&gt; of thing that looks the same, like a pinecone and an artichoke, moss and broccoli, or bark and elephant skin. &lt;strong&gt;EmbeddingGemma 2 runs on your phone&lt;/strong&gt; and judges how alike the two photos look, and it shows its reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Doppel sees:&lt;/strong&gt; what it thinks each photo is ("pinecone ↔ artichoke"), so you know it understood you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A twin score&lt;/strong&gt; from 0 to 100, in four bands: Uncanny, Cousins, Distant, Strangers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What they share:&lt;/strong&gt; "Both: ridged · layered · wrinkled", the visual qualities both photos have.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Three pairs a day, about 10 to 15 minutes, then "Done for today". You can't lose. Photos never leave the phone, and after the first load…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/vinmay/doppel" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How I got here
&lt;/h3&gt;

&lt;p&gt;Doppel was my third try at this challenge.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sense Hunt.&lt;/strong&gt; A daily list of prompts ("a tree with peeling bark"); you photograph one and the model checks it. It worked but it was getting too hard as the prompt generator needed to have a controlled list of words and something achievable for the player.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warmr.&lt;/strong&gt; Hot/cold for the real world: find a hidden daily secret using photos. It depended too much on where you lived. If your street didn’t have the secret, you couldn’t win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doppel.&lt;/strong&gt; I was then looking for a judgement that could be done similarly by a player as well as the model. We are very good at "these two look alike" games and so is image embedding. Doppel is that judgment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;p&gt;EmbeddingGemma 2 came out the day after the challenge opened. It can embed both photos and text into the same space and it's small enough to run in the browser on WebGPU and Doppel leans on it for everything.&lt;/p&gt;

&lt;p&gt;The score is just how close your two photos are scaled to 0-100 which is calibrated on 23 of my own outdoor photos.&lt;/p&gt;

&lt;p&gt;The rest comes from comparing photos to text. At build time I embedded a list of about 200 everyday things ("a tire", "moss") with a few things you shouldn't photograph (screens, rooms, people), and 45 visual words like "round" or "ridged". The closest thing is what "Doppel sees". A visual word shows up under "What they share" when it fits both photos noticeably better than usual. The same lists suggest what to look for on your walk ("Try: seed pod · corn on the cob · seashell") and catch you photographing the same kind of thing twice.&lt;/p&gt;

&lt;p&gt;A pair of very different things that still look alike gets a small bonus, so a pinecone and an artichoke beat a cloud and fog. The "Need an idea?" hints were written by Gemma 4 E4B, running locally through Ollama.&lt;/p&gt;

&lt;p&gt;Nothing leaves the device. Your photos and scores live in IndexedDB, and a service worker keeps the app and the model available offline. The colors come from Sanzo Wada's &lt;em&gt;A Dictionary of Color Combinations&lt;/em&gt; (1933).&lt;/p&gt;

&lt;h3&gt;
  
  
  On my walk
&lt;/h3&gt;

&lt;p&gt;I played a full day in my neighborhood with three pairs and these are the results Doppel showed me -&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tire ↔ manhole cover: 49, Distant.&lt;/strong&gt; Both round, ridged, gray. Fair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power lines ↔ street lamp: 37, Distant.&lt;/strong&gt; Doppel's verdict: "If you squint. Hard."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manhole cover ↔ sidewalk: 83, Uncanny.&lt;/strong&gt; The one that surprised me: up close, the cover and the speckled concrete really do read as the same texture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One observation I had was on my Pixel 8 Pro each photo takes 15-20 seconds to read which is much slower than the 1.7 s on a laptop. It turned out fine because that's time spent looking around instead of at the screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work?
&lt;/h3&gt;

&lt;p&gt;These numbers come from the real model running in a browser.&lt;/p&gt;

&lt;p&gt;I tested on 44 CC0 photos with the real model in a headless browser on a laptop iGPU. (&lt;a href="https://github.com/vinmay/doppel/blob/main/docs/eval.md" rel="noopener noreferrer"&gt;docs/eval.md&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Twins vs strangers, mean score&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;58.6 vs 19.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twins scoring Cousins or better&lt;/td&gt;
&lt;td&gt;6 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strangers scoring Distant or worse&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same-kind pairs refused&lt;/td&gt;
&lt;td&gt;3 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screens, rooms, documents, blurry photos refused&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo embed time (laptop iGPU)&lt;/td&gt;
&lt;td&gt;median 1.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first run passed five of the six original criteria and only 5 of 8 twins reached Cousins (moss and broccoli sat at exactly 50).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It works on a walk with no signal.&lt;/strong&gt; The model is open-weight and small enough (388 MB) to run in the browser on WebGPU, so after one download Doppel works in airplane mode, in a park, on a trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your photos never leave your phone.&lt;/strong&gt; A game that asks you to photograph your street all day shouldn't upload your street. Here there's nothing to upload to: no server, no account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run.&lt;/strong&gt; No API bill per photo means no ads, no subscription, no limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model is swappable.&lt;/strong&gt; The game only needs "embed a photo" and "embed a text". The calibration script re-derives every number from photos, so a better open model is a re-run, not a rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; EmbeddingGemma 2 (released Oct 6) is the core of the game: every photo is embedded on-device in the browser (LiteRT-LM, WebGPU), and the score, the labels, "What they share" and the twin suggestions all come from it. Gemma 4 E4B, run locally through Ollama, wrote the idea hints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; Doppel is deployed on Render as a static site (&lt;code&gt;render.yaml&lt;/code&gt; in the repo). Render serves the app and the LiteRT-LM WebGPU runtime that each phone downloads to run EmbeddingGemma 2 locally.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>webgpu</category>
    </item>
    <item>
      <title>Your `pip install` Just Stole Your SSH Keys: The LiteLLM Supply Chain Attack Explained</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Tue, 24 Mar 2026 20:19:13 +0000</pubDate>
      <link>https://dev.to/vinmay/your-pip-install-just-stole-your-ssh-keys-the-litellm-supply-chain-attack-explained-4l52</link>
      <guid>https://dev.to/vinmay/your-pip-install-just-stole-your-ssh-keys-the-litellm-supply-chain-attack-explained-4l52</guid>
      <description>&lt;p&gt;A single &lt;code&gt;pip install litellm==1.82.8&lt;/code&gt; was enough to drain everything off your machine. No suspicious imports. No weird prompts. Just a package install, and your AWS credentials, SSH keys, and API keys were already heading to an attacker's server.&lt;/p&gt;

&lt;p&gt;Here's what happened, why it's scary, and what you can actually do about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happened
&lt;/h2&gt;

&lt;p&gt;On March 24, 2026, LiteLLM version 1.82.8 landed on PyPI with a malicious file bundled inside: &lt;code&gt;litellm_init.pth&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;.pth&lt;/code&gt; extension is why this attack is so nasty.&lt;/p&gt;

&lt;p&gt;Python automatically runs &lt;code&gt;.pth&lt;/code&gt; files in your &lt;code&gt;site-packages&lt;/code&gt; directory &lt;strong&gt;every time the Python interpreter starts&lt;/strong&gt;. No import needed, no user interaction. The attacker hid a double base64-encoded payload inside this file. The moment Python ran, the payload ran too.&lt;/p&gt;

&lt;p&gt;What did it grab? Pretty much everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All your environment variables (&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;, all of it)&lt;/li&gt;
&lt;li&gt;SSH private keys (&lt;code&gt;~/.ssh/id_rsa&lt;/code&gt;, &lt;code&gt;id_ed25519&lt;/code&gt;, and more)&lt;/li&gt;
&lt;li&gt;AWS, GCP, Azure, and Kubernetes credentials&lt;/li&gt;
&lt;li&gt;Git credentials and &lt;code&gt;.gitconfig&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Shell history (&lt;code&gt;~/.bash_history&lt;/code&gt;, &lt;code&gt;~/.zsh_history&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Docker configs, npm tokens, database passwords&lt;/li&gt;
&lt;li&gt;Crypto wallet files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it encrypted everything with AES-256, wrapped the key with a hardcoded RSA-4096 public key, and shipped it all off to &lt;code&gt;https://models.litellm.cloud/&lt;/code&gt;. Note that domain: &lt;code&gt;litellm.cloud&lt;/code&gt;, not the real &lt;code&gt;litellm.ai&lt;/code&gt;. Classic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Scale Is Scary
&lt;/h2&gt;

&lt;p&gt;LiteLLM gets &lt;strong&gt;97 million downloads per month&lt;/strong&gt;. That alone is a huge problem.&lt;/p&gt;

&lt;p&gt;But supply chain attacks don't stop at the direct install. They travel through dependency trees. If you installed &lt;code&gt;dspy&lt;/code&gt;, &lt;code&gt;langchain&lt;/code&gt;, or any of the other popular AI packages that depend on &lt;code&gt;litellm&amp;gt;=1.64.0&lt;/code&gt;, you were also exposed without ever typing &lt;code&gt;pip install litellm&lt;/code&gt; yourself.&lt;/p&gt;

&lt;p&gt;The attack was only live for about an hour. It got discovered almost by accident: a developer's machine ran out of RAM and crashed because the payload was executing inside Cursor through an MCP plugin that pulled in litellm as a transitive dependency. A bug in the attacker's own code gave them away.&lt;/p&gt;

&lt;p&gt;If that bug wasn't there, this could have run quietly for days or weeks across thousands of CI/CD pipelines, dev machines, and prod servers. Nobody would have noticed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem: You Can't See What's Inside Your Dependencies
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;pip install something&lt;/code&gt;, you're not just installing one thing. You're pulling in a whole tree of packages, and any one of them could be compromised.&lt;/p&gt;

&lt;p&gt;This isn't new, but it's getting worse as the AI package ecosystem keeps exploding. New packages, new versions, new dependencies dropping every single day. The attack surface is growing way faster than anyone can audit it.&lt;/p&gt;

&lt;p&gt;We're taught to think of dependencies as a good thing. Reusable building blocks, standing on the shoulders of giants, all that. The LiteLLM incident is a reminder that every dependency is also a trust decision, and most of us are making those decisions without really thinking about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Should Actually Do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you installed litellm 1.82.8:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check for &lt;code&gt;litellm_init.pth&lt;/code&gt; in your &lt;code&gt;site-packages/&lt;/code&gt; directory&lt;/li&gt;
&lt;li&gt;Rotate everything: every API key, SSH key, and cloud credential that was on that machine&lt;/li&gt;
&lt;li&gt;Check any CI/CD environment where litellm might have been installed too&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Going forward:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin your dependencies.&lt;/strong&gt; Exact version locks (&lt;code&gt;==&lt;/code&gt;) in production instead of &lt;code&gt;&amp;gt;=&lt;/code&gt;. It won't stop a poisoned release from getting in if you're on that version, but it stops silent upgrades pulling in something bad later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use lockfiles.&lt;/strong&gt; &lt;code&gt;pip-compile&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, &lt;code&gt;uv.lock&lt;/code&gt;, whatever fits your setup. Know exactly what you're running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit transitive dependencies.&lt;/strong&gt; &lt;code&gt;pip-audit&lt;/code&gt; and &lt;code&gt;safety&lt;/code&gt; scan your full dependency tree for known issues. Worth running in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't pip install as root.&lt;/strong&gt; Limits how much damage a compromise can actually do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an eye out for &lt;code&gt;.pth&lt;/code&gt; files.&lt;/strong&gt; They're a legit Python feature, but they're also a perfect delivery mechanism for malware. If you see one in &lt;code&gt;site-packages&lt;/code&gt; from a package you don't recognise, that's worth investigating.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Can We Do Better Than Grep?
&lt;/h2&gt;

&lt;p&gt;Most of the advice above is reactive. It helps you recover or reduce damage after something gets in. What's actually hard is knowing &lt;em&gt;before&lt;/em&gt; you run anything that a package can even reach your credentials.&lt;/p&gt;

&lt;p&gt;This is the gap I've been trying to close with something I'm building called &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;ReachScan&lt;/a&gt;. The idea is pretty simple: instead of just matching against a list of known bad packages, it maps what a codebase or its dependencies can actually &lt;em&gt;reach&lt;/em&gt;: filesystem paths, environment variables, system resources. If a package has no business touching &lt;code&gt;~/.ssh/&lt;/code&gt;, you should know that before it runs, not after.&lt;/p&gt;

&lt;p&gt;It won't catch everything. But knowing the capability surface of what you're about to install is a lot better than just hoping nothing in the tree is malicious.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Uncomfortable Truth
&lt;/h2&gt;

&lt;p&gt;Karpathy put it well after this incident: the way we think about dependencies needs to change. The whole "building pyramids from bricks" model assumes the bricks are trustworthy. In 2026, that's a harder assumption to stand behind.&lt;/p&gt;

&lt;p&gt;That doesn't mean stop using dependencies. That's not realistic. It just means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Be deliberate about what you pull in&lt;/li&gt;
&lt;li&gt;Actually understand what each dependency can do on your machine&lt;/li&gt;
&lt;li&gt;Have a rotation plan for credentials that treats compromise as a when, not an if&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LiteLLM attack got caught by luck. The next one might not be.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>I built "npm audit" for AI agents</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Sat, 21 Mar 2026 02:23:50 +0000</pubDate>
      <link>https://dev.to/vinmay/i-built-npm-audit-for-ai-agents-5fc8</link>
      <guid>https://dev.to/vinmay/i-built-npm-audit-for-ai-agents-5fc8</guid>
      <description>&lt;p&gt;I was adding MCP tools to a project when I realized something uncomfortable: I had no idea what the code I was installing could actually do.&lt;/p&gt;

&lt;p&gt;The README said "connects Claude to Blender." What it didn't say was that one of the registered tools passes a raw string parameter to Python's &lt;code&gt;exec()&lt;/code&gt; with no builtin restriction. The LLM doesn't get "Blender API access." It gets full Python execution on the host machine.&lt;/p&gt;

&lt;p&gt;I wanted a way to know this &lt;em&gt;before&lt;/em&gt; running the code. So I built one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reachscan does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;reachscan&lt;/a&gt; is a static analysis CLI for Python and TypeScript/JavaScript AI agent codebases. Point it at a repo, a PyPI package, or an MCP endpoint, and it tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What the code can do (shell exec, file access, network calls, credential access, dynamic code execution)&lt;/li&gt;
&lt;li&gt;Which of those capabilities the LLM can actually trigger (reachability analysis)&lt;/li&gt;
&lt;li&gt;The exact call path from the LLM entry point to the dangerous code
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;reachscan

&lt;span class="c"&gt;# Scan a GitHub repo&lt;/span&gt;
reachscan https://github.com/user/repo

&lt;span class="c"&gt;# Scan a PyPI package before installing&lt;/span&gt;
reachscan pypi:some-agent-package

&lt;span class="c"&gt;# Scan local code&lt;/span&gt;
reachscan ./my-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No config, no API keys, no cloud service. It runs offline and produces a report in about 2 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;When you give an LLM tools, you're granting it real-world capabilities like file access, shell commands, network calls, credential reads. Most frameworks make it easy to add tools and hard to audit what you've exposed.&lt;/p&gt;

&lt;p&gt;Here's real code from a popular MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_blender_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Execute arbitrary Python code in Blender.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;blender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_blender_connection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;code: str&lt;/code&gt; parameter? It ends up here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpy&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# No __builtins__ restriction
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;namespace = {"bpy": bpy}&lt;/code&gt; &lt;em&gt;looks&lt;/em&gt; like a sandbox. It isn't. Without explicitly setting &lt;code&gt;__builtins__&lt;/code&gt;, Python injects the full builtins module. The LLM can &lt;code&gt;import os&lt;/code&gt;, run &lt;code&gt;subprocess&lt;/code&gt;, read your files — anything.&lt;/p&gt;

&lt;p&gt;Here's what reachscan shows for this server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  DYNAMIC  exec()                  server.py:431  reachable
           path: execute_blender_code → send_command → execute_code

  EXECUTE  subprocess.run()        addon.py:89    reachable

  SEND     requests.post()         server.py:198  reachable
           path: generate_3d_model → _call_api

  SECRETS  os.environ[...]         server.py:12   module_level
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;reachable&lt;/code&gt; tag is the key part. It means the LLM can trigger this code through a registered tool and not just that the code exists somewhere in the repo. &lt;code&gt;module_level&lt;/code&gt; means it runs on import. &lt;code&gt;unreachable&lt;/code&gt; means the code exists but no LLM call path leads to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (briefly)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detectors&lt;/strong&gt; scan the AST for 7 capability categories: EXECUTE, READ, WRITE, SEND, SECRETS, DYNAMIC, AUTONOMY&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entry point detection&lt;/strong&gt; finds the functions exposed to the LLM — &lt;code&gt;@tool&lt;/code&gt;, &lt;code&gt;@mcp.tool()&lt;/code&gt;, &lt;code&gt;@function_tool&lt;/code&gt;, &lt;code&gt;BaseTool&lt;/code&gt; subclasses, etc. across LangChain, OpenAI Agents SDK, MCP, Pydantic AI, CrewAI, Semantic Kernel, and AutoGen&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call graph + BFS&lt;/strong&gt; traces up to 8 hops from each entry point to determine which capabilities are actually reachable&lt;/li&gt;
&lt;li&gt;Every finding gets one of 5 states: &lt;code&gt;reachable&lt;/code&gt;, &lt;code&gt;unreachable&lt;/code&gt;, &lt;code&gt;module_level&lt;/code&gt;, &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;no_entry_points&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The false positive rate is 0.47% across 1,912 labeled findings on 10 real-world repos. I care about this number a lot because a noisy scanner is a useless scanner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;The short version: I was evaluating third-party MCP servers and realized there was no &lt;code&gt;npm audit&lt;/code&gt; equivalent for AI agent code. I could run &lt;code&gt;pip audit&lt;/code&gt; to check for known vulnerabilities in dependencies, but nothing told me "this package gives the LLM shell access on your machine."&lt;/p&gt;

&lt;p&gt;The existing tools I found either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Require API calls per scan (expensive, not offline)&lt;/li&gt;
&lt;li&gt;Produce flat capability lists without reachability context (noisy)&lt;/li&gt;
&lt;li&gt;Don't handle the MCP/agent-specific entry point patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built the tool I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found across 50 real MCP servers
&lt;/h2&gt;

&lt;p&gt;I ran reachscan against 50 of the most popular MCP server repos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 in 3&lt;/strong&gt; has shell execution capability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 in 3&lt;/strong&gt; has outbound network I/O&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 in 4&lt;/strong&gt; accesses credentials from environment variables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 of 50&lt;/strong&gt; had 4+ capabilities active simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The highest-risk combination: credential access + network egress. That appeared in 8 of 50 repos. If the LLM can read your AWS keys AND make HTTP calls, that's an exfiltration path.&lt;/p&gt;

&lt;p&gt;Not all of these are bugs. An AWS MCP server &lt;em&gt;should&lt;/em&gt; talk to AWS. The question is whether the LLM can misuse those capabilities — and whether you know about them before you deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;reachscan

&lt;span class="c"&gt;# Scan any GitHub repo&lt;/span&gt;
reachscan https://github.com/ahujasid/blender-mcp

&lt;span class="c"&gt;# Scan a PyPI package before installing&lt;/span&gt;
reachscan pypi:openai-agents

&lt;span class="c"&gt;# JSON output for CI&lt;/span&gt;
reachscan &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="nt"&gt;--severity&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apache 2.0, pure Python, runs offline. No API keys, no cloud service.&lt;/p&gt;

&lt;p&gt;If something looks wrong — false positive, missed pattern, bad output — &lt;a href="https://github.com/vinmay/reachscan/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;vinmay/reachscan&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/reachscan/" rel="noopener noreferrer"&gt;reachscan&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Full scan results (50 repos):&lt;/strong&gt; &lt;a href="https://medium.com/@vinmayN/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-46144659ceca" rel="noopener noreferrer"&gt;Medium writeup&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>I scanned 50 MCP servers to see what they can actually do — here's what I found</title>
      <dc:creator>Vinmay Nair</dc:creator>
      <pubDate>Thu, 12 Mar 2026 03:35:44 +0000</pubDate>
      <link>https://dev.to/vinmay/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-heres-what-i-found-49m5</link>
      <guid>https://dev.to/vinmay/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-heres-what-i-found-49m5</guid>
      <description>&lt;p&gt;One of the 50 MCP servers I scanned gives the LLM a full Python shell &lt;br&gt;
on your machine. The tool is called &lt;code&gt;execute_blender_code&lt;/code&gt;. The &lt;code&gt;exec()&lt;/code&gt; &lt;br&gt;
call has no builtin restriction. I verified it — imports, file reads, &lt;br&gt;
subprocess execution all work.&lt;/p&gt;

&lt;p&gt;That's what I built &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;reachscan&lt;/a&gt; to find.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;MCP servers aren't plugins in a sandboxed browser extension model. They &lt;br&gt;
run as normal OS processes with your user permissions. If a server calls &lt;br&gt;
&lt;code&gt;subprocess.run()&lt;/code&gt;, the LLM can trigger shell commands. If it calls &lt;br&gt;
&lt;code&gt;exec()&lt;/code&gt; without restricting builtins, the LLM gets Python execution on &lt;br&gt;
your machine.&lt;/p&gt;

&lt;p&gt;Most people don't know which of the servers they're running fall into &lt;br&gt;
which category.&lt;/p&gt;
&lt;h2&gt;
  
  
  What reachscan does
&lt;/h2&gt;

&lt;p&gt;It's a static analysis CLI for Python and TypeScript/JavaScript agent &lt;br&gt;
codebases. It maps seven capability categories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXECUTE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shell execution via subprocess, os.system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;READ/WRITE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local filesystem access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SEND&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Outbound HTTP, sockets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SECRETS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Env var credential access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DYNAMIC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exec(), eval(), importlib&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTONOMY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background threads, autonomous loops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry points&lt;/td&gt;
&lt;td&gt;LLM-callable tool registrations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key distinction from a linter: reachscan tracks &lt;strong&gt;reachability&lt;/strong&gt;. &lt;br&gt;
A &lt;code&gt;subprocess.run()&lt;/code&gt; buried in dead code is a different risk than one &lt;br&gt;
called directly from a registered MCP tool.&lt;/p&gt;

&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;reachscan
reachscan https://github.com/any-org/any-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;False positive rate: &lt;strong&gt;0.47%&lt;/strong&gt; across a labeled corpus of ~3,900 findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline finding: blender-mcp
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ahujasid/blender-mcp" rel="noopener noreferrer"&gt;blender-mcp&lt;/a&gt; has a registered &lt;br&gt;
MCP tool called &lt;code&gt;execute_blender_code&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_blender_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Execute arbitrary Python code in Blender.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;blender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_blender_connection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;execute_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string travels over a local TCP socket to the Blender addon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bpy&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ← line 431
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why &lt;code&gt;namespace = {"bpy": bpy}&lt;/code&gt; doesn't protect you
&lt;/h2&gt;

&lt;p&gt;This looks like a sandbox. It isn't.&lt;/p&gt;

&lt;p&gt;When you call &lt;code&gt;exec(code, namespace)&lt;/code&gt; without setting &lt;br&gt;
&lt;code&gt;namespace["__builtins__"]&lt;/code&gt;, Python automatically injects the full &lt;br&gt;
builtins module. I verified this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bpy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;import os; print(os.getcwd())&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → /home/user/...  ✓
&lt;/span&gt;
&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;print(open(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/hostname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;).read())&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → my-machine  ✓
&lt;/span&gt;
&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;import subprocess; r = subprocess.run([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;], &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;capture_output=True, text=True); print(r.stdout)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → uid=1000(user) gid=1000(user)  ✓
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM doesn't just get Blender API access. It gets full Python &lt;br&gt;
execution on the host.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same primitive, done right
&lt;/h2&gt;

&lt;p&gt;Finding &lt;code&gt;exec()&lt;/code&gt; in a scan result doesn't automatically mean critical. &lt;br&gt;
Two projects in this dataset handle it correctly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;awslabs/mcp&lt;/strong&gt; — strips builtins before exec():&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__builtins__&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_SAFE_BUILTINS&lt;/span&gt;
&lt;span class="c1"&gt;# Removes: __import__, exec, eval, compile, open, getattr...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;StarRocks MCP&lt;/strong&gt; — validates the AST before eval():&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;validate_plotly_expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plotly_expr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# must be exactly px.&amp;lt;method&amp;gt;(...)
&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plotly_expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;px&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;local_vars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern. Very different trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results across 50 repos
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1,139 findings across 18 repos with non-zero activity&lt;/li&gt;
&lt;li&gt;1 in 3 repos has shell execution capability&lt;/li&gt;
&lt;li&gt;1 in 4 accesses credentials from environment variables&lt;/li&gt;
&lt;li&gt;10 of 50 had 4+ capabilities active simultaneously&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The TypeScript gap
&lt;/h2&gt;

&lt;p&gt;22 repos showed 0 capability findings — many are TypeScript-only. &lt;br&gt;
reachscan detects TypeScript entry points (452 found) but doesn't yet &lt;br&gt;
analyze TypeScript function bodies. "Clean" for a TS-heavy repo means &lt;br&gt;
"no Python capability findings" — not verified safe.&lt;/p&gt;

&lt;p&gt;TypeScript capability analysis is next.&lt;/p&gt;
&lt;h2&gt;
  
  
  Main takeaway
&lt;/h2&gt;

&lt;p&gt;Adoption is moving faster than visibility. Treat MCP servers like &lt;br&gt;
privileged code, not plugins. Audit tool boundaries before you deploy.&lt;/p&gt;

&lt;p&gt;Try it on any MCP server or agent repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;reachscan
reachscan https://github.com/any-org/any-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/vinmay/reachscan" rel="noopener noreferrer"&gt;github.com/vinmay/reachscan&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full writeup with all 50 results and methodology: &lt;br&gt;
&lt;a href="https://medium.com/@vinmayN/i-scanned-50-mcp-servers-to-see-what-they-can-actually-do-46144659ceca" rel="noopener noreferrer"&gt;Medium article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>mcp</category>
      <category>python</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
