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

What I Built

Should I go out? is a web app that answers one question and then tells you to put your phone away.

You tap once. The app checks the weather, the air quality, and the parks around you, and Gemma, running on your own computer, suggests one outdoor plan that fits the time you have: a park or landmark, a round-trip route, a few things to do there, and what to wear. Then the screen tells you to put your phone in your pocket.

Apps that send you outside usually still want your attention, with feeds, streaks, and leaderboards. I wanted the screen to be the shortest part of the trip. You get one plan, and "Another place" if you don't like it. Goals and badges only move when you check in at the park with "I'm here", and the check-in compares your location in the browser without sending it anywhere.

On the first visit, four quick questions ask how you like to move, what you enjoy, who comes along, and whether you ride public bikes, or you can let the AI decide. Each suggestion also comes with a music video of about 30 seconds that flies over the real streets to the park in 3D. Once the app has loaded on your phone, check-ins work with no signal. It speaks English and Korean, because I live in Seoul.

It's for anyone who opens their phone on a free Saturday to find something to do and is still scrolling at sunset, and for parents who want a playground 20 minutes away on foot.

Demo

The model runs on your own Mac, so there's no public deployment. Here's the whole flow, from the questionnaire to the check-in:

Answering the questionnaire, getting a walk to a park in Seoul, trying another place, opening the route map, switching the outfit, and checking in with

And the walk preview it makes for every suggestion:

The walk preview: a 3D map of central Seoul, a location puck walking the route past 3D buildings, photos of the park with things to do, and an ending card with when to leave and be back

I took it outside

I'm a total homebody and rarely go out. While building the app, I tested it on my phone. It suggested 까치 어린이공원, a small park near home that I didn't even know was there.

The suggestion for 까치 어린이공원, the weather panel, and the walking route on Kakao Map, cropped so the start isn't shown

I walked over, checked in, and got my first badge. Going somewhere because of an app I built myself felt refreshing.

The playground with a tube slide, the park's opening hours sign, and the check-in card with the First steps badge

One thing was off. After I checked in, it suggested "a quick game of soccer at the sports field", but the park has no sports field. The app had counted a field just outside the park as part of it, so I had the agent fix that in the afternoon: only what's inside a park's outline counts now (83df1d5). Using it on my phone had also turned up two bugs earlier, a "Walk there" button that didn't respond and a start point shown as a plus code. Session 6 below covers both.

Code

GitHub logo scs0209 / touch-grass-agent

Should I go out? One tap, one outdoor plan from a local Gemma, then put your phone away.

Should I go out?

Should I go out? A Gemma running on my own computer checks the weather, the air, and the parks nearby, and picks one walk, then tells you to put your phone away. Three numbered phone screens in a row: 01 the pick, a suggested walk to 서소문근린공원; 02 the route, the map to Cheonggyecheon Stream Park; 03 you made it, the check-in at Tapgol Park

One tap, one suggestion, then put your phone away.

It checks the weather, the air, and the parks near you, and Gemma, running on your own computer, suggests one outdoor plan that fits the time you have. Then it tells you to put your phone away.

  • One plan, not a feed. A park or landmark, a few things to do there, a round-trip route that fits your time, and what to wear.
  • Made for you. Four quick questions on your first visit, or let the AI decide. Don't like the place? Tap "Another place".
  • Counts only when you go. "I'm here" checks you in at the place. Goals and badges move only when you actually go somewhere; there are no points and no leaderboard.
  • Preview your walk. A music video of about 30 seconds that flies over the real streets to the park, to save or…

It's a pnpm monorepo with apps/server (Hono and Mastra on Node) and apps/web (Vite and React). The README has a short setup, and AGENTS.md lets a coding agent set everything up for you, including pulling the model.

How I Built It

Piece What it does here
Gemma 3 4B (open weights) Picks the place, writes the reason and the things to do, picks the outfit, and translates the answer into Korean
Ollama (local inference) Runs Gemma on my Mac through its OpenAI-compatible endpoint
Mastra (open-source agent framework) The agent, and a four-step workflow around it
OpenStreetMap data via Nominatim, Overpass, and OSRM Parks and landmarks, what each park has (playground, water, viewpoint), and real walking and cycling routes
Open-Meteo Weather and air quality
MapLibre GL JS and OpenFreeMap The 3D flyover in the walk preview

All of these are free and need no API key.

What the server does and what Gemma does

My first version gave Gemma the parks and asked it to plan. It made up walking times, and the card disagreed with the map. Now the server handles everything that can be measured, and Gemma handles the parts that need judgment. Each request is a four-step Mastra workflow:

  1. Gather conditions from Open-Meteo, Nominatim, and Overpass, and measure the real round trip to every park with one OSRM request. Parks that don't fit your time are dropped.
  2. Ask Gemma. It sees only the parks that fit, each with its roundTripMin and features, and answers in JSON: one park by id, a reason, things to do, and an outfit from a fixed catalog. It can't invent coordinates or clothes.
  3. Check the answer. zod validates the shape. Rules drop things to do that name a facility the park doesn't have, and reasons that don't match the weather. If you said "with kids" and Gemma picked a park with no playground while another candidate has one, the server switches parks. If Gemma fails twice, simple rules answer instead.
  4. Plan the route. Step 1 already measured it, so it comes from the cache.

Korean answers come from a second Gemma call that translates the checked English answer, with place names masked so they come back unchanged.

Tracing with Sentry

With SENTRY_DSN set, each request is one Sentry trace: the four steps, the agent run, every Gemma call with its tokens, and every outgoing API request.

Sentry trace of one recommendation: the OSRM round-trip request runs during gather-conditions, which stops waiting for the slow Overpass request after 3 seconds

The public Overpass server is often slow, so the app waits at most 3 seconds for it and caches park features for a day. In this trace the route step takes 1 ms, because the route was measured during that wait.

Most of the wait is Gemma. Three requests on an M3 Pro MacBook, with the model already loaded:

Input tokens Output tokens Time
Gemma picks the place and writes the plan 2,151 to 2,592 218 to 236 9.0 to 9.7 s
Gemma translates the answer into Korean 523 73 3.2 s
The whole request 12.6 to 15.2 s

That's 75 to 80% of every request, and it costs $0. When Gemma returns invalid JSON, the retry shows up in the same step:

Sentry trace where ask-gemma calls the agent twice: the first answer was invalid, the second one passed

Why Does Open Innovation Matter?

This app knows where you are, when you go out, who you go with, and which parks you visit. With a closed model API, all of that would sit in someone else's logs. Here the prompt never leaves my Mac. The map and weather services get only the coordinates they need, Kakao Map gets the start and the destination when you tap directions in Korea, and check-ins stay in the browser.

Because it's free to run, I kept calls I'd probably have cut with a paid API: "Another place" asks Gemma again, every answer gets a second call for Korean, and broken JSON gets a retry.

The small local model shaped the design too: measured routes, a fixed outfit catalog, rule checks, and a fallback. Swapping models is one environment variable (OLLAMA_MODEL), and the same checks apply to any open model. There's no sign-up and no API key. Clone it, pull the model, and it works anywhere OpenStreetMap, Open-Meteo, and OSRM cover. The catch is that the Mac has to be awake, and the first answer takes 10 to 20 seconds while Gemma loads.

My Agent Session

I built this with Cursor agents, and Entire recorded the sessions and linked each one to its commit: 104 of the 126 commits carry an Entire-Checkpoint: trailer. The records are in a private repository because they also include my work setup.

DEV's parser doesn't read Cursor transcripts yet, so I converted the sessions to the Claude Code format, which is why they show a Claude Code icon. Sessions 1 to 5 come from Entire's records and session 6 from the Cursor chat. Cursor doesn't keep tool results, so you'll see my prompts, the agent's replies, and the commands it ran. My prompts were in Korean, so I added English translations, and I redacted local paths and account names.

1. "The card and the route show different times. Fix that."

The card and the map disagreed on the walk because Gemma was estimating the minutes. The agent moved route measurement in front of Gemma: OSRM measures every candidate's round trip, parks that don't fit are dropped, and Gemma gets each park's roundTripMin. After this I stopped asking a 4B model for numbers I could measure.

Commit: b94354b "keep only parks whose round trip fits"

Claude Code Session
You

카드랑 경로 시간이 다른건 수정해야지

[Translation] The card and the route show different times. Fix that.

Agent

I'll look at how routes are prefetched and how the plan-route step picks the route, then make the server pick a park whose real round trip fits the time you chose.

Looking at where durationMin gets computed, the mismatch happens when the route time exceeds available time. The fix should swap in a park whose prefetched round trip actually fits the available window, preferring one with matching features, and update the activity name and regenerate accordingly.

If no park fits, I'll keep the closest one but show its real round trip time honestly rather than hiding the mismatch. I want to check the route-fetching and condition logic before committing to this.

Show more
▸ Bash cd ~/touch-grass-agent/apps/server/src && ls conditions && cat conditions/rou...
$ cd ~/touch-grass-agent/apps/server/src && ls conditions && cat conditions/route*.ts 2>/dev/null | head -150; cat workflow.ts
Agent

I want to check places.ts to see how the walking range is calculated, along with the preferredPark and matchScore logic, and whether the Park type includes a distanceMeters field.

▸ Bash cd ~/touch-grass-agent/apps/server/src && rg -n "availableMinutes|walkingRadi...
$ cd ~/touch-grass-agent/apps/server/src && rg -n "availableMinutes|walkingRadius|radius|METERS|speed|distanceMeters" conditions/places.ts recommend.ts geo.ts | head -40; rg -n "function preferredPark|function matchScore|function wantedFeatures" -A 14 recommend.ts
Agent
▸ Read ~/touch-grass-agent/apps/server/src/conditions/places.ts
~/touch-grass-agent/apps/server/src/conditions/places.ts
▸ Read ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
Agent

I noticed I already had a PLACE_WORDS constant separate from PARK_WORDS—different purposes, so I'll leave them as is for now. The real issue is that candidate parks get filtered by straight-line distance with an assumed 1.3x detour factor, but actual routes can be much longer than that estimate; since routes are already being prefetched in parallel with Gemma, I could use the real route data for the decision without adding delay.

I'm thinking the cleanest approach is to await the route round trips in getConditions and filter out parks whose round trip exceeds availableMinutes before passing anything to Gemma — that way Gemma's description always matches reality, with no post-hoc mismatches. If I run the route fetch in parallel with the features fetch (which already waits up to 3s), the added latency should be close to zero. The remaining question is what to do if OSRM fails or if no parks fit within the time budget — fall back to straight-line filtering, or keep the closest park anyway and let the card show it exceeds available time.

That way the preview and the walk scene stay consistent, both showing the honest route time. I'm also considering attaching the round-trip minutes to each park object so the model gets informed context, and sketching a helper that fetches or caches round-trip walks per origin-destination pair, filling in missing entries before returning them all.

This makes the separate prefetch step somewhat redundant since routes get fetched during getConditions anyway — though I need to be careful about the unsafe-weather case where prefetching is normally skipped. Simplest approach is to just always fetch routes in parallel with weather/air data since it's a single cheap OSRM request either way, and then update the README and docs that reference the old prefetch timing behavior.

I'll keep the Sentry screenshot but reword its caption to something generic and still accurate, since I can't regenerate the actual trace. For timeout handling, routes should have a bound similar to the feature fetch so total latency stays close to the slower of the two, and if routes time out the park objects simply carry a null round-trip time, which buildResponse can display honestly — I need to check how the Gemma prompt in agent.ts handles that null field.

Since straight-line distance underestimates real walking routes, I'll fetch actual round-trip times and park features in parallel, filter out parks that don't actually fit before Gemma picks, and ensure the card reflects the real route. First, I'll check how the prompt currently describes parks.

Show more
▸ Bash cd ~/touch-grass-agent/apps/server/src && rg -n "distanceMeters|nearbyParks|f...
$ cd ~/touch-grass-agent/apps/server/src && rg -n "distanceMeters|nearbyParks|features|durationMin|availableMinutes" agent.ts; rg -n "prefetch" -r '' . ; rg -n "Park\b|nearbyParks" --type ts . | rg -v "recommend.ts|agent.ts" | head
Agent
▸ Read ~/touch-grass-agent/apps/server/src/agent.ts
~/touch-grass-agent/apps/server/src/agent.ts
Agent

I should add a roundTripMin field to the park data and update the prompt to clarify that durationMin must be at least the measured round-trip walking time when known, keeping the change minimal since the server already enforces a max route constraint. Now I'm updating route.ts to replace the prefetch function for round-trip walks.

Writing the batch fetch logic...

Actually I want to simplify so a cache miss falls back to a direct per-destination fetch rather than relying on remember's returned array, since a rejected cached promise might linger before its cleanup runs and cause duplicate refetch attempts on failure—better to just treat failures as null results in getConditions rather than retrying individually. I'm now updating getConditions in recommend.ts to handle the nearby parks lookup with a safe fallback.

Writing the park details helper, pulling features and round-trip routes in parallel with Overpass/OSRM, then filtering out parks whose measured round trip exceeds the available time, with a timeout wrapper so a slow external call doesn't block the whole plan.

I'm spotting a logical gap: if every park gets filtered out by the round-trip check, the user just gets a generic "walk around your neighborhood" suggestion, which feels honest but maybe too easy to trigger. There's also an edge case where the "worth the walk" distance filter discards closer parks before the route check, but those closer parks might actually have fit if the farther ones got dropped for exceeding time -- I should move that distance-based filtering to happen after the route filtering instead.

Weighing whether to overhaul getNearbyParks to handle this properly versus accepting the rare edge case and moving on. I decide it's not worth the added complexity for an infrequent scenario, so I'll leave the current ordering and just make sure buildResponse correctly takes the max of the recommended duration and the actual round-trip route time.

I also confirm removing the old prefetch functions since routing now happens inside the gather-conditions step, and note that fetching routes even when the weather is unsafe to go outside causes one wasted OSRM call -- acceptable tradeoff rather than adding sequencing delay. Time to write the actual code now.

I'll measure the actual round-trip travel time to each park before passing data to Gemma, so the "reachable within time" guarantee in the prompt is backed by real numbers rather than assumed.

Show more
▸ Edit ~/touch-grass-agent/apps/server/src/conditions/route.ts
~/touch-grass-agent/apps/server/src/conditions/route.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Read ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
Agent
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Read ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
Agent

I'm updating the duration calculation to take the max of the recommendation's duration and the route's duration when a route exists, then checking whether availableMinutes is still needed elsewhere in the function before removing it.

▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Bash cd ~/touch-grass-agent/apps/server/src && rg -n "availableMinutes" recommend....
$ cd ~/touch-grass-agent/apps/server/src && rg -n "availableMinutes" recommend.ts | sed -n '1,40p' | tail -8; rg -n "prefetchRoutes|Routes" workflow.ts
Agent
▸ Edit ~/touch-grass-agent/apps/server/src/workflow.ts
~/touch-grass-agent/apps/server/src/workflow.ts
▸ Edit ~/touch-grass-agent/apps/server/src/workflow.ts
~/touch-grass-agent/apps/server/src/workflow.ts
▸ Edit ~/touch-grass-agent/apps/server/src/workflow.ts
~/touch-grass-agent/apps/server/src/workflow.ts
▸ Edit ~/touch-grass-agent/apps/server/src/agent.ts
~/touch-grass-agent/apps/server/src/agent.ts
Agent

I should also check the forbidden-terms rule about field names like distanceMeters and windKmh, and make sure roundTripMin gets added to that list too.

▸ Read ~/touch-grass-agent/apps/server/src/agent.ts
~/touch-grass-agent/apps/server/src/agent.ts
Agent
▸ Edit ~/touch-grass-agent/apps/server/src/agent.ts
~/touch-grass-agent/apps/server/src/agent.ts
▸ Bash cd ~/touch-grass-agent && pnpm typecheck 2>&1 | rg -v "^$" | tail -8
$ cd ~/touch-grass-agent && pnpm typecheck 2>&1 | rg -v "^$" | tail -8
Agent

Typecheck passes. Now calling getConditions directly for 15, 30, and 60 minutes at two locations, to see the measured round trips and which parks are dropped:

Since the typecheck passed, other places constructing Park objects should be fine. Now I want to test getConditions directly at 15, 30, and 60 minutes for Seoul and a few other locations, printing out parks with distance and round-trip time along with how many got dropped, before moving on to real requests.

▸ Write /tmp/roundtrip-test.mts
/tmp/roundtrip-test.mts
▸ Bash cd ~/touch-grass-agent/apps/server && npx tsx /tmp/roundtrip-test.mts 2>&1 | ...
$ cd ~/touch-grass-agent/apps/server && npx tsx /tmp/roundtrip-test.mts 2>&1 | rg -v '^\s*$'
Agent

Ah, that park actually is in the list — the mismatch was a curly apostrophe versus a straight one in the name, which is why my matching failed. My unlisted-park fallback worked around it, but the real fix is to normalize apostrophes when comparing names.

Checking the 30/15 min filters for Seoul City Hall, the drop counts look right, and the Overpass timeout delays are pre-existing and unaffected by the gather change. I want to patch the sanitize logic to normalize curly and straight apostrophes before matching the activity text against park names, and make sure the Hangul replacement uses the normalized activity string too — let me look at that code section to apply the fix.

Filtering is working as expected, and I've traced the earlier "unlisted park" issue to a curly vs. straight apostrophe mismatch in "Postal Administration Citizen's Park." I'll now fix the name matching logic to ignore apostrophe style differences.

Show more
▸ Read ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
Agent

I'm normalizing curly quotes to straight ones in the activity text so it matches consistently between the named park and stray Hangul checks, using that normalized version for the output title. I also need to handle the case where the model picks a park that got filtered out for being too far.

▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts
~/touch-grass-agent/apps/server/src/recommend.ts
Agent
▸ Grep PARK_WORDS.test ~/touch-grass-agent/apps/server/src/recommend.ts
PARK_WORDS.test ~/touch-grass-agent/apps/server/src/recommend.ts
Agent

That replace didn't go through even though the string looked identical—maybe the file changed underneath me. I'll retry with a smaller, more targeted string.

▸ Edit ~/touch-grass-agent/apps/server/src/recommend.ts

Related posts