Reps ships a command-line tool and a Model Context Protocol server that run on your Mac. Point Claude at them and it can read your training history and write to your log, without your data leaving your machine for anything except the model call you asked for.
I did not build that because workout apps needed more AI. I built it because I wanted to ask a question that no workout app UI answers.
The question that started it
"Has my bench actually moved since May, or does it just feel like it?"
Every tracker can show you a graph. A graph is an answer to a different question. What I wanted was to hand the whole log to something that could read all of it at once, compare lifts against each other, notice that the hip work had flatlined while the pressing climbed, and say so. It could, and it did: the bench arc from May and June is the answer it found.
That is a language problem, not a charting problem.
What it looks like
The CLI is the plain version. Real output from my Mac this morning:
$ reps last
2026-07-28 JJ 3 · Hips / Carries [locked, done]
Calf Raise 3x25 @ 55 ✓ RPE 4
DB Shrug 3x15 @ 55 ✓ RPE 7
Farmer Carry 3x60 @ 55 ✓ RPE 7
Lunge Split Squat 3x10 @ 60 ✓ RPE 8
Military Press 12,10,10 @ 55 ✓ RPE 7
Romanian DB Deadlift 3x12 @ 55 ✓ RPE 7
total volume: 22,490 lb
Logging is one line:
$ reps log "EZ-Bar Curl" 95x12 --sets 4 --rpe 8
The MCP server exposes the same operations as 23 tools, so an assistant
can call them directly: get_exercise_history, get_personal_records,
get_week_summary, log_set, plan_workout, clear_day, and the rest.
In practice that means I can ask Claude "what has stalled in the last
month" and it goes and looks, instead of me pasting screenshots.
The part I care about more than the AI
A connector that can write to your training history is a connector that can destroy it. Three constraints shaped the design.
Reads and writes take different paths. Reads come straight from the app's local CloudKit-synced database, read-only. Writes never touch that file. They get dispatched to the Mac app as URL commands and the app performs them, because an outside process writing directly to a CloudKit-backed store corrupts the sync state and quietly poisons every device you own. The store is the app's; the connector is a guest.
Every write returns a receipt. Commands land in a receipts directory so there is an audit trail of what an assistant actually did, separate from what it said it did.
Locked days are refused. When you finish a workout in Reps, the day locks, and the connector cannot edit a locked day. Not "asks for confirmation." Refuses. Your training history is a record of what happened, and a record that a language model can quietly revise is not a record. It is also the input to every prescription the app makes, so corrupting it corrupts tomorrow as well as yesterday.
The honest tell
Run reps doctor and it tells you when it might be wrong:
$ reps doctor
transport: local-store [ok]
sync: CloudKit mirroring ACTIVE (release build → Production)
freshness: store last touched 12h ago — reads may be
stale; open the Reps Mac app to re-sync
That freshness warning exists because the Mac app is what syncs the store, so if the Mac app has not run today, the connector is reading yesterday's truth. A tool that hides that would let an assistant confidently analyze stale data. Twelve hours old is usually fine. It should still say so.
Why local matters here
Training logs are a body-data diary: what you can lift, when you got hurt, when you stopped showing up. Reps keeps that in your own iCloud private database with no account and no server of mine in the middle, and the connector preserves that property by running on your machine. The only thing that leaves is the context of a request you deliberately made.
If you want to try it
You need the Mac app installed and signed into the same iCloud account
as your other devices. Then the CLI lives at reps and the MCP server
registers with Claude Code or Claude Desktop like any other. There is no
pairing step, because CloudKit has already put the same data on every
device you own; the connector just reads the copy that is already there.
Ask it something a graph can't answer. That's the whole point of it.