This is a submission for the Hacktoberfest Open-Source AI Challenge: Touch Grass
What I Built
In Lagos the weather app on your phone says "60% rain" for the whole day, and then it shines until 4pm and pours at 5. So you either stay in all day, or you go out and get soaked.
Go Out Today? is a morning brief that answers one question: when is the best time today to be outside for the thing I want to do? You pick an activity (walk, run, football, market, drying clothes, commute, picnic, hangout), press one big play button, and a voice tells you something like:
"The best time for a picnic is between 3 pm and 6 pm with at most 29% chance of rain. Outside this window, rain chances peak at 54% around 12 pm."
Then you close the laptop and go. That is the whole idea for "Touch Grass": the screen should be the shortest part. At 05:45 a scheduled job builds the brief, so at 6am it plays straight away.
How it works:
- TabPFN looks at three years of hourly Lagos weather and forecasts the rain chance and temperature for each of the next 12 hours.
- Code works out the facts: the best window, the window to avoid, any heat.
- A local Qwen model through Ollama turns those facts into two or three plain sentences.
- ElevenLabs reads it out loud, with captions that light up word by word.
TabPFN and the model run on my laptop: an Intel UHD 620, 16 GB of RAM, no GPU.
It's for anyone who wants to get off the screen and go touch grass today, whether that's a walk, a run, a picnic, a hangout with friends or football with the guys. You don't need to read a weather chart or guess what "60% rain" means. Press play, listen for a few seconds, and it tells you the best time to go out and the time to avoid. Then you close the laptop and go.
I built it for me first. I'm the one who stays in all day because the app said 60%.
Demo
The cold-start part of the video is sped up (it says so on screen). The voice parts are real time, sound on.
The sky follows the real time of day: orange at sunrise, blue in the day, dark with a moon and stars at night. The character on the grass changes with the activity, so dry clothes gets a clothesline and picnic gets a blanket.
Taking it outside
Saturday 10 October. A friend was celebrating his birthday, so I had a party to get to. In the morning I picked Hangout and pressed play:
"The best time to hang out today is from 4 pm to 6 pm, with at most a 29% chance of rain."
It also said there were no windows to avoid, so the rest of the day wasn't a write-off either. I went to the party, and the 4–6 pm window held: no rain. I filmed the brief on my laptop outside, then pressed "I went outside ✓", typed one line and logged it. That log stays in a file on my laptop. It's the only "score" the app keeps: how often I actually went.
Here it is in real use: four activities I filmed on my phone, ending with logging the outing.
One good day isn't proof. Sep 30 (below) was a bad day for it. But on Saturday, it was the reason I left the house in the right two hours instead of guessing.
What my colleague said
Nobody else has used it yet, but I showed it to my colleague at work. She liked the idea of hearing the day before you've even properly got out of bed: you know the forecast and when you can go out, without opening an app.
Then she found the real feature. If you don't feel like going out and Go Out Today? puts the rain right in the window you were planning to go, that's a perfect excuse. I hadn't designed for that, but it's honest: the brief works in both directions.
Code
G00dS0ul-Hackerton / w1-go-out-today
A local, voice-first morning brief that tells people in Lagos the best time today to be outside.
w1-go-out-today
A local, voice-first morning brief that tells people in Lagos the best time today to be outside.
Quick Start
1. Prerequisites
- Python 3.12 managed with
uv - Ollama running locally with
qwen2.5-coder:7b:ollama pull qwen2.5-coder:7b
2. Setup
Clone the repository and install dependencies:
git clone https://github.com/G00dS0ul-Hackerton/w1-go-out-today.git cd w1-go-out-today uv sync Copy .env.example to .env and configure your settings:
cp .env.example .env Key settings:
-
ELEVENLABS_API_KEY: Your ElevenLabs API key (required for audio; or pass--no-voicefor text-only). -
ELEVENLABS_VOICE_ID: Voice ID (default:JBFqnCBsd6RMkjVDRZzb- George). -
ELEVENLABS_MODEL_ID: Model ID (default:eleven_flash_v2_5- 0.5 credits/char on free tier). -
TABPFN_TOKEN: Your TabPFN token (required on Windows to prevent browser authentication prompts). -
OLLAMA_BASE_URL: Ollama API endpoint (default:http://localhost:11434). -
OLLAMA_MODEL: Chosen Ollama model (default:qwen2.5-coder:7b).
(Note: 3 years of hourly historical Lagos weather data is pre-bundled in data/lagos_weather.csv).
3. Run the Morning Brief (CLI)
Generate a 12-hour…
Python 3.12 with uv, FastAPI, and vanilla HTML/CSS/JS with no build step. There are 64 tests and they all run offline: TabPFN, Ollama and ElevenLabs are stubbed.
How I Built It
Here's the whole pipeline. Only fetching the latest weather and the voice need the internet.
05:45 scheduled job (or you press play) │ ▼ Open-Meteo last 2 days of Lagos weather + 3-year CSV (online) │ ▼ TabPFN rain chance + temperature, next 12 hours (local, CPU) │ ▼ Code: the facts best window · window to avoid · heat hours (no AI) │ ▼ Qwen via Ollama facts → 2-3 plain sentences (local) │ ▼ Guard numbers must match the facts, no "will rain", │ no false "at most X%" ── fails twice? → plain template ▼ ElevenLabs voice + word timings for captions (online) │ ▼ Press play, listen, close the laptop, go outside 1. The data: three years of Lagos weather
I pulled hourly weather for Lagos from the Open-Meteo archive (CC BY 4.0): 26,304 rows from 2023-10-01 to 2026-09-30. It has temperature, humidity, dew point, rain, cloud cover, pressure and wind. For real mornings the app also fetches the last couple of days from Open-Meteo's forecast API, because the CSV stops at the end of September.
2. TabPFN: a forecast without training a model
TabPFN is a foundation model for tables. You give it rows, it gives you predictions, and you don't train anything. I used one classifier (will it rain at this hour?) and one regressor (temperature), with "hours ahead" as a feature so a single model covers all 12 hours.
To keep myself honest I held out the last 30 days (forecasts issued at 06:00, 12 hours ahead, 360 rows) and compared against two simple guesses:
- Climatology: "what is normal for this month and hour"
- Persistence: "the next hours will be like right now"
| Model | Temp error (MAE) | Rain AUC (higher is better) | Rain Brier (lower is better) |
|---|---|---|---|
| TabPFN | 0.71 °C | 0.733 | 0.205 |
| Climatology | 0.75 °C | 0.606 | 0.231 |
| Persistence | 1.61 °C | 0.663 | 0.322 |
| Gradient-boosted trees (same 3,000 rows) | 0.69 °C | 0.748 | 0.199 |
| Linear / logistic regression (same rows) | 0.85 °C | 0.645 | 0.250 |
TabPFN beats the simple guesses on every number. Then, the night before posting, I added the test I should have run first: scikit-learn's gradient-boosted trees, default settings, trained on exactly the same 3,000 rows with the same features. The trees edged TabPFN on all three numbers. The gaps are small (0.02 °C, 0.015 AUC, 0.006 Brier) on only 30 mornings, so I'd call it a tie rather than a loss, but it's not the win I was hoping to write.
So why keep TabPFN? I didn't tune anything. No hyperparameters, no training loop: one fit, and it landed within a whisker of a strong tree model on the first go. It also hands me probabilities straight away, and the brief is built on "chance of rain", never "it will rain". With more time I'd backtest more months and try averaging both. TabPFN is not magic, though. An AUC of 0.733 means that when you pick one hour that rained and one that didn't, it gives the rainy hour the higher chance about 73% of the time. That's useful, but it's not a crystal ball.
Two things surprised me:
More data didn't help.
| Training rows | Temp MAE | Rain AUC | Rain Brier | Time |
|---|---|---|---|---|
| 1,000 | 0.627 | 0.685 | 0.222 | 28 s |
| 3,000 | 0.712 | 0.733 | 0.205 | 97 s |
| 5,000 | 0.691 | 0.728 | 0.207 | 189 s |
5,000 rows took twice as long as 3,000 and scored the same. I kept 3,000. I also did not switch to 1,000 for temperature, because that would be tuning on my test set.
More estimators didn't help either. n_estimators=10 gave the same accuracy as 2 (Brier 0.203 vs 0.205, AUC 0.733 both) but took about 8 minutes instead of 3.6. So it's 2 everywhere.
Then I checked one real day. On Sep 30 it called a wet midday (72–81% from 10am to 2pm) that never came, and the only rain fell at 5pm, when it had said 23%. Its temperatures were off by 0.8 °C on average. That one day is why the brief always says "chance of rain" and never "it will rain".
What broke on Windows: my Windows username has an emoji in it (don't ask). That emoji broke how a TabPFN dependency loads OpenBLAS. Then TabPFN's first-run licence login crashed with WinError 10038, because it reads the keyboard in a way that only works on Mac and Linux. The fix was to put a TABPFN_TOKEN in .env and load it before TabPFN starts.
3. Ollama: the code decides, the model only talks
I tested two local models, qwen2.5:7b and qwen2.5-coder:7b, giving each the same real forecast 6 times. I checked every answer with a guard: numbers must come from the forecast, it must say "chance of rain" (never "will rain"), and it must be 2–3 sentences.
-
qwen2.5:7bpassed 2 of 6. Most answers were one sentence, and it kept saying "below 32°C", which was just the heat threshold from my prompt. -
qwen2.5-coder:7bpassed 6 of 6.
But "passed" was hiding something. One coder answer said 5–6pm was "the coolest at 26.2°C". 7am was cooler (25.4°C). The number existed in the forecast, so my guard let it through, but the claim was false.
So I changed the design. The code now works out the facts (best window, window to avoid, heat hours) and the model only turns those facts into sentences. The guard checks that every number matches the facts, and that no range claim is wrong ("at most 29%" has to be true for every hour in that window). If the answer fails, it retries once and then falls back to a plain template. The model can still word things badly, but it can't make up the weather.
This is the guard every sentence goes through (trimmed):
def validate_plan_output(text: str, facts: dict) -> tuple[bool, str]: if "will rain" in text.lower(): return False, "Forbidden phrase 'will rain' detected." sentences = [s for s in re.split(r"(?<=[.!?])\s+", text.strip()) if s] if len(sentences) not in (2, 3): return False, f"Sentence count is {len(sentences)}, expected 2 to 3." # "at most 29%" can't be stretched to the whole forecast if the peak is higher for m in re.finditer(r"at most\s+(?:a\s+)?(\d+)%", text.lower()): ... # every number in the text must come from the facts the code computed valid_nums = extract_fact_numbers(facts) for num in re.findall(r"\b\d+(?:\.\d+)?\b", text): if float(num) not in valid_nums: return False, f"Number '{num}' does not appear in the facts block." return True, "Valid" TabPFN predicts, the code decides, and the LLM talks.
4. ElevenLabs: a voice on the free plan
When I went to claim the Hacktoberfest ElevenLabs credits, the promo pool was empty. So I built for the free plan:
-
eleven_flash_v2_5: about half the credits per character, and fast. - Cache by
SHA-256(text + voice + model): the same brief is never paid for twice. Files are written to.partfirst, so a broken download never gets cached. - Voice-friendly text: "4 pm", not "16:00", and about 40 words.
- With-timestamps endpoint: word timings drive the live captions.
- Every failure becomes one line, and the plan still shows as text.
My first live call used 149 characters. One fun bug: a bad API key came back as a 400, not a 401, and my error message printed every response header to the screen. Now it says "API key is invalid (check .env)" and nothing else.
5. The page: hiding a slow pipeline
On my laptop a cold start takes a few minutes (about 4 in my demo recording), because TabPFN fits two models on the CPU and Ollama has to load 4.7 GB into memory. A spinner for that long would feel broken, so:
- Pre-build at 04:30 with Windows Task Scheduler, so the brief plays instantly in the morning.
- The forecast runs once per morning. Switching activity only reruns the plan and the voice.
- Real progress from the server (Server-Sent Events), one step at a time: reading the sky → TabPFN forecasting → writing your plan → recording.
- A waveform ring around the play button, drawn on a
<canvas>from the Web AudioAnalyserNodeand limited to the speech range.
Two bugs I'm glad I caught before recording. First, I picked "Dry clothes" and heard the walk brief, because the cache wasn't keyed by activity and an older request answered late. Now there's a cache per activity, request IDs, and AbortController. Second, the captions came out as Thebesttimeforawalk... because inline-block spans eat spaces.
Working with agents
I built this in Antigravity with a rule that the agent writes a plan, waits for my APPROVE, builds, then stops at "awaiting confirmation". It never marks anything done; only I do. Each milestone has a plan file, a journal entry with the real numbers, and a saved agent session. Most of the good catches above came from me reading the output and pushing back on the plan, not from the first draft.
Things I got wrong first
- My Windows username has an emoji in it. That broke OpenBLAS loading inside a TabPFN dependency. Don't put emoji in usernames.
- The captions read
Thebesttimeforawalk.inline-blockspans ate every space. - I asked for Dry clothes and got the walk brief. An older request answered late and the cache wasn't keyed by activity.
- A model said 5–6 pm was "the coolest at 26.2°C". 7 am was cooler. The number was real, the claim wasn't. That's why the code decides now.
- A bad ElevenLabs key printed every response header onto the screen. Now it says "API key is invalid (check .env)".
- I assumed TabPFN would beat trees. It didn't, quite (see the table).
What this is not
- It's not a weather service. It's one city, one CSV, and a 12-hour forecast from 06:00.
- An AUC of 0.733 helps you pick your hours. It doesn't promise a dry afternoon.
- It knows rain and heat, not traffic, flooding or how loud your street gets.
- The cold start takes minutes on my laptop, which is why the brief is pre-built at 05:45.
Why Does Open Innovation Matter?
- It runs on my laptop. TabPFN and Qwen run locally with no GPU and no cloud bill. Only the voice goes online, and if there's no internet you still get the full forecast and plan as text.
- My routine stays with me. Where I am, what I do and when I go out never leaves my machine. The only thing sent to ElevenLabs is the two or three sentences of the brief. The "I went outside" log is a local file.
- I could open it up and change it. I swapped two models and benchmarked them in an afternoon, added a guard, and moved the reasoning out of the model into code. You can't do that with a closed weather API.
- It's grounded in my city. TabPFN learns from three years of Lagos history in my own CSV, not a global average.
An honest note: TabPFN's code is Apache 2.0, but the 3.5 weights I used are under a non-commercial licence. That's fine for a personal morning brief, and the README links the licence.
My Agent Session
These are the sessions behind the parts of the build where the agent and I had the most back and forth:
TabPFN forecast and the Windows fixes (M3)






