<?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: Mike Kim</title>
    <description>The latest articles on DEV Community by Mike Kim (@mikek).</description>
    <link>https://dev.to/mikek</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%2F4153534%2Fc25ee08f-f2f2-40db-a7f7-0a64996f846d.png</url>
      <title>DEV Community: Mike Kim</title>
      <link>https://dev.to/mikek</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mikek"/>
    <language>en</language>
    <item>
      <title>Doorman: my apps' contact form got 23 messages in six weeks, and all 23 were pitches</title>
      <dc:creator>Mike Kim</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:37:43 +0000</pubDate>
      <link>https://dev.to/mikek/doorman-my-apps-contact-form-got-23-messages-in-six-weeks-and-all-23-were-pitches-h7c</link>
      <guid>https://dev.to/mikek/doorman-my-apps-contact-form-got-23-messages-in-six-weeks-and-all-23-were-pitches-h7c</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;I have three small Shopify apps. Between 29 August and 13 October their listings' contact form and my email got 35 messages: 23 once the repeats and image-only ones are gone, and all 23 were selling something. Installs and reviews "from real store owners" at $30 a store, paid posts in Shopify Discords, UGC videos. One opened "are you the right person in charge of this brand...?", so I answered it. It was a pitch.&lt;/p&gt;

&lt;p&gt;The problem isn't the pitches. It's the day a real merchant writes "the cleaner deleted a product I wanted to keep, can I get it back?", and it sits between two messages that begin "I came across your Shopify app and was really impressed".&lt;/p&gt;

&lt;p&gt;Gmail already catches most of this. The pitches sent straight to my address mostly went to spam. The nine that came through the app listing's contact form all reached the inbox, because Shopify delivers them inside its own notification email, from its own address. A spam filter sees Shopify; only the words inside are a pitch. That's the inbox Doorman is for: the one behind a listing, where every message arrives wearing the marketplace's uniform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doorman&lt;/strong&gt; sorts a contact-form inbox: customers first, pitches folded away. Drop in &lt;code&gt;.eml&lt;/code&gt; files or a Gmail Takeout &lt;code&gt;.mbox&lt;/code&gt;, or paste a message, and press Sort. Pitches fold away with the giveaway sentence highlighted; close calls stay on top as "not sure"; nothing is deleted or answered. It runs entirely in your browser: the model downloads from Hugging Face once, and a customer's message, with their name, store and order in it, never leaves the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who would pay for it:&lt;/strong&gt; anyone whose contact form belongs to a marketplace listing, starting with Shopify app and theme developers. Those pitches can't be filtered upstream, because the marketplace is the sender. A new app lives on its first reviews, and a merchant whose question sat under a pile of pitches is a lost install or a one-star review. The page stays free; what I'd charge for is the version that runs on the Gmail page itself (below).&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://doorman.mikey9220.workers.dev" rel="noopener noreferrer"&gt;doorman.mikey9220.workers.dev&lt;/a&gt;&lt;/strong&gt;. The example is five merchants' questions from the public Shopify forum that the classifier has never seen, and seven pitches from my own inbox (anonymised, and part of its training data). The first sort downloads EmbeddingGemma once (197 MB); then the twelve take about two seconds on my Mac's GPU.&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%2F4p6mzukl8nlc77p481jn.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%2F4p6mzukl8nlc77p481jn.gif" alt="Sorting the example inbox: five customers stay on top, seven pitches fold away with the giveaway sentence highlighted"&gt;&lt;/a&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/mikey92" rel="noopener noreferrer"&gt;
        mikey92
      &lt;/a&gt; / &lt;a href="https://github.com/mikey92/doorman" rel="noopener noreferrer"&gt;
        doorman
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Sorts the contact-form inbox of a small app or store: real customers in, sales pitches out. Gemma runs in your browser; messages never leave your machine.
    &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;Doorman&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Your app's or shop's contact form, sorted: customers first, sales pitches folded away. It runs in your browser; nothing you load leaves the page.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://doorman.mikey9220.workers.dev" rel="nofollow noopener noreferrer"&gt;https://doorman.mikey9220.workers.dev&lt;/a&gt;&lt;/strong&gt; · Write-up: &lt;a href="https://dev.to/mike_kim_692aa79c288bfed8/doorman-my-apps-contact-form-got-23-messages-in-six-weeks-and-all-23-were-pitches-h7c" rel="nofollow"&gt;on DEV&lt;/a&gt; · Evaluation: &lt;a href="https://github.com/mikey92/doorman/eval/CLASSIFIER.md" rel="noopener noreferrer"&gt;eval/CLASSIFIER.md&lt;/a&gt; · Data: &lt;a href="https://github.com/mikey92/doorman/eval/DATA.md" rel="noopener noreferrer"&gt;eval/DATA.md&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;In six weeks my three Shopify apps' contact form, and the email behind it, got 23 different messages from 21 senders. All 23 were people selling me something: installs, reviews, a promo post in their Discord. Not one was a customer. The day one is, I don't want it buried under the next "I came across your app and was really impressed".&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/mikey92/doorman/media/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fmikey92%2Fdoorman%2FHEAD%2Fmedia%2Fdemo.gif" alt="Sorting the example inbox: customers stay on top, pitches fold away with the giveaway sentence highlighted"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Load messages (paste one, drop &lt;code&gt;.eml&lt;/code&gt; files, or a Gmail Takeout &lt;code&gt;.mbox&lt;/code&gt;) and press &lt;strong&gt;Sort Inbox&lt;/strong&gt;. &lt;strong&gt;EmbeddingGemma 300M&lt;/strong&gt;, running in a Web Worker on your GPU, turns each message and each of its sentences into 768 numbers; a logistic regression trained on 245 hand-labelled forum posts and my…&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/mikey92/doorman" 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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.eml / .mbox / paste → the text, minus the form's wrapper and quoted replies
→ EmbeddingGemma 300M on WebGPU: the message and each sentence as 768 numbers
→ logistic regression (768 weights, 7 KB of JSON): P(pitch)
→ ≥ 0.8 folded away · 0.5–0.8 "not sure" · below 0.5 customer
→ the most pitch-like sentence quoted on the card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A static site on Cloudflare's free tier; &lt;a href="https://huggingface.co/docs/transformers.js" rel="noopener noreferrer"&gt;Transformers.js&lt;/a&gt; runs &lt;code&gt;onnx-community/embeddinggemma-300m-ONNX&lt;/code&gt; (q4). No server.&lt;/p&gt;

&lt;h3&gt;
  
  
  First try: ask Gemma 3 1B, and read its mind
&lt;/h3&gt;

&lt;p&gt;Last week I ran Gemma 3 1B in the browser, so I asked it to copy the sentence where the sender offers their own product or service, or write NONE.&lt;/p&gt;

&lt;p&gt;Gemma 1B doesn't copy. Asked for "word for word", it paraphrases: "OFFER: StartStorez Bundle is a free bundle app" for a message that said "you can give StartStorez Bundle a try". Every quote failed the check.&lt;/p&gt;

&lt;p&gt;So I stopped asking it to write anything. For each sentence I asked "In this sentence, is the sender offering the business their OWN product, app, service, agency or help? Answer Yes or No", and instead of reading its answer, read its next-token logits for "Yes" and "No". With an open model you can do that: the generate loop takes a logits processor, and mine only looks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pair&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;yes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;no&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_new_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;do_sample&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;logits_processor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;peek&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// log-odds of Yes over No&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logits told me something the answers never would have: Gemma 1B says Yes to almost everything. On 40 hand-labelled messages, customers scored between −2.3 and 11.6 in log-odds; pitches between 6.8 and 14.9. A merchant asking for "Alerts via email, ideally with an attached CSV or Excel report" scored 11.6, higher than 11 of the 20 pitches. It ranks pitches above customers more often than not (AUC 0.91), but only 9 of 20 pitches beat every customer, and with one forward pass per sentence it took about 20 seconds a message on my Mac's CPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  What worked: don't ask, measure
&lt;/h3&gt;

&lt;p&gt;A pitch doesn't need to be understood to be recognised. "I came across your app", "I can help you get your first installs", "let me know if you're open to a quick chat": pitches read alike, and an embedding model is built to notice what reads alike. EmbeddingGemma is a quarter of the download, and one forward pass gives me a message's 768 numbers. All I needed was examples to fit a line through them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the examples came from
&lt;/h3&gt;

&lt;p&gt;My inbox had 23 pitches and no customers: the problem, not a dataset. So I went where merchants ask in public: the &lt;a href="https://community.shopify.com/c/shopify-apps/186" rel="noopener noreferrer"&gt;Shopify Community "Shopify Apps" board&lt;/a&gt;. I took the 60 most recently active topics, 637 posts, and read and labelled every one: asking for help, selling something of their own, helping with nothing to sell, or other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;282 of the 637 posts were someone selling their own app or service.&lt;/strong&gt; 48 of the 60 topics had at least one. One merchant asked for bundle app recommendations "(from shop owners, not app developers!)"; seven of the thirteen replies were from app developers.&lt;/p&gt;

&lt;p&gt;Leaving out 150 posts where I couldn't tell whether the account was the app's maker, and keeping one pitch per account, gave 115 merchants asking and 130 pitches. (&lt;a href="https://github.com/mikey92/doorman/blob/main/eval/DATA.md" rel="noopener noreferrer"&gt;How it was labelled&lt;/a&gt;.)&lt;/p&gt;

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

&lt;p&gt;Five-fold cross-validation over the forum posts, split by topic so nothing is ever scored by a model that saw its thread (&lt;a href="https://github.com/mikey92/doorman/blob/main/eval/CLASSIFIER.md" rel="noopener noreferrer"&gt;full report&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;Fold away at P ≥&lt;/th&gt;
&lt;th&gt;Customers hidden&lt;/th&gt;
&lt;th&gt;Pitches folded away&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;5 of 115&lt;/td&gt;
&lt;td&gt;125 of 130&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;0.8 (the page)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 of 115&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;102 of 130&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;0 of 115&lt;/td&gt;
&lt;td&gt;79 of 130&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pitches it doesn't fold away aren't lost either: the 28 under 0.8 stay on top, 23 of them marked "not sure" with their most pitch-like sentence quoted. They cost five seconds; a hidden customer costs a sale, which is why the bar is 0.8.&lt;/p&gt;

&lt;p&gt;AUC 0.991: pick a random pitch and a random customer, and 99 times in 100 the pitch scores higher. On the same 40 messages Gemma 1B judged, scored by a model trained on the other 205 posts, it's 1.000: the most pitch-like customer scores 0.28 and every pitch is above 0.8. I chose the settings on this table, so read it as a ceiling.&lt;/p&gt;

&lt;p&gt;The two customers it would hide are merchants describing their own shop in ad voice ("Award-winning refill shop in Oxfordshire…"). That is a pitch, just not aimed at me, and it's why nothing is ever deleted.&lt;/p&gt;

&lt;h3&gt;
  
  
  My inbox: a lesson in where training data comes from
&lt;/h3&gt;

&lt;p&gt;Trained on the forum alone, the classifier folded away only 13 of my 23 pitches at 0.8 (22 at 0.5). Cold emails are written differently from forum replies: longer, more flattering, and they end by asking for a call. The fix was to train on them too, and to test it honestly I scored each of the 21 senders with a model trained on the forum and on every &lt;em&gt;other&lt;/em&gt; sender's messages. &lt;strong&gt;23 of 23 folded away at 0.8&lt;/strong&gt;, 19 at 0.9. That inbox has no customers in it, so it's only half the test; the forum table is the other half. The shipped classifier is trained on all 268 messages.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The messages are the private part.&lt;/strong&gt; A support inbox is customers' names, emails, store URLs and order problems. With an open model in the browser, nothing is uploaded and no server holds anything. "Your customers' messages never leave your machine" is the whole pitch of a product like this, and with a hosted API I couldn't say it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's free to run, so it can be cheap to buy.&lt;/strong&gt; A message takes under a fifth of a second of the visitor's GPU. A hosted API would put a bill on every message, a key behind a server, and a floor under the price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could look inside.&lt;/strong&gt; Reading Gemma 1B's logits is what showed me it was saying Yes to everything, which a chat API's confident "Yes" would have hidden. Open weights also let me train a 7 KB head on EmbeddingGemma's vectors, ship it as JSON anyone can read, and run the same code in Node for the evaluation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where a closed model would be better:&lt;/strong&gt; a frontier model would likely sort these well without a single labelled example. But it would see every customer's message, charge for every one, and I'd still have needed the 245 labels to know how well it does.&lt;/p&gt;

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

&lt;p&gt;Me, and I'm not counting it: all 23 of my pitches folded away, each sender scored by a model that had never seen them. I'm looking for three app developers to run their own Takeout export through it and tell me what it got wrong; what they find goes in the comments.&lt;/p&gt;

&lt;p&gt;What they'll ask next, if my inbox is any guide: "can it run inside Gmail?" That's the Chrome extension: the same model, on the page you already have open.&lt;/p&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 300M reads every message on the visitor's GPU, with its classification prefix; Gemma 3 1B's open logits showed why asking a small model "is this a pitch?" doesn't work.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Every PDF failed on Windows because of one trailing backslash (and two more Windows text gotchas)</title>
      <dc:creator>Mike Kim</dc:creator>
      <pubDate>Fri, 09 Oct 2026 04:10:58 +0000</pubDate>
      <link>https://dev.to/mikek/every-pdf-failed-on-windows-because-of-one-trailing-backslash-and-two-more-windows-text-gotchas-9n5</link>
      <guid>https://dev.to/mikek/every-pdf-failed-on-windows-because-of-one-trailing-backslash-and-two-more-windows-text-gotchas-9n5</guid>
      <description>&lt;p&gt;I build BCD, a hosted remote MCP server that lets ChatGPT and Claude read files and run commands on your own computer. Our logs showed file reads failing far more often on Windows than anywhere else. There were three causes, and you'd never see any of them testing on a Mac.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. pdf.js and a trailing &lt;code&gt;\&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;We passed pdf.js (pdfjs-dist) its standard font folder like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;standardFontDataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;here&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../node_modules/pdfjs-dist/standard_fonts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS and Linux that ends in &lt;code&gt;/&lt;/code&gt;. On Windows &lt;code&gt;path.sep&lt;/code&gt; is &lt;code&gt;\&lt;/code&gt;, so it ends in &lt;code&gt;\&lt;/code&gt;. pdf.js treats the value as a URL and checks the last character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getFactoryUrlProp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Invalid factory url: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" must include trailing slash.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So on Windows, &lt;code&gt;getDocument()&lt;/code&gt; threw for every PDF, whatever its content, and from the outside it just looked like "this PDF can't be read". The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fontFolderUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;separator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;separator&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Node, pdf.js appends the font file name and hands the result to &lt;code&gt;fs.readFile&lt;/code&gt;. Node's &lt;code&gt;fs&lt;/code&gt; on Windows reads &lt;code&gt;C:/Users/.../standard_fonts/FoxitSans.pfb&lt;/code&gt; with forward slashes just fine. We added a unit test that feeds in a Windows-style path, and a step in our Windows CI smoke test that reads a real PDF.&lt;/p&gt;

&lt;p&gt;Lesson: when you append &lt;code&gt;path.sep&lt;/code&gt;, check whether the value is used as a path or as a URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. PowerShell writes UTF-16
&lt;/h2&gt;

&lt;p&gt;In Windows PowerShell 5.1, &lt;code&gt;command &amp;gt; out.txt&lt;/code&gt; writes UTF-16LE with a byte order mark (&lt;code&gt;FF FE&lt;/code&gt;). Our text reader was UTF-8 only and treated any NUL byte as binary. UTF-16 has a NUL after every ASCII character, so those files were all rejected. An AI writing output to a file with PowerShell and then reading it back is a common flow, so this hurt.&lt;/p&gt;

&lt;p&gt;Now the first two bytes pick the decoder: &lt;code&gt;new TextDecoder('utf-16le', { fatal: true })&lt;/code&gt; (or &lt;code&gt;utf-16be&lt;/code&gt;). When we read the end of a large file, cutting in the middle can split a character, so the window starts after the next line break (&lt;code&gt;0A 00&lt;/code&gt; in UTF-16LE).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Excel CSVs in CP949, and Node's &lt;code&gt;euc-kr&lt;/code&gt; trap
&lt;/h2&gt;

&lt;p&gt;On Korean Windows, Excel saves CSV in CP949. There's a trap here: Node's &lt;code&gt;TextDecoder('euc-kr')&lt;/code&gt; is not CP949. It can't decode the extended Hangul syllables, and it doesn't throw. It silently produces control characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;euc-kr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x8c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x63&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="c1"&gt;// '\x8Cc'  (wrong, and no exception)&lt;/span&gt;
&lt;span class="nx"&gt;iconv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x8c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x63&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cp949&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// '똠'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What we do now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If UTF-8 decoding hits an invalid sequence, read the file again in the legacy code page for the computer's language (CP949 for Korean, CP932 for Japanese, CP936 or CP950 for Chinese) with iconv-lite.&lt;/li&gt;
&lt;li&gt;If that result contains &lt;code&gt;U+FFFD&lt;/code&gt;, it isn't text.&lt;/li&gt;
&lt;li&gt;Files with NUL bytes are real binaries and are never retried.&lt;/li&gt;
&lt;li&gt;The read reports which encoding it used (&lt;code&gt;encoding: "cp949"&lt;/code&gt;), so the AI knows before it writes the file back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: classify failures honestly
&lt;/h2&gt;

&lt;p&gt;A file that really isn't text is now recorded as an invalid argument, not a tool failure. If your own bugs and your users' inputs land in the same bucket, you won't see the next bug.&lt;/p&gt;




&lt;p&gt;Disclosure: I'm the maker of &lt;a href="https://bcd.snack-wrap.com/?utm_source=devto&amp;amp;utm_campaign=windows-pdf-encoding" rel="noopener noreferrer"&gt;BCD&lt;/a&gt;. It lets ChatGPT and Claude work on your own Mac, Windows or Linux computer: files, commands, documents and a browser.&lt;/p&gt;

</description>
      <category>node</category>
      <category>windows</category>
      <category>javascript</category>
      <category>encoding</category>
    </item>
    <item>
      <title>Letting a team's AI run commands on shared computers: two layers of permissions and an audit log</title>
      <dc:creator>Mike Kim</dc:creator>
      <pubDate>Thu, 08 Oct 2026 22:16:41 +0000</pubDate>
      <link>https://dev.to/mikek/letting-a-teams-ai-run-commands-on-shared-computers-two-layers-of-permissions-and-an-audit-log-2kn5</link>
      <guid>https://dev.to/mikek/letting-a-teams-ai-run-commands-on-shared-computers-two-layers-of-permissions-and-an-audit-log-2kn5</guid>
      <description>&lt;p&gt;Once ChatGPT or Claude can edit files and run commands on your computer through MCP, using it alone is easy. Sharing it with a team raises questions fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who asked the AI to do what, on which computer?&lt;/li&gt;
&lt;li&gt;What if a command the AI ran had an API key in it?&lt;/li&gt;
&lt;li&gt;Where do you see what someone did after they left the team?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disclosure: I'm the maker of BCD, a hosted remote MCP server that lets AI apps work on your own Mac, Windows or Linux computer. This week I shipped a team activity log, and these are the decisions behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each computer runs a small agent that only opens an &lt;strong&gt;outbound&lt;/strong&gt; connection to a relay. No ports are opened.&lt;/li&gt;
&lt;li&gt;AI apps (ChatGPT, Claude, Codex) connect by adding &lt;code&gt;https://bcd.snack-wrap.com/mcp&lt;/code&gt; as a connector.&lt;/li&gt;
&lt;li&gt;In a team, each member shares computers they paired.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two layers of permissions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What the computer's owner allows&lt;/strong&gt;: read / write / run commands / control the screen / clipboard. A new computer starts read-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The member's team role&lt;/strong&gt;: admin (everything, plus managing members), builder (read, write, run commands), viewer (read).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A member gets the &lt;strong&gt;narrower&lt;/strong&gt; of the two. A builder on a computer its owner left read-only can only read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the log records
&lt;/h2&gt;

&lt;p&gt;Every call on a shared computer is recorded, including the owner's own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who, on which computer, through which AI app (ChatGPT, Claude…)&lt;/li&gt;
&lt;li&gt;what they did (ran a command, read a file, deleted files…), the result, why it failed, how long it took&lt;/li&gt;
&lt;li&gt;the files and folders, web addresses (without their query) and commands the call named&lt;/li&gt;
&lt;li&gt;team changes: invitations, joins, role changes, removals, shared computers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never recorded: typed text, file contents, clipboard contents, output.&lt;/p&gt;

&lt;p&gt;Admins filter by member, computer, result and period, search by file path or command, and export CSV. The team owner picks how long entries are kept: 30, 90, 180 or 365 days. Only admins can see the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets are masked before anything is stored
&lt;/h2&gt;

&lt;p&gt;Commands an AI runs tend to carry tokens. These are real outputs of the masking code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -H "Authorization: Bearer eyJhbGciOi..." https://api.example.com
→ curl -H "Authorization: *** ***" https://api.example.com

export OPENAI_API_KEY=sk-proj-... &amp;amp;&amp;amp; npm test
→ export OPENAI_API_KEY=*** &amp;amp;&amp;amp; npm test

psql postgres://me:s3cret@db.example.com/app
→ psql postgres://me:***@db.example.com/app

https://api.example.com/v1/orders?token=abc
→ https://api.example.com/v1/orders?…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Decisions worth stealing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist the arguments you read.&lt;/strong&gt; The log reads only named arguments such as &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;command&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt;. Fields like &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt; or &lt;code&gt;input&lt;/code&gt; are never read, so a new tool can't leak file contents into the log by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot names.&lt;/strong&gt; Entries keep the person's email and the computer's name at the time, so they still read correctly after a member leaves or a computer is removed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defuse CSV formulas.&lt;/strong&gt; Cells starting with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;@&lt;/code&gt; get a leading &lt;code&gt;'&lt;/code&gt;, so a spreadsheet never runs them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows, not a wide table.&lt;/strong&gt; Each entry reads top to bottom: what happened and its result, what it named, then who, where, when. It works on a phone without sideways scrolling.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;One computer stays free; the first month covers 3 computers with no card. Paid plans and the free month can try a team of 3.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://bcd.snack-wrap.com/?lang=en&amp;amp;utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=team_audit" rel="noopener noreferrer"&gt;bcd.snack-wrap.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What else would your team need before letting an AI work on shared machines? I'd like to hear it in the comments.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Paper Trail: Gemma writes your walk a field card, then the screen goes away</title>
      <dc:creator>Mike Kim</dc:creator>
      <pubDate>Tue, 06 Oct 2026 02:23:27 +0000</pubDate>
      <link>https://dev.to/mikek/paper-trail-gemma-writes-your-walk-a-field-card-then-the-screen-goes-away-3mf4</link>
      <guid>https://dev.to/mikek/paper-trail-gemma-writes-your-walk-a-field-card-then-the-screen-goes-away-3mf4</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;Nature apps have an awkward habit: they get you outside and then ask you to look at them. You lift the phone to identify a bird, the bird leaves, and you're still looking at the phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paper Trail&lt;/strong&gt; goes the other way. Tell it where you're walking and when. It finds the species people have actually reported there at this time of year, asks &lt;strong&gt;Gemma 3 1B, running inside your browser tab&lt;/strong&gt;, for one line on how to recognise each, and gives you a single page to print: twelve species with checkboxes, a warning where Wikipedia gives a reason ("Toxic: don't taste"), the day's weather, the time to start by to finish in daylight, and a box to sketch whatever isn't on the card. On my Mac the screen part takes about twenty seconds. The phone can stay in your pocket for the rest of the afternoon.&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%2F7lm01ze6evu1vozi23jh.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%2F7lm01ze6evu1vozi23jh.png" alt="The example card: Rancho San Antonio, a preserve in the hills above Cupertino, for a 60-minute walk on October 11" width="800" height="896"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's for anyone who'd like to know what they're looking at without walking behind a screen: a family ticking boxes on a Sunday loop, a kid who just learned what a towhee is and wants to find another.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live: &lt;a href="https://paper-trail.mikey9220.workers.dev" rel="noopener noreferrer"&gt;paper-trail.mikey9220.workers.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Press &lt;em&gt;See an Example&lt;/em&gt; for a finished card straight away. Your own card downloads Gemma 3 1B once (about 0.8 GB). In Chrome on my Mac the first card took 21 seconds of download and 17 to make; every card after that, about 17. Without WebGPU the page suggests quoting Wikipedia instead.&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%2F5xes2vo3tp570xjubotn.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%2F5xes2vo3tp570xjubotn.gif" alt="Making a card in real time: type a place, Gemma writes twelve tips on the GPU, print" width="719" height="497"&gt;&lt;/a&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/mikey92" rel="noopener noreferrer"&gt;
        mikey92
      &lt;/a&gt; / &lt;a href="https://github.com/mikey92/paper-trail" rel="noopener noreferrer"&gt;
        paper-trail
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A printed field card for your walk: species people reported nearby, one line each written by Gemma 3 1B in your browser and checked against Wikipedia.
    &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;Paper Trail&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A printed field card for this week's walk, written by Gemma in your browser.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://paper-trail.mikey9220.workers.dev" rel="nofollow noopener noreferrer"&gt;https://paper-trail.mikey9220.workers.dev&lt;/a&gt;&lt;/strong&gt; · Write-up: &lt;a href="https://dev.to/mike_kim_692aa79c288bfed8/paper-trail-gemma-writes-your-walk-a-field-card-then-the-screen-goes-away-3mf4" rel="nofollow"&gt;on DEV&lt;/a&gt; · Evaluation: &lt;a href="https://github.com/mikey92/paper-trail/eval/EVAL.md" rel="noopener noreferrer"&gt;eval/EVAL.md&lt;/a&gt; · Hand review: &lt;a href="https://github.com/mikey92/paper-trail/eval/REVIEW.md" rel="noopener noreferrer"&gt;eval/REVIEW.md&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Tell it where and when. It finds the species people actually reported there in past years at this
time of year (iNaturalist), reads what each one looks like (Wikipedia), and has &lt;strong&gt;Gemma 3 1B, running
in a Web Worker on your own GPU&lt;/strong&gt;, write one line on how to recognise each. Every line is checked
against the Wikipedia sentences it was written from before it can reach the card; a line that fails
is retried once with the rejected words named, and if it fails again the card quotes Wikipedia
instead. You get one page with checkboxes, a box to sketch in, the weather and the time to start by
to finish in daylight. Print it and put the phone…&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/mikey92/paper-trail" 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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;where + when → iNaturalist: species reported within a few km, this month in past years
             → pick 12: birds, plants, a fungus and an insect, most-reported first
             → Wikipedia: the Description sentences about colour, shape, texture, sound
             → Gemma 3 1B in a Web Worker: one tip each
             → check against those sentences: fail → retry once → quote Wikipedia
             → Open-Meteo weather → one printable page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no backend: the page is static files on Cloudflare's free tier, and Gemma runs on your GPU. &lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;Transformers.js&lt;/a&gt; loads &lt;a href="https://huggingface.co/onnx-community/gemma-3-1b-it-ONNX-GQA" rel="noopener noreferrer"&gt;&lt;code&gt;onnx-community/gemma-3-1b-it-ONNX-GQA&lt;/code&gt;&lt;/a&gt; (4-bit, 763 MB) on WebGPU, and decoding is greedy, so the same sentences always produce the same tip.&lt;/p&gt;

&lt;h3&gt;
  
  
  A 1B model is a careless witness
&lt;/h3&gt;

&lt;p&gt;My first prompt asked for "ONE sentence of at most 15 words that tells a walker what to look or listen for", using "only words and facts from the source". I ran it on six places on three continents (72 species, the same inputs every time) and read every answer. Gemma 3 1B writes fluent, plausible tips, and gets the details wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Low smartweed: &lt;em&gt;"Look for hairy stems…"&lt;/em&gt;. Wikipedia: "The hairless, branching stems…"&lt;/li&gt;
&lt;li&gt;Great blue heron: &lt;em&gt;"a rusty gray head"&lt;/em&gt;. The neck is rusty-gray; the head is paler.&lt;/li&gt;
&lt;li&gt;A bracket fungus: &lt;em&gt;"…and a mild taste"&lt;/em&gt;. Wikipedia says so. No card should send anyone to taste a wild mushroom.&lt;/li&gt;
&lt;li&gt;Toyon, a shrub: &lt;em&gt;"listen for the cheerful toyon song"&lt;/em&gt;, borrowed from the robin in my example prompt.&lt;/li&gt;
&lt;li&gt;Common hawthorn: &lt;em&gt;"Dark red fruit persists for 107.3 days, bearing 2.8 seeds."&lt;/em&gt; True. Useless on a walk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What makes this fixable is that every tip is written from a handful of sentences I already have, so every tip can be checked against them. &lt;code&gt;checkTip()&lt;/code&gt; in &lt;a href="https://github.com/mikey92/paper-trail/blob/main/src/ground.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/ground.ts&lt;/code&gt;&lt;/a&gt; passes a tip only if:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;every content word is in the source&lt;/strong&gt;, after crude stemming (&lt;em&gt;lobed&lt;/em&gt; and &lt;em&gt;lobes&lt;/em&gt;, &lt;em&gt;reddish&lt;/em&gt; and &lt;em&gt;red&lt;/em&gt;); pointing words like &lt;em&gt;look&lt;/em&gt; and &lt;em&gt;observe&lt;/em&gt; are free;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;every colour, tone or pattern sits next to the same part in the source&lt;/strong&gt;: within eight words, with no other colour in between. That catches the heron's &lt;em&gt;rusty gray head&lt;/em&gt;, and &lt;em&gt;green spores&lt;/em&gt; on sulphur tuft, whose gills turn green "as the blackish spores develop";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;there are no numbers&lt;/strong&gt;, not even true ones, and &lt;strong&gt;nothing about taste&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"listen for" comes with something to hear&lt;/strong&gt;: a call, a song, drumming.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;checkTip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Look for yellow gills and green spores.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tuft&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;unsupported&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;green spores&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed tip gets one retry that names the rejected words (&lt;em&gt;The source does not say: "gray leaves"…&lt;/em&gt;). If that fails too, the card quotes the article's most visual sentence in quotation marks. Every line on a card is either checked or quoted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four prompts, the same 72 species
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Passed first try&lt;/th&gt;
&lt;th&gt;Printed, with one retry&lt;/th&gt;
&lt;th&gt;Quoted instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;3 (4%)&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2&lt;/td&gt;
&lt;td&gt;"Look for" or "Listen for"; a bird and a plant as examples; &lt;em&gt;observe&lt;/em&gt; allowed; no numbers&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;No taste; "listen" needs a sound; no sentences comparing with another species; colour within eight words of its part&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;"Keep every colour with its own part"; no other colour in between&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v1's 4% wasn't Gemma being wrong 96% of the time: most first tries began "Observe the…", and &lt;em&gt;observe&lt;/em&gt; isn't in any Wikipedia description. Reading the rejections, not just counting them, found that and most of the other fixes (&lt;a href="https://github.com/mikey92/paper-trail/blob/main/eval/EVAL.md" rel="noopener noreferrer"&gt;full report&lt;/a&gt;). v4 prints fewer of Gemma's lines than v3 on purpose: I read every line both printed, and v3 had 12 wrong where v4 has 5. A quote is plain, but it's true.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the check can't see
&lt;/h3&gt;

&lt;p&gt;I read all 39 tips Gemma wrote in the final run next to their sources (&lt;a href="https://github.com/mikey92/paper-trail/blob/main/eval/REVIEW.md" rel="noopener noreferrer"&gt;every verdict&lt;/a&gt;): 29 right, 5 clumsy (&lt;em&gt;glabrous&lt;/em&gt;, &lt;em&gt;sori&lt;/em&gt;), 5 wrong. Two of the wrong ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wood duck: &lt;em&gt;"listen for a rising whistle from the female"&lt;/em&gt;. The rising whistle is the male's call.&lt;/li&gt;
&lt;li&gt;Toyon: &lt;em&gt;"red fruits developing in December"&lt;/em&gt;. They develop in August or September and ripen in December. That one is on the example card above; I left it there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every word is in the source and every colour is next to its part. What's wrong is &lt;em&gt;whose&lt;/em&gt; or &lt;em&gt;when&lt;/em&gt;, taken from the next clause, and a word-level check can't parse who a clause is about. It narrows what can go wrong from anything a model can imagine to true words put back together wrong, while the species list, warnings and weather stay plain data. A third was my fault, not Gemma's: the lyrebird article's tail section opens with "Adult males have tails up to 70 cm (28 in) long", my filter dropped that sentence for its measurement, and Gemma never learned whose tail had "two silvery median feathers".&lt;/p&gt;

&lt;p&gt;Gemma 3 270M, a third of the download, isn't the default for a measured reason: on the same 72 species, one of its tips passed. It mostly copied the prompt's plant example onto everything (&lt;em&gt;"bright white bark peeling in papery strips"&lt;/em&gt; for a sparrow and a hawk), and the check caught each one.&lt;/p&gt;

&lt;h3&gt;
  
  
  One deploy surprise
&lt;/h3&gt;

&lt;p&gt;Everything worked on localhost; on the live site the model never loaded. Hugging Face answers 404, without a CORS header, to any request whose &lt;code&gt;Referer&lt;/code&gt; is a &lt;code&gt;*.workers.dev&lt;/code&gt; page (&lt;code&gt;pages.dev&lt;/code&gt; and &lt;code&gt;localhost&lt;/code&gt; are fine; I checked with curl). The page fell back to quoting Wikipedia, as designed, which is why I nearly missed it. The fix is one line in &lt;code&gt;public/_headers&lt;/code&gt;: &lt;code&gt;Referrer-Policy: no-referrer&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;It costs nothing to run, so it can stay free.&lt;/strong&gt; A card is up to two dozen generations. On a hosted API that's a bill per card and a key to hide behind a server; here the model runs on the visitor's GPU and the site is static files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your location stays yours.&lt;/strong&gt; The model never sees where you are, only Wikipedia sentences. A location from your device is rounded to about a kilometre before it goes to iNaturalist and Open-Meteo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could see and steer every step.&lt;/strong&gt; Open weights let me run the same model, prompt and decoding in Node for a reproducible evaluation; for the example card it wrote the same twelve lines as the browser on WebGPU, but for one word. Retries are free, so being strict is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where a closed model would be better:&lt;/strong&gt; the writing. A frontier model would write livelier tips with fewer retries, but it would still need the same check, and it would put a server, a key and a bill between you and the walk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data is open too:&lt;/strong&gt; iNaturalist's observations, Wikipedia's descriptions (CC BY-SA) and Open-Meteo's forecasts (CC BY). Paper Trail is glue and a small model on top of other people's open work.&lt;/p&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;: Gemma 3 1B writes every tip in the visitor's browser on WebGPU, with no server, and most of this post is what it took to print a 1B model's sentences.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Gate Check: can this power bank fly? An agent that knows when airlines and regulators disagree</title>
      <dc:creator>Mike Kim</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:27:51 +0000</pubDate>
      <link>https://dev.to/mikek/gate-check-can-this-power-bank-fly-an-agent-that-knows-when-airlines-and-regulators-disagree-2410</link>
      <guid>https://dev.to/mikek/gate-check-can-this-power-bank-fly-an-agent-that-knows-when-airlines-and-regulators-disagree-2410</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path One: Ship an Agent That Queries Real Content&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Gate Check&lt;/strong&gt; answers a question people get wrong at the gate every day: &lt;em&gt;can this power bank come with me, and what am I allowed to do with it on board?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The rules changed fast. On 27 March 2026 ICAO limited power banks to two per passenger and banned recharging them on board. Hong Kong applied that the next day, Korea from 20 April, Japan from 24 April. Emirates already allowed only one power bank and banned using it. Lufthansa Group, EVA Air, Singapore Airlines and Qantas each picked their own dates and wording. IATA's 2026 guidance lists power banks of 100–160 Wh as &lt;em&gt;forbidden&lt;/em&gt;, while ICAO's addendum still allows them with the airline's approval. And the copies go stale: a 2025 Korean government page still says you may bring five, and Incheon Airport's 2025 press release converts 100 Wh to 20,000 mAh at 5 V, while the ministry and the airlines use 3.7 V (about 27,000 mAh).&lt;/p&gt;

&lt;p&gt;So "can I bring my 27,000 mAh power bank?" is not a search problem. You have to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;convert mAh to watt-hours,&lt;/li&gt;
&lt;li&gt;find every rule that applies to &lt;em&gt;this&lt;/em&gt; trip: each airline, each departure country, and the worldwide baseline,&lt;/li&gt;
&lt;li&gt;place the battery in each rule's watt-hour bands and count limits, and&lt;/li&gt;
&lt;li&gt;let the strictest rule decide, while keeping track of which page says what.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Gate Check does steps 1 to 4 in code over structured content in Sanity, and an agent uses a Sanity Knowledge Base to explain the result and the disagreements behind it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two 27,000 mAh power banks, Korean Air then Emirates, leaving Korea: &lt;strong&gt;not allowed&lt;/strong&gt;. Emirates allows one, and Gate Check quotes both Emirates pages that say so.&lt;/li&gt;
&lt;li&gt;One 30,000 mAh power bank (111 Wh) on Asiana from Seoul: &lt;strong&gt;needs airline approval&lt;/strong&gt;, because it is over 100 Wh and up to 160 Wh.&lt;/li&gt;
&lt;li&gt;Two 20,000 mAh power banks on Korean Air from Seoul, asked in plain English: &lt;strong&gt;you can carry them, but not use them&lt;/strong&gt;. The agent explains that Korean Air and Korea's transport ministry ban using power banks in flight, while ICAO only says they "should not" be used.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://gate-check.mikey9220.workers.dev" rel="noopener noreferrer"&gt;https://gate-check.mikey9220.workers.dev&lt;/a&gt; (no login)&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%2Fvzarjx0ui4s44511iqdz.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%2Fvzarjx0ui4s44511iqdz.gif" alt="Gate Check in action: the quick check, then the agent working through a question, with the rule engine's verdict shown before the answer." width="600" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quick check&lt;/strong&gt; runs the rule engine alone, with no AI: capacity, count, airlines and departure countries in; one card per rule out, strictest first, with the exact wording behind each finding and a link to the page it came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the agent&lt;/strong&gt; takes a question in plain language and shows its work as it goes: each tool call, then the rule engine's verdict, then the answer, with every page it rests on.&lt;/li&gt;
&lt;/ul&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%2Fog7lyggtrch07bnpszrp.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%2Fog7lyggtrch07bnpszrp.png" alt="Quick check: two 27,000 mAh power banks on Korean Air and Emirates. Emirates allows one, and both Emirates pages are quoted." width="780" height="900"&gt;&lt;/a&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%2F9cnwjubn01c825430sfw.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%2F9cnwjubn01c825430sfw.png" alt="The agent answering a question in plain English, with the pages behind the answer." width="780" height="900"&gt;&lt;/a&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/mikey92" rel="noopener noreferrer"&gt;
        mikey92
      &lt;/a&gt; / &lt;a href="https://github.com/mikey92/gate-check" rel="noopener noreferrer"&gt;
        gate-check
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Can this power bank fly? Airline and regulator power bank rules as structured content in Sanity, evaluated in code, served through Sanity Context MCP.
    &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;Gate Check&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Can this power bank fly?&lt;/strong&gt; An agent that answers from airline, regulator and IATA rules kept as structured content in Sanity, evaluates them in code, and shows the exact wording behind every verdict, including where the sources disagree.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://gate-check.mikey9220.workers.dev" rel="nofollow noopener noreferrer"&gt;https://gate-check.mikey9220.workers.dev&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/mikey92/gate-check/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fmikey92%2Fgate-check%2FHEAD%2Fdocs%2Fdemo.gif" alt="Gate Check: the quick check, then the agent working through a question, with the rule engine's verdict shown before the answer"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Power bank rules changed fast in 2025 and 2026. ICAO limited power banks to two per passenger and banned recharging them on board from 27 March 2026; Korea, Japan and Hong Kong followed with their own dates; Emirates allows one and bans using it; IATA's 2026 guidance lists 100–160 Wh power banks as forbidden while ICAO still allows them with the airline's approval. News articles, airport pages and travel blogs restate all of this and go out of date. A traveller holding a "27,000 mAh" battery has to convert it to watt-hours, find every rule that applies to every leg, and pick the strictest.&lt;/p&gt;
&lt;p&gt;Gate Check does…&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/mikey92/gate-check" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The content model
&lt;/h3&gt;

&lt;p&gt;Three document types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;authority&lt;/code&gt;&lt;/strong&gt;: an airline, regulator, airport operator or industry body, with its country and codes (Asiana is &lt;code&gt;OZ&lt;/code&gt;, &lt;code&gt;Asiana&lt;/code&gt; and &lt;code&gt;아시아나&lt;/code&gt;). There are 32.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt;&lt;/strong&gt;: a captured copy of one page: URL, kind (official, news or third-party copy), language, capture time, an excerpt, and the statements quoted from it. There are 60, in English, Korean, Japanese and Chinese.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;batteryRule&lt;/code&gt;&lt;/strong&gt;: what one authority says, in fields code can evaluate. There are 30.

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scope&lt;/code&gt;: an airline's own rule, a departure country's rule, domestic flights, the worldwide baseline, or advisory guidance&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;limits&lt;/code&gt;: &lt;code&gt;freeMaxWh&lt;/code&gt;, &lt;code&gt;approvalMaxWh&lt;/code&gt;, &lt;code&gt;maxWh&lt;/code&gt;, &lt;code&gt;freeMaxCount&lt;/code&gt;, &lt;code&gt;approvalMaxCount&lt;/code&gt;, &lt;code&gt;totalMaxCount&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onboard&lt;/code&gt;: checked baggage, using it in flight, recharging it, the overhead bin, keeping it visible, terminal protection, a readable label, a certification mark, each with a value such as &lt;code&gt;forbidden&lt;/code&gt;, &lt;code&gt;discouraged&lt;/code&gt; or &lt;code&gt;required&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt; (confirmed, disputed or unverified) and a &lt;code&gt;note&lt;/code&gt; that explains a disagreement&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quotes[]&lt;/code&gt;: for each field, the exact wording and a reference to the source it came from (211 in all)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rule may only state what a source says. The build script copies every quote's text from the captured page, so it is never typed twice, checks every reference, and fails if a rule sets a limit or an on-board condition without a quote. That check found 28 gaps in my own data. It also fails if two documents share a title, because the Knowledge Base cites documents by title.&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%2Fls3hu8dfxnq44fomqxv5.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%2Fls3hu8dfxnq44fomqxv5.png" alt="A batteryRule in Sanity Studio: the Evidence group holds the exact wording behind each field." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Two Context MCP endpoints
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gate-check-data&lt;/code&gt;, GROQ mode, over the whole dataset.&lt;/strong&gt; The rule engine fetches every rule that applies to a trip in one GROQ query (airline rules matched by name or code, departure-country rules by country, worldwide rules always), with each rule's quotes and their sources. User input only reaches GROQ as JSON-escaped string literals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gate-check-rules&lt;/code&gt;, Knowledge Base mode.&lt;/strong&gt; The Knowledge Base reads the same dataset through a custom GROQ projection: 30 rules and 60 pages, 90 documents within the 150 budget. The projection flattens Portable Text with &lt;code&gt;pt::text(body)&lt;/code&gt; and keeps provenance (kind, language, capture date, authority) next to the text, so entries can tell an official page from a news report or a copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Knowledge Base
&lt;/h3&gt;

&lt;p&gt;Sanity Context organised the 90 documents into 22 entries: airlines by region, each regulator, the ICAO baseline, IATA's guidance, airport screening, entries across all authorities on in-flight use, storage, watt-hour limits and terminal protection, and an entry for conflicting and superseded rules.&lt;/p&gt;

&lt;p&gt;Each build files the conflicts it finds as issues, with both claims and their sources side by side. Working through them was the most useful part of the project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real errors became standing instructions.&lt;/strong&gt; An entry spelled Air Canada's banned Anker model A1682; the page says A1681. Another said ICAO bans power banks from overhead bins; the addendum says nothing about overhead bins, and the claim had come from a travel blog. Korea's 2025 limit of five power banks lost to the 2026 limit of two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Differences between authorities were dismissed.&lt;/strong&gt; ICAO's "should not use" and an airline's ban are both right, each for its own flights. The FAA's "no quantity limit" and an airline's cap of two are both right too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facts the entries kept getting wrong became manual instructions.&lt;/strong&gt; EASA has not banned using power banks: its bulletin is information only, and its press release says "not forbidden but strongly discouraged". In Japan, five of the seven rules in the ministry's leaflet carry penalties, and "don't use it to charge devices" is not one of them. And ANA's page sets no rule on using power banks in flight, but an entry that covered JAL and ANA together summed both up as banning it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The purpose carries the policy for everything else.&lt;/strong&gt; Rules are never merged; every limit is attributed to its authority and dated; superseded rules are labelled as previous rules; and for 100–160 Wh power banks, both ICAO's and IATA's positions are reported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No issue is left pending: each one was resolved or dismissed, and every future build keeps those decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agent
&lt;/h3&gt;

&lt;p&gt;A Cloudflare Worker runs a tool-calling loop on GPT-5.5, on my ChatGPT plan through a small relay on my machine (chatgpt.com refuses calls from Workers); if the relay is down, it runs on Workers AI (Nemotron 3). Its tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;check_power_bank&lt;/code&gt;: the rule engine. It converts mAh to Wh, evaluates every applicable rule and returns the verdicts. The model is told never to convert units or compare limits itself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;find_rules&lt;/code&gt;: one authority's rules with every quote, from the GROQ endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compare_rules&lt;/code&gt;: one on-board condition, such as the overhead bin, across every airline and regulator, for questions like "which airlines ban…". One GROQ query instead of six entry reads.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_entries&lt;/code&gt; and &lt;code&gt;read_entries&lt;/code&gt;: the Knowledge Base, through &lt;code&gt;knowledge_base_search&lt;/code&gt; and &lt;code&gt;knowledge_base_read&lt;/code&gt;, with the outline from &lt;code&gt;initial_context&lt;/code&gt; in the system prompt.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_groq&lt;/code&gt;: read-only GROQ, as a last resort.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model never writes a URL: the Worker maps the documents each Knowledge Base entry cites, and the source of every rule it evaluated, to their pages, official pages first. The quick check calls the same &lt;code&gt;check_power_bank&lt;/code&gt; function with no model at all, so a verdict never depends on the model.&lt;/p&gt;

&lt;p&gt;The Worker streams each tool call and the rule engine's verdict to the page while the model is still writing, and keeps finished answers in Workers KV, so a repeated question answers at once.&lt;/p&gt;

&lt;p&gt;I checked it with 20 varied questions against the dataset: every verdict matched, and the slips I found (a missed list of banned models, ICAO's "must not be recharged" softened into a recommendation) became rules in the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Project ID: &lt;code&gt;nkpgzr3t&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dataset: &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nkpgzr3t.api.sanity.io/v2025-02-19/data/query/production?query=%2A%5B_type%3D%3D%22batteryRule%22%5D%7Btitle%2Cscope%2Cstatus%2Climits%2Conboard%2C%22authority%22%3Aauthority-%3Ename%2C%22quotes%22%3Aquotes%5B%5D%7Bfield%2Ctext%2C%22source%22%3Asource-%3Eurl%7D%7D" rel="noopener noreferrer"&gt;Every rule with its quotes, as a public GROQ query&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Context MCP endpoints: &lt;code&gt;gate-check-rules&lt;/code&gt; (Knowledge Base mode) and &lt;code&gt;gate-check-data&lt;/code&gt; (GROQ mode)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rules are a snapshot from 2 October 2026. Always check with your airline before you fly.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>I let ChatGPT on my phone work on my Mac: a remote MCP setup</title>
      <dc:creator>Mike Kim</dc:creator>
      <pubDate>Thu, 01 Oct 2026 04:11:30 +0000</pubDate>
      <link>https://dev.to/mikek/i-let-chatgpt-on-my-phone-work-on-my-mac-a-remote-mcp-setup-51eb</link>
      <guid>https://dev.to/mikek/i-let-chatgpt-on-my-phone-work-on-my-mac-a-remote-mcp-setup-51eb</guid>
      <description>&lt;p&gt;I kept asking ChatGPT to "clean up my Downloads folder" and getting a nice list of steps back. What I wanted was for it to just do it, on my Mac, even when I'm not at the desk.&lt;/p&gt;

&lt;p&gt;So I built BCD. Disclosure up front: I'm the maker. This post walks through how the setup works, what it can and can't do, and the safety model, so you can decide if it fits.&lt;/p&gt;

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

&lt;p&gt;BCD has two parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A small agent on your Mac. It connects out to a relay over a WebSocket. Nothing listens on your network and no ports are opened.&lt;/li&gt;
&lt;li&gt;A hosted remote MCP server at &lt;code&gt;https://bcd.snack-wrap.com/mcp&lt;/code&gt;. You add it to ChatGPT or Claude as a connector, sign in, and the AI can call tools that run on your Mac.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the AI app talks to the hosted server, it works from the ChatGPT or Claude app on your phone too. The Mac does the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup (about 5 minutes)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create a free account at &lt;a href="https://bcd.snack-wrap.com/?utm_source=devto" rel="noopener noreferrer"&gt;bcd.snack-wrap.com&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;On the Mac, paste one line into Terminal:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://bcd.snack-wrap.com | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then click Approve in the browser tab it opens. The agent runs as a login service, so it reconnects after restarts and sleep. You don't keep a Terminal window open.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the connector:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT:&lt;/strong&gt; Settings, Apps, Advanced settings, turn on Developer mode, then create an app with the address &lt;code&gt;https://bcd.snack-wrap.com/mcp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude:&lt;/strong&gt; Settings, Connectors, Add custom connector, same address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code / Codex:&lt;/strong&gt; the installer offers to add it for you.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Things I actually use it for
&lt;/h2&gt;

&lt;p&gt;These are the prompts I send most, in plain words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Sort my Downloads folder by file type and tell me what moved."&lt;/li&gt;
&lt;li&gt;"Read the three meeting-notes PDFs on my Desktop and list the to-dos nobody closed."&lt;/li&gt;
&lt;li&gt;"Compare September and August sales in sales.xlsx by product, without changing the file."&lt;/li&gt;
&lt;li&gt;"Cut 0:20 to 0:32 of this video into a 9:16 clip with a title." (It edits on the Mac with AVFoundation, no ffmpeg needed.)&lt;/li&gt;
&lt;li&gt;"Build my iOS app and upload it to TestFlight." (Handy from the phone when you're away.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Long jobs keep running if the chat connection drops, and the AI can check the outcome of a call whose answer got lost instead of running it twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety model
&lt;/h2&gt;

&lt;p&gt;Giving an AI your computer deserves some care, so the defaults are conservative:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new computer starts &lt;strong&gt;read-only&lt;/strong&gt;. The AI can look but not change anything.&lt;/li&gt;
&lt;li&gt;Writing files, running commands, screen control and clipboard are separate switches you turn on per Mac in the dashboard.&lt;/li&gt;
&lt;li&gt;File changes the AI makes can be listed and undone.&lt;/li&gt;
&lt;li&gt;BCD doesn't store your file contents. When the AI reads a file, the content passes through the relay in memory to your AI app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mac only for now. Windows has a waitlist.&lt;/li&gt;
&lt;li&gt;You need an AI app that supports custom connectors (ChatGPT developer mode, Claude custom connectors, Claude Code, Codex).&lt;/li&gt;
&lt;li&gt;One Mac is free with no monthly tool-call quota. New accounts get the 3-Mac plan free for a month, no card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it, I'd like to hear what you wanted the AI to do on your computer and where it got stuck. Example prompts are here: &lt;a href="https://bcd.snack-wrap.com/prompts?utm_source=devto" rel="noopener noreferrer"&gt;bcd.snack-wrap.com/prompts&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>chatgpt</category>
      <category>macos</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
