garmin · Article
How I optimise my workouts with AI and Garmin MCP
A morning n8n run that reads my last two weeks of lifting through a Garmin MCP server, plans the next strength session, and writes it back into Garmin Connect so it is on the watch before I get to the gym.

Strength training is mostly bookkeeping. Progressive overload means this week has to know what last week did, per movement, and back off when you are not recovered. My watch already holds both halves of that. It records every set, and it computes sleep, HRV and Body Battery overnight.
It just never puts the two together. Garmin will happily tell me my recovery is poor and then show me a calendar with nothing on it, and the decision about what to actually lift stays where it always was: in my head, at 6am, badly.
So I moved that decision into a workflow.
What the loop does
One n8n run each morning. It reads the last fourteen days of sessions and this morning's recovery numbers, decides what the next session should be, and writes it back into Garmin Connect as a scheduled strength workout. By the time I pick up the watch, the session is on it.
No notifications, no dashboard. The calendar is the interface.
Every call to Garmin in this workflow, reading and writing, goes through an MCP server. There is not a single HTTP node in it.
Why third-party MCP is the only route
Garmin does publish an official way to put workouts on someone's calendar, the Training API, and it is genuinely the right tool. It is also not available to you. The Connect Developer Program is a partner programme: you apply as a company, with a product and a use case, and an individual who wants to schedule their own squats is not what it is for.
So the practical options for a normal person are the community MCP servers built
on the same endpoints the Connect web app uses.
Taxuspt/garmin_mcp exposes 110+ tools
covering most of python-garminconnect.
eddmann/garmin-connect-mcp is a
tighter 22, grouped by area.
Nicolasvegam/garmin-connect-mcp
sits between them at 61.
Check for write tools before you commit to a server. Most Garmin MCP servers are read-only, because reading is what people wanted first. "MCP server for Garmin" usually means "ask questions about Garmin", and this workflow falls apart without a create-workout tool on the other end.
The server, and how n8n reaches it
I run Taxuspt/garmin_mcp, which is one of the few with real write tools. It speaks stdio, and n8n needs HTTP, so mcp-proxy sits in front of it.
services: garmin-mcp: image: python:3.12-slim container_name: garmin-mcp-http entrypoint: - sh - -c - | set -e apt-get update && apt-get install -y --no-install-recommends git pip install --no-cache-dir \ 'git+https://github.com/Taxuspt/garmin_mcp.git' \ mcp-proxy exec mcp-proxy --host 0.0.0.0 --port 8000 --pass-environment -- garmin-mcp environment: GARMIN_EMAIL: ${GARMIN_EMAIL} GARMIN_PASSWORD: ${GARMIN_PASSWORD} restart: unless-stopped ports: - "127.0.0.1:8000:8000" volumes: - /root/garminconnect:/root/.garminconnect networks: - labnetworks: lab: external: true--pass-environment is what hands the credentials through to the wrapped
process. The mounted token directory is what stops it needing them on the second
run: garth writes its session there and resumes from it.
Both containers sit on the external lab network, so n8n reaches the server at
http://garmin-mcp:8000/sse by service name. Nothing is published beyond
loopback.
Bind the port to 127.0.0.1, not 0.0.0.0. An MCP server holding a live
Garmin session has no authentication of its own. Anything that can open the
port can read your location history.
The workflow
Two agents. The first one plans and can only read. The second one uploads and does nothing else. Between them sits a code node whose entire job is to disagree.
The prompt
The planning agent gets a narrow brief and a schema, not an invitation to be creative.
You are planning ONE strength session for tomorrow.You will receive a brief containing: - every session logged in the last 14 days, with exercise, sets, reps, load - total volume per movement pattern for the last 7 and 14 days - days elapsed since each pattern was last trained - this morning's Body Battery, HRV status and sleep scoreUse the Garmin tools only to fill a specific gap in the brief. Do notre-fetch what you already have.Rules: - Train the pattern that has gone longest untrained, unless recovery is poor. - 3 to 5 exercises. Compounds first, accessories after. - You prescribe movement, sets, reps and rest. You do NOT prescribe load. Never mention weight. There is no field for it and I decide it on the day. - Every name MUST come from the provided EXERCISES list. If the movement you want is not on the list, choose the closest one that is. - reps 3-15, sets 1-6, rest_seconds 45-300. - If Body Battery is under 35 or HRV status is "unbalanced", return {"rest": true} and nothing else. Rest is a correct answer, not a failure.Return ONLY JSON matching the schema. No prose, no markdown, no explanation.Three of those earn their place. The exercise list, because Garmin will accept anything and validate nothing. The explicit ban on prescribing load, because a model asked to program strength training will reach for kilograms by default and there is nowhere to put them. And permission to rest, because a model asked to plan a session will always plan a session, since that is what it was asked for.
The upload scheme
create_strength_workout takes two arguments, and the second is a flat list.
That is the whole contract.
{ "rest": false, "name": "Lower A - squat focus", "date": "2026-06-28", "exercises": [ { "name": "BARBELL_BACK_SQUAT", "sets": 4, "reps": 5, "rest_seconds": 180 }, { "name": "BARBELL_HIP_THRUST", "sets": 3, "reps": 8, "rest_seconds": 120 }, { "name": "LEG_CURL", "sets": 3, "reps": 12, "rest_seconds": 90 } ]}Read the builder before you design around this, because two things are not what you would assume.
There is no weight field. Not in the tool, not in the JSON it generates. You cannot prescribe load through this API at all. Garmin's model is that you record what you lifted during the session, not that the workout tells you beforehand.
sets is decorative. The builder interpolates it into the step description
and then emits a single step whose end condition is reps. So 4 sets x 5 and
1 set x 5 produce the same executable workout, differing only in the text on
the watch face.
steps.append({ "type": "ExecutableStepDTO", "stepOrder": step_order, "stepType": {"stepTypeId": 3, "stepTypeKey": "interval"}, "description": f"{ex_name}: {sets} sets x {reps} reps", "endCondition": {"conditionTypeId": 10, "conditionTypeKey": "reps"}, "endConditionValue": float(reps), "targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"}, "category": "UNASSIGNED", "exerciseName": ex_name,})Note category is hardcoded to UNASSIGNED and the name goes through as free
text. Garmin's exercise taxonomy is never consulted. An invented movement is not
rejected, it is accepted and shown as an unnamed generic set, which you find out
about mid-session with a barbell on your back.
So the validation has to happen on my side. Nothing downstream will do it.
That also resets what the model is actually for. It is not picking my weights, because it cannot. It picks the movement, the rep range and the rest, and the load stays where it belongs: with me, informed by what the last session logged.
The code node that disagrees
The agent proposes. This decides.
// Garmin accepts any string as an exercise name, so this list is the only// thing standing between a typo and an unnamed set on the watch.const unknown = plan.exercises.filter((e) => !EXERCISES.has(e.name));if (unknown.length) throw new Error(`unknown: ${unknown.map((e) => e.name)}`);// Total working sets per pattern rise by at most 10% week over week. The// model is optimistic about what I recover from; this is the part that argues.for (const [pattern, sets] of Object.entries(plannedSets(plan.exercises))) { const ceiling = Math.ceil(lastWeek[pattern] * 1.1); if (sets > ceiling) plan = trimPattern(plan, pattern, ceiling);}// Rep ranges stay sane. An agent that has decided today is a "volume day"// will cheerfully write 5 x 20 back squats.plan.exercises = plan.exercises.map((e) => ({ ...e, reps: clamp(e.reps, 3, 15), sets: clamp(e.sets, 1, 6), rest_seconds: clamp(e.rest_seconds, 45, 300),}));// Recovery outranks the plan. Nothing is written at all.if (bodyBattery < 35 || hrvStatus === 'unbalanced') return { rest: true };The exercise-name check is the one that matters, precisely because nothing
downstream performs it. create_strength_workout hands your string straight
to Garmin with the category left UNASSIGNED, so a confidently invented
BARBELL_BULGARIAN_HACK_PRESS uploads without complaint and reaches the watch
as a blank set. Silent acceptance is worse than rejection.
Rest is an output
The branch that writes nothing is a real branch, not error handling.
If recovery is poor the run ends with an empty day on the calendar, and the empty day is the instruction. This was the hardest thing to leave alone. An automation that produces nothing feels broken, and the urge is to have it schedule something light instead of admitting the answer is no.
The version that always scheduled something was worse than no automation at all, because I trusted it.
Where it actually helps
Not in the lifting. It helps with the bookkeeping I was never going to do honestly: remembering I have hit the same pattern three times this week, noticing a lift has not moved in a month, backing off when the numbers say so rather than when I feel like it.
The model is a small part of this. Most of the value is having the last two weeks summarised correctly, every morning, without me doing it.
If you build one
Start read-only. Point an MCP server at your account and spend a week asking questions about your own training before you let anything write. You will find out quickly whether the data supports the decision you want to automate, and that is much cheaper to learn before a scheduler is pushing sessions to your watch at six in the morning.
- garmin
- mcp
- n8n
- ai
- automation
Like what you see?
Let's build something great together.