This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass
Last week I built a tool that reads what my coding agents ran in the terminal. Between midnight and mid-afternoon of one working day, it counted 548 commands. This week's theme is Touch Grass, and that number made it personal.
I live in Niterói, across the bay from Rio. Here, the question the night before is "dá praia amanhã?": is tomorrow a beach day? The honest answer depends on things nobody checks at 10 pm: will the wind stay down until nine, does the rain chance climb after lunch, when is low tide, and is the sand wide and firm at the hour you can actually go.
dapraia answers that question in one line:
Dá praia amanhã (qua 07/10): Piratininga, das 6h às 9h. A maré baixa às 7h10 e o vento está bem fraco, então o céu encoberto não atrapalha nada.
Beach day tomorrow (Wed 07/10): Piratininga, 6 to 9 am. Low tide at 7:10 and the wind is very light, so the overcast sky won't get in the way.
You use the screen three times, and each time is short:
It is for anyone whose time outside depends on wind, rain and tide: footvolley and beach volleyball, a run on the hard sand, a swim, surf, a walk with the dog at low tide. I built it for the beach first because that is where I live.
Here is a real run, on a real forecast for three beaches in Niterói recorded on October 6, so anyone can reproduce it from the repo. --why shows the hours behind the pick:
$ dapraia --why
Dá praia amanhã (qua 07/10): Piratininga, das 6h às 9h. A maré baixa às 7h10 e o vento está bem fraco, então o céu encoberto não atrapalha nada.
Piratininga · amanhã (qua 07/10) · onda e maré do ponto do modelo a 4,5 km
hora vento km/h chuva % h da maré
6h ✓ 3 8 0,7 ◀
7h ✓ 4 5 0,3 ◀
8h ✓ 4 3 1,3 ◀
maré baixa às 7h10, maré cheia às 13h10, maré baixa às 19h30 (maré baixa: modelo +60 min, do seu plano)
Previsão: Open-Meteo.com (CC BY 4.0). Onda e maré vêm de um modelo com grade de ~8 km: servem pra planejar, não pra navegar.
Every hour from 6 to 9 passes: 3 to 4 km/h of wind against a limit of 15, a rain chance under 10%, and low tide at 7:10, inside the two hours either side that the plan allows. The sky is 95% cloud, and the sentence says so instead of calling it a great day; that took a fix of its own. The window ends at 9 because that is when the plan says the morning ends. The "+60 min" is a correction I measured against the Brazilian Navy's tide table, for low tides only; more on that below.
"Dá praia?" is what people in Rio and Niterói ask the night before: is tomorrow a beach day? dapraia answers it in one line. It picks the hour to go out from an open forecast and from how you like it, said in your own words, and a model running on your own machine explains the pick.
Dá praia amanhã (qua 07/10): Piratininga, das 6h às 9h. A maré baixa às 7h10 e o vento está bem fraco, então o céu encoberto não atrapalha nada
That is the whole screen time. You read one line in the evening (or get it as a push notification) and go.
The work is split. A model reads and writes words…
dapraia is Python with no dependencies of its own. The model is Gemma 4 12B, served by Ollama on 127.0.0.1. The forecast is Open-Meteo, and push notifications go through ntfy.
The design rule is the one I kept from last week: the model reads and writes words; plain code decides. Gemma never decides whether you go. It turns your words into rules, and it explains a decision the code already made. Both outputs are checked before you see them.
Reading how you like it. Gemma gets your sentence and a JSON schema (Ollama's structured outputs) and returns rules: a field, a value, and a quote. This is what it read from the sentence above:
Entendi assim:
atividade futevôlei ← "futevôlei"
a partir das 6h ← "de manhã"
até as 9h ← "cedo"
vento até 15 km/h ← "pouco vento"
chance de chuva até 20% ← "sem chuva"
maré baixa ← "maré baixa"
pelo menos 2h seguidas ← "Pelo menos 2 horas"
não sei medir: "pra areia ficar firme"
The quote is what makes the reading checkable. A rule is kept only if its quote is in what you wrote, word for word, inside one sentence. A number in the quote has to be the rule's value, converted from the unit written right after it: "vento até 12 km/h" can't become 15, "15 mph" becomes 24 km/h, "20%" can't be a wind limit. Then the check runs the other way: every number you wrote has to be the value of some rule, or sit in the list of things the tool can't measure. What fails goes back to Gemma once with the problems listed; what still fails is left out and shown to you. Nothing is saved until you say yes.
That list of things it can't measure became my favourite part. "Pra areia ficar firme" (so the sand is firm) has no field: firm sand is what low tide gives you, and low tide is already a rule. Gemma put it on the list instead of inventing a rule for it, and did the same in English with "with my dog".
Vague words are where the model earns its place. "Pouco vento" has no number, so Gemma picks one (15 km/h), and you see it next to the words it came from. Its early picks were wrong in useful ways: "de manhã cedo" came back as "from 8 am" with no end, and "no rain" as a 0% rain chance, which a Rio forecast almost never shows. I didn't patch those in code; I told the model what a person means. Then six sentences in two languages came back clean, in 2 to 6 seconds each on my laptop, one of them in feet, knots and Fahrenheit.
The seventh caught my own check. "Kitesurf com vento entre 15 e 25 nós" (wind between 15 and 25 knots) came back as 15 and 25 km/h, and the check let it through, because the unit sat three words after the first number. That is the worst kind of error, a plan that looks right and picks days with half the wind you wanted. Now the unit is read after the number or the end of its range, and the retry says "in km/h that is 28". Gemma fixes it on the retry, 28 and 46, three times out of three.
Picking the window. No model here. For every spot, pick.py checks every hour you asked for, in daylight and not already gone, against every rule, finds the stretches that pass for long enough, and ranks them by the room they leave inside your limits. Low and high tide come from the modelled sea level, with a parabola through three hourly readings, rounded to 10 minutes. An English test plan found a bug: "late afternoon" became 17:00 to 21:00, the sun sets at 17:53, and I required the whole hour in daylight, so nothing ever qualified. Now an hour counts when its middle is in daylight.
Explaining it. The headline (go or not, where, from when to when) is always written by code. Gemma writes only the second half, the why, and check_why reads it first: every number, time, date, day and name in it has to be in the facts the code gave it, a number with a unit needs the same unit there, and it has 160 characters at most. A failed sentence gets one retry, then you get the code's own sentence.
None of that sentence's real failures was a wrong number. The first version repeated the headline and mangled the tide into "a maré baixa dura até 2h antes ou depois das 6h10" (the low tide lasts until two hours before or after 6:10). The second copied my prompt: I had given it one example, and it reused the example's ending ("after the window, the tide comes in") on a day the window ended for another reason, then said the low tide fell "right in the middle" when it fell near the end, because my next example said so. A third called a 95% overcast morning "ótimo" (great). An example in a prompt is an instruction, and Gemma followed it. The fix was to stop asking the model for judgements the code can make: the code now writes where the tide falls ("perto do fim do horário"), how much room each condition leaves ("bem abaixo do limite"), and the cloud cover, and the check refuses sunny words under a grey sky. A check can stop a wrong fact. It can't stop a true-sounding phrase the facts never said, so the facts have to say more.
After the beach. dapraia fui ("I went") takes one sentence about how it went, compares it with the forecast for that day, and proposes changes, each quoting your words. For example:
$ dapraia fui "ventou bem mais do que dizia, umas rajadas chatas, e às 8h30 a areia já tava mole" --day 2026-10-07
Mudaria assim:
vento até 15 km/h → vento até 12 km/h ← "ventou bem mais do que dizia"
(nada) → rajadas até 14 km/h ← "umas rajadas chatas"
até 2h antes ou depois da maré (padrão) → até 1h antes ou depois da maré ← "às 8h30 a areia já tava mole"
(conta do código: 8h30 fica 1h20 depois da maré baixa das 7h10, então até 1h)
Aplicar? [s/N]
("Way windier than it said, some annoying gusts, and at 8:30 the sand was already soft.") Every pick is kept with all the numbers the forecast gave for those hours, so Gemma sees that the gusts that morning went from 8 to 14 km/h and proposes a limit near the top: 14 here, 13 or 15 on other runs. It also decides which rule the sand is about and whether the window should tighten. The arithmetic is the code's: 8:30 is an hour and twenty minutes after the 7:10 low tide, so the window becomes the last half hour before that, one hour. Nothing changes until you say yes.
The tide, checked against the Navy. Open-Meteo's documentation says its tides come from a model on a grid of about 8 km, with limited accuracy at the coast, not for navigation. I wanted to know how limited. The Brazilian Navy publishes official tide predictions for the port of Rio de Janeiro, at Ilha Fiscal, across the bay. For October 6 to 9, every one of the model's eight low tides came early, by 53 to 69 minutes, and the high tides 21 to 39 minutes early. The model's own point inside the bay was just as early for the lows, so it isn't the bay against the open sea.
For a tool built around "go at low tide", an hour is the whole window. It is also the good kind of error, steady, so one number fixes it for the tide your rule is about: with dapraia tide-shift 60, all eight lows land within 10 minutes of the Navy's table. The sixteen official times and the check that recomputes the gap ship in the repo and run with the tests.
Trying to break it. Before publishing, I ran an adversarial review whose only job was to break the code and the README. I ran it eleven times. The first found 13 problems, among them that fui compared your words with whichever forecast had run last, and that a date past the forecast came back as "não dá praia", as if the beach were closed. Most of the rest were about one thing: how people write the time. "Até 12 no fim de semana" (up to 12 on the weekend) became 22 km/h because I had taught the unit check that "no" might be "nó", a knot. "Umas 2h depois da maré" was read as two in the morning. "A partir das 7 e meia" was saved as 7:00, "entre 10 e 15 km/h" became a quarter past ten, and "às 8 horas e meia" lost its half hour because a regular expression tried "h" before "horas". Several of those holes were opened by my own fixes from the round before, and each one is a test now. In the end I stopped teaching that part to understand more spellings and made it refuse instead: the code only does the sum when the time is unambiguous, and anything else ("8.30", "8 e dez", "às 2h depois da maré": two o'clock, or two hours?) gets a note and leaves the number for you to judge.
The finding that changed the design came in round three. My direction check was a list of phrases, and when it misread one ("evito vento acima de 30 km/h", I avoid wind above 30), it rejected Gemma's correct maximum and told it why. Gemma did what it was told: on the retry it flipped the rule into a minimum, which the list liked, and the plan said the opposite of what the person wrote. A heuristic that talks back to the model doesn't just miss errors; it can manufacture them. So the lists in dapraia never argue with the model any more. They show you a note before you save, and you decide.
There are 344 tests, and none of them needs the model or the network. They cover every check against quotes and numbers that should and shouldn't pass, the picker on synthetic days (wind picking up at nine, rain all morning, short stretches, weekends only, daylight, low tide, hours already gone, time zones), a real Open-Meteo answer for three beaches, the tide claim against the Navy's table, the sentence check against sentences that invent numbers, times, dates, days and places, and the command line end to end with a fake model.
A plan for the beach is location data. "Piratininga, weekdays at 6 am" says where someone will be, on a schedule. dapraia sends those words to a model on my own laptop. The client only talks to Ollama on loopback unless I pass a flag, and it ignores proxy settings so the prompt can't take a detour. Open-Meteo gets the coordinates of the beach, because a forecast needs them. If I turn on push notifications, the daily line goes to the ntfy server I picked, and I can run that server myself.
It costs nothing to run every day. Open-Meteo needs no key and no account, and neither does ntfy. Gemma runs on hardware I already own. A tool you use once a day for years should not come with a bill or a key that expires.
Open also let me check the data instead of trusting it. Open-Meteo documents what its tide model can and can't do, the Navy publishes its tables, and comparing the two took one afternoon and sixteen numbers. That comparison is now a test in the repo. Open-Meteo is open source too (AGPL-3.0), with self-hosting documented, so the forecast doesn't have to come from someone else's server either.
And it is mine to change. --model swaps Gemma for anything Ollama serves, the plan is a JSON file I can edit, and the rules are one readable module. If you surf, you want waves above a metre where I want them flat; that is a sentence, not a code change.
Best Use of Gemma: Gemma 4 12B runs locally through Ollama. It reads the plan (with Ollama's structured outputs and a quote check on every rule), writes the daily why (under a fact check), and reads how the outing went to propose changes to the plan.