DEV Community

ADK BBX
ADK BBX Subscriber

Posted on AI-assisted

I asked my junk mail one question: are you worth the walk?

Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass Submission 🌿

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass

One week of flyers from my mailbox, fanned out in my hand: a drugstore price list on top, a cleaner's sale, a KUMON leaflet and a meal-delivery coupon behind it

This is one week of my mailbox. A drugstore price list with 52 products on it, a cleaner's half-price sale, a pilates studio's trial, KUMON, a meal-delivery coupon, gold buyers, junk removal. Most of it goes straight into paper recycling. A few of these are probably worth leaving the house for. I just never know which ones, because I don't read Japanese well enough to go through a stack like this every week.

So I built an app that reads them for me.

Five flyers laid out on a mat, each tagged with what the app decided: two worth a walk, two to use at home, one to recycle

Five of them, tagged with what the app decided. The pilates studio and the cleaner are worth a walk. The sushi menu and the meal kit are things to use at home. The gold buyer goes in the recycling.

What I Built

Worth the Walk? You photograph your flyers, one photo per page. Gemma 4 reads each page and the app sorts every flyer into one of four piles:

Pile What goes there What happens to it
Worth a walk a place you can go to: a cleaner's sale, a studio trial, a shop it becomes a stop on today's walk
Use at home delivery menus, coupons kept, with its deadline
Keep, not an ad anything that looks like a bill or a notice nothing from it is stored
Recycle gold buyers, junk removal, the rest paper recycling

The answer looks like a train ticket for today. The places worth a walk sit on a route line in walking order, starting from where you are or from a station you pick. Each one shows its best printed deal on a yellow sticker, the same way the flyers do it (Trial ¥1,000, was ¥3,300). The app picks the stops itself. If there's one you don't want, you remove it, and that choice (along with every stamp you collect) is what TabPFN learns from.

When you start the walk, the screen turns into the display above a train door: NEXT 次は, the place, a little line map of your stops, and how to use the deal. At each stop you tap I'm here. One GPS reading, checked on the phone, gives you an ink stamp like the ones at Japanese train stations, with a different ink for each kind of place. Tap what you actually used and the savings add up, but only from prices printed on the flyer. At the end the ticket comes back punched, with a 済 ("done") stamp and the day's totals.

Six phone screens from left to right: photograph the pile, Gemma 4 reads a real drugstore flyer, a ticket for today, walk mode, an ink stamp at a stop, and the finished walk

There's no account. Photos are read and thrown away, and everything else stays on your phone. You can add it to your home screen, and a walk you've started will reopen even with no signal.

Taking it outside

On Monday afternoon I put the drugstore flyer from that pile through the live app. It was the only place on the day's ticket: a Welcia three minutes away, 211 m. I switched to walk mode and went.

The app in walk mode at the drugstore: stop 1 of 1, 3 minutes, 211 m, and the stamp

The drugstore from the street, on a clear afternoon

The stamp is the dashed kind, the one the app gives when it stamps without a GPS reading.

It's a short walk, which is kind of the point. That flyer had been sitting in my mailbox all week. It took three minutes to go and look.

How it got here

My first plan for this challenge was a grocery app: 547 supermarket branches around 74 stations, which point cards each one takes, and when the bonus days are. The next step was going to be scanning supermarket flyers for deals. Then two things happened on Sunday.

First I tested the models. Gemma 4 E2B, running on my laptop, read a whole supermarket page and got 0 of 8 items right. It put real prices next to products that weren't on the page. Gemma 4 31B on DigitalOcean got a cropped section of the same page 4 out of 4, member and non-member prices included.

Then I emptied my mailbox. Fifteen flyers, and not one of them from a supermarket. Gold buyers, junk removal, a hot-yoga trial, an aikido dojo, sushi delivery, a cleaner's half-price sale. Supermarket flyers do come some weeks. Most weeks it looks like this.

So I rebuilt the app around the mailbox I actually have. Any flyer goes in, the app sorts it, and the places worth visiting become a walk. The grocery data stayed on as station locations and an optional grocery stop on the way.

The first phone test failed in a boring way. I took seven photos, tapped Read at the bottom of the screen, and nothing seemed to happen, because the progress line was at the top and off screen. I refreshed and lost every photo. That's why there's now a reading card with a progress bar, cards that appear as each flyer finishes, photos that wait on the phone until they've been read, and a screen that stays on while it works.

Then ten real photos went through, and the real problems showed up.

The flyers that fooled it

A logo isn't text. The sushi menu's back page only has the shop name as a brush-style logo. Gemma read it as two different restaurants on two runs, so the back page never joined its front.

One shop, two spellings. The cleaner's front page says "Good!" and the back says "クリーニング Good!". Pages now group when one name sits inside the other. On one run Gemma wrote the name in katakana instead and they split again, so now you can tap a page and pick which flyer it belongs to.

A phone number cut in half. A big 0120 number fell across two tiles, and each tile only read half of it.

The model copied my example. My prompt said "phones, e.g. 0120-123-456", and that exact number turned up on a gold buyer's flyer. It also broke the grouping, because it looked like another shop's phone. The prompt has no example digits now.

Almost-JSON. Gemma sometimes drops a quote before a key. The parse failed, the retry doubled the cost, and the second reading lost the comparison prices. The parser fixes that now before giving up.

Class times look like opening hours. A dojo prints "毎週土曜 10:00~12:00". At 4 pm it counted as closed and dropped out of every walk. Lessons and events never count as closed now.

The drugstore that had no prices. On Monday I photographed the drugstore flyer: 52 products, each with a price before and after tax. The app found the flyer and showed none of the prices. The first pass reads the whole page, and when a page is a dense price list it's told to skip the items and describe them instead. A rule in code then decides whether to read the page again in tiles, and that rule didn't recognise this page as a price list. Supermarket and drugstore flyers now always get the second pass, and the same photo comes back with all 52 products and their prices.

Here's how one real mailbox scored on Sunday evening:

Flyer Pages Business name Pile Offer
Dinner delivery 1 right right (home) right
Sushi delivery 2 front right, back wrong (logo) right (home) right
Dry cleaner 2 right right (walk) ¥297 → ¥98, ¥987 → ¥326
Pilates studio 1 right right (walk) ¥3,300 → ¥1,000
Gold buyer 2 right, pages grouped right (recycle) right
Junk removal 1 right right (recycle) right
Appliance pickup 1 right right (recycle) right

Every pile and offer was right. Business names were right apart from the logo.

Late on Sunday it worked, and I still wouldn't have used it. Every flyer was a card with every field on it, in four tabs, with thumbs up and down buttons. Looking at it on my phone, I wouldn't have read any of it. So it became one answer: the app picks the stops, draws them as a ticket, puts the best printed price on a sticker, and all you do is remove what you don't want. Then you walk.

Early on Monday I opened it like someone who had never seen it, and it was a station picker and a camera button with no explanation. Now the first screen shows what happens, it only asks where you're starting when it needs to, and Try a sample mailbox runs the whole thing with eight made-up flyers, so you can try it without any Japanese mail.

How it works

Architecture: the phone, a Node server on Render running the Mastra workflows, and the models and services around it, with the open pieces marked

Reading flyers: whole pages first, tiles only for price lists (Gemma 4 + Mastra)
Model Input Result
Gemma 4 E2B (local, Ollama) full page 0/8
Gemma 4 E2B one section 2/4 (若鶏もも肉 became "sheep wool meat")
Gemma 4 31B (DigitalOcean) full page wrong prices, invented eggs
Gemma 4 31B one section 4/4, both prices

Tiles fixed the accuracy, but six calls per page made the first real test take minutes. Most of my mailbox isn't price lists, though. A gold buyer, a junk-removal service or a yoga studio is big text, a phone number and one offer, and read whole, all four of those came back right in one call each (4 to 26 s).

So reading is adaptive. The phone crops each photo to the flyer and sends the whole page first. A Mastra readPage workflow reads it and the card shows up within seconds. A rule in code then decides whether the page is a price list worth a closer look (a supermarket, a drugstore, a cleaner's price table, a takeout menu). Only those get a second pass as a 2×3 grid of overlapping tiles, read in parallel and streamed back while the card says "Reading prices…".

A text-only mergeFlyer call combines the readings from every page of one flyer, since the offer is often on the front and the address on the back. Price lists are merged in code, which took merging from 232 s to 87 s on my test pile. Then I timed the whole thing through the real app and found the merge was still the slow part: five single-page flyers took four and a half minutes, because each one waited for a merge call that had nothing to merge. A flyer that was read whole in one call is now sorted in code. The same five pages now go from photos to a ticket in 38 seconds, into the same piles.

The model only copies what's printed. Code decides the pile, which pages belong together, the deadline, and what counts as a saving.


Finding the places: GSI, SerpApi and OpenStreetMap

Printed addresses go to GSI (国土地理院), Japan's free government geocoder, which needs no API key. When a flyer worth a walk has no usable address, SerpApi looks the business up on Google Maps. If Google's phone number matches the one on the flyer, the card says "phone matches the flyer". If SerpApi finds nothing, the app tries OpenStreetMap by name.

The free SerpApi plan has 250 searches a month, so only walkable flyers without an address can trigger a search, results are cached for 30 days, every search is counted before it's made with daily and monthly caps and a reserve of 20, and the tests run against a network that throws, so a test can't spend one.

SerpApi dashboard: 10 searches out of 250 this month

SerpApi after the whole build: 10 searches out of 250, none failed, and the busiest day stopped at 8, the app's own daily cap.

Learning what you go to: TabPFN through Prior Labs' MCP

The places you remove and the stamps you collect train TabPFN through Prior Labs' remote MCP server. Mastra's MCP client does upload, fit and predict, and reuses the fit until your choices change. TabPFN only sees a row of numbers per flyer: category, a distance bucket, offer type, whether it's a free trial, printed value, days left and three yes/no flags. No names, addresses or flyer text. On a synthetic check it scored a nearby free trial at 0.9996 and a far-away flyer with no offer at 0.0004, in 11.6 s. With fewer than six choices, or if anything fails, simple rules take over.

Watching the agent: Sentry traces with no flyer content

The Mastra Sentry exporter keeps the model, tokens, cost and timing, and drops prompts, outputs, images and any URL with a query string, because these are photos of someone's mail. Sentry's cost numbers put one tile at about $0.00044, so reading a page costs about a quarter of a cent.

A readPage trace in Sentry: eight Gemma 4 calls for one page, with tokens and cost, and no input stored

One readPage trace in Sentry: eight Gemma 4 calls for one drugstore page, mostly in parallel, 29.8 s in all. Every pink chat gemma-4-31B-it bar is one call to Gemma 4 31B on DigitalOcean's serverless inference. The selected span shows the agent, the model, 1.7K tokens in and 497 out, a cost under a cent, and "No input for this span". The flyer itself never reaches Sentry.

Sentry's AI Agents view: three agents, tokens and cost per run, and merges that cost nothing

Sentry's AI Agents view: the page reader, tile reader and merger, every run's tokens and cost, and no errors. The mergeFlyer rows with 0 tokens and $0 are flyers sorted in code instead of by the model.

A mergeFlyer trace: the workflow, its merge-and-locate step, the flyer-merger agent and its Gemma 4 call

The Mastra structure as Sentry records it: the mergeFlyer workflow, its merge-and-locate step, the flyer-merger agent and its Gemma 4 call. 1.2K tokens, under a cent, 2.89 s against this agent's 23.6 s average, and again no input stored.

Walks, opening hours and stamps

The station data came from the grocery app that didn't work out. Stops are ordered by trying every order, and the lowest-priority stop is dropped until the walk fits your time. The hours parser handles 83 store formats: 25:00, 〜 and -, full-width digits, 平日 vs 土日祝, "closes at 21:00 on weekends", public holidays. A stamp is one GPS reading, compared on the phone, and the location never leaves it. If you start from where you are, the server only ever gets that start rounded to about 1 km, for address lookups.

Running it: Render

One Node web service from a render.yaml Blueprint. Keys are kept in the dashboard, not the repo. Each IP gets hourly limits on reading, merging, lookups and ranking, and X-Forwarded-For is only trusted behind Render's proxy, so the limits can't be dodged with a fake header.

Render environment variables, every value masked

Render: the Blueprint-managed worth-the-walk service and its auto-deploys

Render: one Blueprint-managed Node service on the Starter plan. Every push deployed in about a minute, 30 times over the build.

Why Does Open Innovation Matter?

Not everything in this app is open, so it's worth being clear about which parts are. The open pieces do the core work. Gemma 4 (open weights, Apache 2.0) reads every flyer and merges the multi-page ones. Mastra (Apache 2.0) runs every workflow and agent. The services around them are closed, and each one can be switched off.

Piece What it does Open? Without it
Gemma 4 31B / E2B reads, translates and merges every flyer open weights, Apache 2.0 no app
Mastra workflows, agents, the MCP client, the trace exporter Apache 2.0 no app
DigitalOcean hosts Gemma 4 31B hosted service, open model LOCAL_LLM=1 runs Gemma 4 E2B on my laptop with Ollama
TabPFN via Prior Labs' MCP ranks flyers from my choices and stamps open-source code, hosted API simple ranking rules
SerpApi finds places that print no address hosted service OpenStreetMap
OpenStreetMap (Nominatim) finds a shop by name when nothing else did open data, free, no key printed addresses only
GSI address search turns printed addresses into map points free public service not needed
Sentry traces every model call hosted service no tracing
Render hosts the web app hosted service any Node host, or npm start

I tested that last column on Monday morning with the finished app. With every key removed and LOCAL_LLM=1, three real flyers from my mailbox went from photos to a ticket in 35 seconds on my laptop. GSI placed a printed address, OpenStreetMap found a chain store by name, and the ranking ran on rules. It cost nothing and needed no account. The only things that left my machine were an address for GSI and a shop name for OpenStreetMap.

Here's what being open actually changed for me.

I get to choose where my mail is read. Hosted, the photos go to DigitalOcean to be read and the app never stores them. With LOCAL_LLM=1 they never leave my laptop. A closed API doesn't give you that option.

I could test the models on my own flyers before trusting them. That 0 out of 8 against 4 out of 4 decided the whole design: price lists are read in tiles, everything else in one call.

It's cheap enough that I didn't need accounts. A fully tiled page costs about a quarter of a cent and a whole mailbox about five cents, so there's no sign-up and no subscription.

The local version works, but it's worse. E2B put two of those three flyers in the right pile (it sent a dinner-delivery menu to recycling) and made up the yoga studio's prices: ¥500, ¥150 and ¥100 for a flyer that prints a ¥3,000 trial. So local mode tells you to check every price. Running it at home is a real option, not a good one yet, and a bigger Gemma 4 is one setting away (LOCAL_LLM_MODEL) if your machine can hold it. I only know all this because the weights are open.

The code is MIT licensed.

Demo

Trying the sample mailbox: a ticket of four made-up places, walk mode, an ink stamp at each stop, and the finished ticket

worth-the-walk.onrender.com, no sign-up. If you don't have any Japanese flyers, tap Try a sample mailbox. It places made-up shops near you so you can see the ticket, walk mode and a stamp in under a minute.

Code

Worth the Walk?

Photograph the flyers in your mailbox, read what each one is in English, and plan a walk from where you are (or a station you pick) to the places worth visiting. Each stop gives you an ink stamp.

No Japanese flyers to hand? Open the app and tap Try a sample mailbox: eight made-up flyers, placed around your start, with no model calls.

Install and offline: Worth the Walk? is installable on your phone. After an online visit, the app shell is cached network-first so a walk in progress reopens without signal, with your saved walk and stamps intact. Reading flyers still needs a connection.

How it works

  1. Photograph the pages. Gemma 4 reads each page whole first (one call), so its card appears within seconds. Only walkable price lists (supermarkets, drugstores, cleaners, menus) get a second pass in six overlapping tiles for exact prices.
  2. The…

MIT licensed and built during the challenge week. There are 147 offline tests and a Playwright run at phone and desktop size, in light and dark. The Playwright run covers scanning, sorting, ranking, planning, both kinds of stamp, savings, a reload in the middle of reading, a second browser that sees none of the first one's data, going offline mid-walk, a rate-limited read, and contrast, focus and 200% text checks.

How I built it with two agents

Claude Code was the architect. It wrote the specs and the contracts between modules, reviewed every diff, ran the tests and checked the screenshots. Codex CLI wrote most of the modules from those specs. On the last night I left them running in a loop for five hours: Claude wrote a spec per round (onboarding, visual polish, the walk, error states, accessibility, offline, speed), Codex built it, and Claude ran the browser tests, looked at the screenshots and sent back what was wrong. When Codex hit its usage limit around 6:30 a.m., Claude carried on with the smaller fixes.

Prize Categories

Best Use of Sentry Agent Tracing. Every page read and every merge is one trace: the readPage workflow, the read-tiles step, the flyer-reader agent and its Gemma 4 call, with model, tokens, cost and timing. Since these are photos of people's mail, a span processor (omit-flyer-payloads) drops every prompt, output, image and query-string URL before export, and an allow-list keeps only attributes I added on purpose: page and tile counts, whether a flyer was sorted in code or by the model (chirashi.merge), its pile, and where its location came from (gsi, serpapi or osm). The cost per tile in this post comes from Sentry's numbers. The three Sentry screenshots above show it running.

Best Use of Mastra. Mastra runs the reading pipeline. The readPage workflow streams tiles back while the card says "Reading prices…", and the mergeFlyer workflow merges, sorts and locates a flyer in one step. Three agents (page reader, tile reader, merger) share one prompt schema, Mastra's MCP client talks to TabPFN, and its Sentry exporter produces the traces above. When a flyer was read whole in one call, the workflow skips the merger agent and sorts it in code, which is what took five real flyers from four and a half minutes to 38 seconds.

Best Use of SerpApi. Only flyers worth a walk that print no address get looked up, by business name near your start (rounded to about 1 km). If Google's phone number matches the flyer's, the card says so, which is also a decent way to tell a real shop from a door-to-door buyer. On the free plan every search is counted before it's made, with daily and monthly caps, a reserve, a 30-day cache, and tests that can't spend a search. The whole build used 10 of 250. When SerpApi finds nothing, OpenStreetMap is the fallback.

Best Use of TabPFN. The places you remove and the stamps you collect are the labels, so there's nothing extra to rate. Through Prior Labs' remote MCP, TabPFN ranks the mailbox from nine numbers per flyer, never a name, address or flyer text, and reuses the fit until your choices change. I've only checked it on synthetic rows so far (0.9996 for a nearby free trial, 0.0004 for a far flyer with no offer, in 11.6 s), and with fewer than six choices simple rules take over.

Best Use of Gemma. Gemma 4 31B reads Japanese flyers into English and structured prices (all 52 products on one drugstore page), and Gemma 4 E2B runs the same app on a laptop with no keys.

Best Use of DigitalOcean. Every flyer the hosted app reads goes to Gemma 4 31B (gemma-4-31B-it) on DigitalOcean's serverless inference, through its OpenAI-compatible endpoint: every page read, tile read and model merge. Mastra uses it like any other provider, and LOCAL_LLM=1 swaps in Ollama with no code changes. DigitalOcean is also where I ran the test that shaped the app: 31B there against E2B on my laptop, on the same pages. On my 10-page pile a whole page comes back in 4 to 30 s (about 7 s typical), and Sentry's cost numbers put a tile at about $0.00044, so a whole mailbox costs about five cents. That price is why the app has no sign-up.

DigitalOcean serverless inference dashboard for the last 7 days: one model, gemma-4-31B-it, with input and output tokens and token usage over time

DigitalOcean's serverless inference dashboard: one model, gemma-4-31B-it. The climb on the right, 11 and 12 October, is this app being built and tested on real flyers.

Best Use of Render. One Node service from a Blueprint, with secrets in the dashboard, per-IP limits that respect the proxy, compression and caching, a service worker so a walk reopens without signal, and a deploy on every push.

Built solo.

If your mailbox looks like mine, photograph it and tell me which flyer it got wrong.

Top comments (0)