My e-bike has an API, so I built it a brain
A Bosch e-bike, the EU Data Act, and the thing that's actually changed underneath all of it: AI now lets one person build personal software on demand — exactly when it becomes useful.
I bought a Bianchi e-bike with a Bosch motor. A normal purchase — until I found out that, thanks to the EU Data Act, the bike has a real, read-only API. My riding data is legally mine to pull.
The old reflex would have been to download the vendor app and accept whatever view of my data it offered. Instead, over a weekend, I built the system I actually wanted on my own server and wired the bike into it.
The project started with a bike, but the same pattern fits almost anything that produces useful data. What matters is why a weekend was enough: AI now gives an individual the ability to build personal software on demand — exactly when it becomes useful. What follows is how I built it — bugs and all.
Instead of another app
Every device ships with an app, and every app is someone else's idea of what I should care about. I wanted my bike data next to my Garmin data and the actual weather I rode in, with a way to ask questions about all of it. None of those apps talk to each other. So I built a small system that does:
- Bosch e-bike data — distance, my own pedal power, cadence, speed, climb, GPS track, battery and service health.
- Garmin — heart rate, HR zones, training load: how hard each ride actually was.
- Weather — the real conditions during each ride, not a forecast.
- A way to talk to it — from ChatGPT or Claude, anywhere.
Architecture
Bosch eBike API Garmin Connect Open-Meteo
(EU Data Act, RO) (rides, HR, load) (historical wx)
│ │ │
└───────────┬──────────────┴───────────┬──────────┘
▼ ▼
┌─────────────────────────────────────────┐
│ PostgreSQL = single source of truth │
│ rides · activities · weather │
└─────────────────────────────────────────┘
▲ ▲
hourly sync (cron) tools read live
│ │
┌─────────────────────────────────────────┐
│ MCP server (FastMCP, Python) │
└─────────────────────────────────────────┘
│
Streamable HTTP + bearer token
│
┌─────────────┴─────────────┐
▼ ▼
ChatGPT Claude
(connector / MCP) (connector / MCP)
Two decisions kept it sane. Postgres is the source of truth — each API is fetched once, normalized, and stored, so the assistant reads my database, not vendor endpoints. And the model only ever sees data I can stand behind — no invented fields, no fake conversions, no "probably." That second one was most of the work.
Getting the data out of the bike
The Bosch system exposes a read-only REST API under the Data Act: OAuth2 to authorize once, then every call is a GET. The integration is physically incapable of writing anything back to the bike — exactly what you want from software bolted onto hardware you depend on.
Getting authorized, though, had a trap. My Bosch account's region was set to Iceland, where I live — and when I tried to grant Data Act access, the consent page simply returned "not available in your region." A VPN made no difference; the behaviour followed the account, not the IP.
What worked was unglamorous: I created a fresh eBike account with the phone set to an EU country, re-paired the bike, and re-registered as my own data recipient. With that account the consent flow went through, and the API started returning my bike. I'm reporting what I hit, not a legal rule about the whole system. The lesson that stuck: "it has an API" and "you can actually get your data out" are different milestones. Budget for the second.
Making the numbers trustworthy
Pulling JSON is easy. Not quietly lying to yourself with it is the job. Three real bugs, all the same flavor:
- The odometer was 1000× too high. Bosch reports distance in metres; I rendered it as kilometres, so my ~48 km ride proudly displayed "48,141 km." Fix: convert at the boundary — and delete the magic-number hacks you bolt on to hide the symptom.
- Every GPS track started in the Atlantic. The first trackpoint of each ride is a
(0,0)"no-fix-yet" placeholder; left in, it stretched the route's bounding box across the ocean. Fix: drop null, out-of-range,(0,0)and outlier points before computing anything — and keep a visible count of how many were dropped, so the cleaning is never hidden. - Pedal power is not motor power. The API gives my wattage at the pedals. It does not expose motor output, assist level, or energy per ride — those live only in the phone app. The assistant is forbidden from reporting them:
Never output fields the Data Act API doesn't provide (motor power, assist ratio, mode %, energy per ride…). If it's missing, say "the API doesn't expose that" — never guess.
That rule is the difference between an assistant I trust and a confident bullshitter — I'd much rather it say "I don't have that" than hand me a plausible, invented number.
How hard, and in what conditions
A distance alone is meaningless — two enrichments make a ride legible.
Garmin. Each Bosch ride is matched by time window to the corresponding Garmin activity, so every row also carries heart rate, HR zones, and training load. That surfaced something I'd never have noticed by eye: the same ~25 km loop on a regular bike generated eight times the training load of the e-bike version — which lined up with a complaining knee. The assistant can reason about effort, not just kilometres.
Weather. Using each ride's GPS point and timestamp, I pull the actual weather during the ride from Open-Meteo — real recorded values, not a forecast — and store it on the row. "Was it raining on Tuesday's ride?" is a database lookup, not a guess.
Talking to my bike
All of it is exposed through an MCP server (FastMCP, Python, Streamable HTTP, bearer-token auth). Each capability is a small, documented tool — for instance, a report computed entirely from the database, with zero API calls:
@mcp.tool()
def ride_report(period: str = "month", offset: int = 0) -> dict:
"""Distance, moving time, climb, power, cadence, heart rate and
% change vs the previous period — straight from Postgres."""
return report_from_db(period, offset)
Because it speaks MCP, I connect that same server to ChatGPT or Claude as a connector. I get one assistant that reads my whole bike life and reasons over it — phone, laptop, anywhere. Not a dashboard I open, but something I ask:
"Summarize this month on the bike versus last."
"Given my training load and that it's raining, should I ride today or rest the knee?"
The part that actually matters
The bike is one example. The shift behind it is much larger.
For most of software's history, building a small personal tool meant justifying the effort — wiring, auth, parsing, storage, hours of unglamorous work for an audience of one. AI has cut that cost dramatically. The gap between "I wish this existed" and "it exists" is now mostly deciding the problem is worth it.
What doesn't disappear is the discipline. The reason this system is useful rather than merely impressive is the dull stuff: converting units at the boundary, refusing to invent fields the source doesn't have, throwing out bad GPS fixes instead of hiding them, keeping provenance visible. An answer that's confident and wrong is worse than one that admits what it doesn't know — whether it's my bike or anything else I'd let an AI tell me.
So if you own a watch, a car, or a spreadsheet you keep rebuilding by hand, the interesting question has changed. For me it now sounds like this:
I no longer ask whether an app exists for something. I ask whether the problem is useful enough to build around.