<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ivan Ficko</title>
    <description>The latest articles on DEV Community by Ivan Ficko (@dynamicsninja).</description>
    <link>https://dev.to/dynamicsninja</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4151780%2F74a7335e-c560-4427-865c-8d88482a1e36.jpg</url>
      <title>DEV Community: Ivan Ficko</title>
      <link>https://dev.to/dynamicsninja</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dynamicsninja"/>
    <language>en</language>
    <item>
      <title>Connect your Garmin to Claude in two minutes</title>
      <dc:creator>Ivan Ficko</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:20:33 +0000</pubDate>
      <link>https://dev.to/dynamicsninja/connect-your-garmin-to-claude-in-two-minutes-5gk3</link>
      <guid>https://dev.to/dynamicsninja/connect-your-garmin-to-claude-in-two-minutes-5gk3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No coding needed.&lt;/strong&gt; If you're a developer who wants Garmin data in your own app, see &lt;a href="https://dev.to/dynamicsninja/from-zero-to-your-first-garmin-data-in-node-4ai6"&gt;Build with Garmin data&lt;/a&gt; instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you wear a Garmin watch, you have months or years of data about your sleep, heart rate, recovery and training sitting in the Garmin Connect app. It's all there, but asking it a simple question, like &lt;em&gt;"Am I sleeping worse since I started marathon training?"&lt;/em&gt;, means a lot of tapping through charts.&lt;/p&gt;

&lt;p&gt;This series is about asking that question in plain words instead, using &lt;strong&gt;Claude&lt;/strong&gt; and a free Garmin connector I built. You don't need to be a programmer, and there's nothing to install.&lt;/p&gt;

&lt;p&gt;In this first part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adding the connector (about two minutes)&lt;/li&gt;
&lt;li&gt;What it can see, what it stores, and how to remove it&lt;/li&gt;
&lt;li&gt;The first five questions to ask&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvsh89yy5bkpgnrxlzv0s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvsh89yy5bkpgnrxlzv0s.jpg" alt="Claude comparing two weeks of sleep in a table: sleep score, total sleep, deep sleep, REM, HRV, resting heart rate and Body Battery" width="800" height="828"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A real answer from the connector. The numbers have been changed for privacy.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Garmin Connect account.&lt;/strong&gt; If you use a Garmin watch, you have one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Claude account&lt;/strong&gt; at &lt;a href="https://claude.ai" rel="noopener noreferrer"&gt;claude.ai&lt;/a&gt;. The connector works in Claude on the web, in the Claude phone apps and in Claude Desktop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick but important note: this connector is &lt;strong&gt;unofficial&lt;/strong&gt;. It isn't made by Garmin or by Anthropic. It's open source, and it talks to Garmin the same way the Garmin Connect app does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: add the connector
&lt;/h2&gt;

&lt;p&gt;You add it once on the web, and it then follows your Claude account to your phone and computer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; Go to &lt;strong&gt;&lt;a href="https://claude.ai" rel="noopener noreferrer"&gt;claude.ai&lt;/a&gt;&lt;/strong&gt; and click &lt;strong&gt;Customize&lt;/strong&gt; in the left sidebar, then &lt;strong&gt;Connectors&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Click &lt;strong&gt;Add&lt;/strong&gt; at the top right, then &lt;strong&gt;Add custom connector&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb664da10uwlujdnd20xm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb664da10uwlujdnd20xm.png" alt="The Connectors page in Claude with the Add menu open, showing " width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; Name it &lt;strong&gt;Garmin&lt;/strong&gt;, and paste this address into &lt;strong&gt;MCP server URL&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://garmin.ficdev.xyz/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc6adx4c5kk8kw28c9n4c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc6adx4c5kk8kw28c9n4c.png" alt="The Add custom connector dialog with the name Garmin and the URL filled in" width="670" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4.&lt;/strong&gt; Click &lt;strong&gt;Continue&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you don't see &lt;strong&gt;Add custom connector&lt;/strong&gt;, custom connectors may not be available on your Claude plan, or your organisation's admin may have turned them off. Claude's help center lists which plans include them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: sign in to Garmin
&lt;/h2&gt;

&lt;p&gt;A Garmin sign-in window opens. Enter the email and password you use for Garmin Connect.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-factor codes work.&lt;/strong&gt; If Garmin sends you a code by email or text, the window asks for it next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It takes a few seconds.&lt;/strong&gt; Garmin's sign-in has several steps behind the scenes, so a short wait after you press the button is normal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Furu2x94mihhhju75td7u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Furu2x94mihhhju75td7u.png" alt="The connector's Garmin sign-in page: email, password, and a " width="800" height="1171"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you've signed in, the window sends you back to Claude, and the connector shows a tick in your list:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fayrxy7kc48s55adpmpwp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fayrxy7kc48s55adpmpwp.png" alt="The Connectors list showing the Garmin connector as connected" width="800" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using the Claude phone app?&lt;/strong&gt; You don't need to do anything there. Once the connector is on your account, the app picks it up automatically. The same goes for &lt;strong&gt;Claude Desktop&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: ask your first question
&lt;/h2&gt;

&lt;p&gt;Start a new chat and ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How did I sleep this week compared with last week?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude works out which Garmin data it needs, fetches it and answers. Above the answer you'll see a small line like &lt;em&gt;"used Garmin MCP integration"&lt;/em&gt;. Click it to see exactly what it did: here it looked up the right tool, then called &lt;code&gt;getSleepDaily&lt;/code&gt; for the two weeks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxp3dd4i01oqwgidzq99v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxp3dd4i01oqwgidzq99v.png" alt="The expanded tool details: a getSleepDaily request with start and end dates, and the start of Garmin's response" width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is worth a look now and then. It shows you which dates Claude actually asked for, so you can tell a careful answer from a guess.&lt;/p&gt;

&lt;p&gt;The first time, Claude may ask your permission to use the connector. You can allow it once, or always.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five good first questions
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;em&gt;"How did I sleep this week compared with last week?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"What has my HRV done over the last month? Anything unusual?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Am I ready to train hard today?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"What were my last five runs? Compare their pace and average heart rate."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"What do my race predictions say, and how have they changed since the summer?"&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain questions work best. You don't need to know what any of the Garmin data is called. Follow-up questions work too: &lt;em&gt;"Why do you think Tuesday was so bad?"&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it can see and store
&lt;/h2&gt;

&lt;p&gt;This is your health data, so here's exactly what happens to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Claude can do.&lt;/strong&gt; Through the connector, Claude can read your Garmin data, and it can also &lt;em&gt;save&lt;/em&gt; some things for you: workouts, weigh-ins, and changes like renaming an activity. Anything that deletes or overwrites data is marked as destructive, so Claude asks you before doing it. You'll see the workout features in Part 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the connector stores.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your password&lt;/strong&gt; goes to Garmin once, at sign-in, and is never stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Garmin session&lt;/strong&gt; isn't stored on the server either. It's encrypted inside the connection token that your Claude account keeps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server keeps one small record per connection&lt;/strong&gt;: a random id, a scrambled reference to your Garmin account, the app's name (for example "Claude"), and timestamps. It's deleted after 35 days without use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Garmin data&lt;/strong&gt; passes through to Claude and isn't kept.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What Claude sees.&lt;/strong&gt; The data the connector fetches becomes part of your conversation, the same as anything you paste into a chat. How Claude handles conversations is covered by Anthropic's privacy policy.&lt;/p&gt;

&lt;p&gt;The connector's full &lt;a href="https://garmin.ficdev.xyz/privacy" rel="noopener noreferrer"&gt;privacy policy&lt;/a&gt; states all of this as facts about the code, which is &lt;a href="https://github.com/DynamicsNinja/garminconnect-js" rel="noopener noreferrer"&gt;open source&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Removing it
&lt;/h2&gt;

&lt;p&gt;Either of these disconnects it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In Claude, go to &lt;strong&gt;Customize → Connectors&lt;/strong&gt; and remove &lt;strong&gt;Garmin&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Or, the next time you sign in to Garmin through the connector, tick &lt;strong&gt;"Disconnect my other Garmin connections"&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To end the session on Garmin's side as well, change your Garmin password.&lt;/p&gt;

&lt;h2&gt;
  
  
  If something doesn't work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Sign-in failed" or "too many attempts."&lt;/strong&gt; Garmin limits how often you can sign in. Wait 15 minutes and try again. You only need to sign in once; after that the connection keeps itself alive as long as you use it at least once a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saving fails, but reading works (European accounts).&lt;/strong&gt; If you're in the EU and Claude says Garmin refused to save something, your Garmin account hasn't granted &lt;em&gt;upload consent&lt;/em&gt; yet. It's a one-time setting in your Garmin Connect account's privacy settings, and Garmin requires it for every app that saves data to your account. Grant it there, then try again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude says there's no data.&lt;/strong&gt; It can only see what your watch recorded and synced to Garmin Connect. A night you didn't wear the watch, or a feature your watch doesn't have (some don't measure HRV), shows up as no data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude didn't use Garmin at all.&lt;/strong&gt; Start your question with &lt;em&gt;"From my Garmin data, …"&lt;/em&gt;, or check that the connector is switched on in the chat's tools menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;Part 2&lt;/strong&gt; we'll go deeper on asking questions: comparing weeks and months, checking whether you're recovering well, reading race predictions sensibly, and how to tell when Claude's answer is solid and when it's guessing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set-up guides for every Claude app: &lt;a href="https://garmin.ficdev.xyz/claude" rel="noopener noreferrer"&gt;garmin.ficdev.xyz/claude&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How it's built (for the curious): &lt;a href="https://dev.to/dynamicsninja/i-built-a-garmin-connect-library-for-typescript-then-let-claude-use-it-g62"&gt;my first post&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What was the first thing you asked? Tell me in the comments. I collect good questions for the next post.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>beginners</category>
      <category>garmin</category>
    </item>
    <item>
      <title>From zero to your first Garmin data in Node</title>
      <dc:creator>Ivan Ficko</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:18:36 +0000</pubDate>
      <link>https://dev.to/dynamicsninja/from-zero-to-your-first-garmin-data-in-node-4ai6</link>
      <guid>https://dev.to/dynamicsninja/from-zero-to-your-first-garmin-data-in-node-4ai6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;For developers.&lt;/strong&gt; Just want to ask Claude about your training, with no code? Start with &lt;a href="https://dev.to/dynamicsninja/connect-your-garmin-to-claude-in-two-minutes-5gk3"&gt;Your Garmin data in Claude&lt;/a&gt; instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my &lt;a href="https://dev.to/dynamicsninja/i-built-a-garmin-connect-library-for-typescript-then-let-claude-use-it-g62"&gt;first post&lt;/a&gt; I introduced &lt;strong&gt;garminconnect-js&lt;/strong&gt;, a typed, zero-dependency Garmin Connect client for Node. This series is the hands-on version: four posts that take you from an empty folder to an app that reads and writes Garmin data.&lt;/p&gt;

&lt;p&gt;This first part covers the foundation everything else stands on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Signing in, including two-factor (MFA) codes&lt;/li&gt;
&lt;li&gt;Keeping the session, so you sign in once and not on every run&lt;/li&gt;
&lt;li&gt;Reading data, and what "no data" looks like&lt;/li&gt;
&lt;li&gt;Handling errors properly&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By the end you'll have a small command-line script that prints last night's sleep and HRV.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Written against garminconnect-js &lt;strong&gt;0.10.0&lt;/strong&gt;. Every snippet here type-checks against that version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 18 or newer&lt;/strong&gt; (&lt;code&gt;node --version&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Garmin Connect account&lt;/strong&gt; with some data in it. A watch that records sleep makes this post more fun, but everything works without one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick note on what this library is: Garmin has no public API for your own Connect data, so garminconnect-js talks to the same endpoints Garmin's web and mobile apps use. It's unofficial, those endpoints can change, and it's &lt;strong&gt;server-side only&lt;/strong&gt;. Your Garmin password and tokens should never reach a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up the project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;garmin-sleep &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;garmin-sleep
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;garminconnect-js
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; tsx typescript @types/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Deno or Bun, the same code is published on JSR as &lt;code&gt;@dynamicsninja/garminconnect&lt;/code&gt; (&lt;code&gt;npx jsr add @dynamicsninja/garminconnect&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tsx&lt;/code&gt; runs TypeScript files directly, so there's no build step for a script like this. Add &lt;code&gt;"type": "module"&lt;/code&gt; to &lt;code&gt;package.json&lt;/code&gt; so top-level &lt;code&gt;await&lt;/code&gt; works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And keep your secrets out of git from the start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"tokens/"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: sign in once
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;login.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createInterface&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:readline/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stdout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FileTokenStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garminconnect-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileTokenStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GARMIN_EMAIL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GARMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mfa_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInterface&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stdout&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Garmin sent you a code. Enter it: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumeLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mfaState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Signed in. Your session is saved in ./tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with your credentials in the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GARMIN_EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@example.com &lt;span class="nv"&gt;GARMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'...'&lt;/span&gt; npx tsx login.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(On Windows PowerShell, set them first with &lt;code&gt;$env:GARMIN_EMAIL="you@example.com"&lt;/code&gt;, or use a &lt;code&gt;.env&lt;/code&gt; file and &lt;code&gt;node --env-file=.env --import tsx login.ts&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;If you use a &lt;code&gt;.env&lt;/code&gt; file and your password contains a &lt;code&gt;#&lt;/code&gt;, put the value in quotes: &lt;code&gt;GARMIN_PASSWORD="..."&lt;/code&gt;. Without them, Node reads everything from the &lt;code&gt;#&lt;/code&gt; on as a comment, Garmin gets a shortened password, and you get a &lt;code&gt;GarminAuthError&lt;/code&gt; that looks like a wrong password. It happened to me while testing this post.&lt;/p&gt;

&lt;p&gt;Three things are worth understanding here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;login()&lt;/code&gt; doesn't throw or block on MFA.&lt;/strong&gt; If your account has two-factor turned on, it returns &lt;code&gt;{ state: "mfa_required", mfaState }&lt;/code&gt; and you finish with &lt;code&gt;resumeLogin(mfaState, code)&lt;/code&gt;. In a script, that's a prompt. In a web app, the two halves can happen in &lt;strong&gt;two separate HTTP requests&lt;/strong&gt;, which is exactly what Part 2 builds on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FileTokenStore&lt;/code&gt; keeps your session on disk.&lt;/strong&gt; After a successful login, the library writes two small JSON files to &lt;code&gt;./tokens&lt;/code&gt;. Those files &lt;em&gt;are&lt;/em&gt; your Garmin session, so treat them like a password: keep them out of git, and off any machine you don't trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign in as rarely as possible.&lt;/strong&gt; Garmin rate-limits sign-ins, sometimes after only one or two attempts in a row. The library already falls back to Garmin's web sign-in when the mobile one is rate-limited, but the real fix is not to sign in repeatedly. Sign in once, then reuse the tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: reuse the session
&lt;/h2&gt;

&lt;p&gt;Every other script starts like this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Garmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FileTokenStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garminconnect-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileTokenStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadTokens&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not signed in yet. Run login.ts first.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Garmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;loadTokens()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; rather than throwing when there's nothing saved, so the "not signed in" case is just an &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You never refresh tokens yourself. The short-lived access token lasts about a day, and the library renews it automatically before any request that needs it, then saves the new one back through your token store. The underlying session keeps rolling forward &lt;strong&gt;as long as you make a call at least once every 30 days&lt;/strong&gt;. Go longer than that and you'll get a &lt;code&gt;GarminAuthError&lt;/code&gt;, which means "sign in again".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Garmin&lt;/code&gt; is the class with all the methods (about 200 of them). Create &lt;strong&gt;one&lt;/strong&gt; per signed-in user and reuse it: it caches your profile, which most date-based calls need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: read last night's sleep
&lt;/h2&gt;

&lt;p&gt;Here's the full script, &lt;code&gt;sleep.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Garmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FileTokenStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GarminAuthError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GarminError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garminconnect-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PersonalApi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garminconnect-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileTokenStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadTokens&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not signed in yet. Run login.ts first.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Garmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Garmin files a night's sleep under the date you woke up.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;h &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;m`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hrv&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSleepData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHrvData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;night&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;dailySleepDTO&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;PersonalApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DailySleepDTO&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;night&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;sleepTimeSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No sleep recorded for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Sleep on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;night&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sleepTimeSeconds&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  Score: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;night&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sleepScores&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;overall&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;n/a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  Deep &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;night&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deepSleepSeconds&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;, REM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;night&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remSleepSeconds&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hrv&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;hrvSummary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HRV: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastNightAvg&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ms last night, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weeklyAvg&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ms weekly average (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;GarminAuthError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your Garmin session has expired. Run login.ts again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;GarminError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Garmin error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it for today, or for any date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx sleep.ts
npx tsx sleep.ts 2026-10-20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output looks like this (your numbers will differ):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sleep on 2026-10-20: 7h 34m
  Score: 81
  Deep 1h 22m, REM 1h 51m
HRV: 52 ms last night, 49 ms weekly average (BALANCED)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "No data" is not an error
&lt;/h3&gt;

&lt;p&gt;Notice the checks for &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt;. &lt;strong&gt;For most Garmin endpoints, an empty answer is normal&lt;/strong&gt;: no watch worn that night, no HRV feature on your device, or simply a date before you owned the watch. &lt;code&gt;getSleepData&lt;/code&gt; and &lt;code&gt;getHrvData&lt;/code&gt; return &lt;code&gt;null&lt;/code&gt; in that case; they don't throw.&lt;/p&gt;

&lt;p&gt;A few methods do throw on an empty answer, for example &lt;code&gt;getActivity&lt;/code&gt; when the id doesn't exist. The library's &lt;a href="https://garmin.ficdev.xyz/docs/reference" rel="noopener noreferrer"&gt;reference&lt;/a&gt; says which is which, method by method, so check there rather than assuming one rule for all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dates are UTC calendar dates
&lt;/h3&gt;

&lt;p&gt;Every method takes a date either as &lt;code&gt;"YYYY-MM-DD"&lt;/code&gt; or as a &lt;code&gt;Date&lt;/code&gt;. A &lt;code&gt;Date&lt;/code&gt; is converted with &lt;code&gt;toISOString()&lt;/code&gt;, which is &lt;strong&gt;UTC&lt;/strong&gt;. If you're in New York and pass &lt;code&gt;new Date()&lt;/code&gt; at 9 p.m., you're asking for &lt;em&gt;tomorrow&lt;/em&gt;. When the day matters, pass the string you mean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Typed, but honest about it
&lt;/h3&gt;

&lt;p&gt;Most responses have real types. Many are generated from Garmin's own Personal API spec and exported as &lt;code&gt;PersonalApi.*&lt;/code&gt;, which is where &lt;code&gt;DailySleepDTO&lt;/code&gt; above comes from. A few raw responses are deliberately loose (&lt;code&gt;Record&amp;lt;string, unknown&amp;gt;&lt;/code&gt;) because Garmin's shape varies too much to promise more. That's why the script narrows &lt;code&gt;dailySleepDTO&lt;/code&gt; to the spec's type in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling errors
&lt;/h2&gt;

&lt;p&gt;Every error the library throws is a &lt;code&gt;GarminError&lt;/code&gt; or a subclass, so you can handle them by type:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Means&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GarminAuthError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The session is gone or invalid (401/403, expired tokens)&lt;/td&gt;
&lt;td&gt;Sign in again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GarminRateLimitError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Garmin said 429&lt;/td&gt;
&lt;td&gt;Back off. It carries &lt;code&gt;retryAfter&lt;/code&gt; (seconds) when Garmin sends one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GarminConnectionError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Network failure or timeout, after retries&lt;/td&gt;
&lt;td&gt;Retry later, but read the note below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GarminHttpError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any other non-2xx&lt;/td&gt;
&lt;td&gt;Inspect &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The library already retries GET requests on network errors and 5xx responses, with backoff. It &lt;strong&gt;never&lt;/strong&gt; retries a POST, so a write can't be duplicated behind your back.&lt;/p&gt;

&lt;p&gt;One trap: a few &lt;em&gt;permanent&lt;/em&gt; errors are deliberately reported as &lt;code&gt;GarminConnectionError&lt;/code&gt;, such as uploading an activity that already exists. So never write a blind &lt;code&gt;catch (e) { if (e instanceof GarminConnectionError) retryForever() }&lt;/code&gt;. Look at the message, or at &lt;code&gt;error.cause&lt;/code&gt;, first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond sleep
&lt;/h2&gt;

&lt;p&gt;The same pattern, sign in once and then call methods, covers the rest of your data. A few to try in the same script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// steps, calories, stress, Body Battery for one day&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDailySummaries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-20&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the same, one row per day, for a range&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTrainingReadiness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// readiness score and what's driving it&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRacePredictions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// 5K, 10K, half and full marathon predictions&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActivities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// your five most recent activities&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you need an endpoint the library doesn't wrap, &lt;code&gt;client.connectapi(path)&lt;/code&gt; calls any Garmin Connect path with the same authentication. You'll see that in Part 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;You now have the two building blocks: a session you sign in to once, and typed reads with proper error handling. In &lt;strong&gt;Part 2&lt;/strong&gt; we'll put this behind a web app: a Next.js app where users sign in with their own Garmin account (MFA included, across two requests), with tokens stored in your database instead of a folder.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs and every method: &lt;a href="https://garmin.ficdev.xyz/" rel="noopener noreferrer"&gt;garmin.ficdev.xyz&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;a href="https://github.com/DynamicsNinja/garminconnect-js" rel="noopener noreferrer"&gt;github.com/DynamicsNinja/garminconnect-js&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something here doesn't match what your account returns, tell me in the comments. Undocumented endpoints are full of surprises, and every report makes the library better.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>javascript</category>
      <category>garmin</category>
    </item>
    <item>
      <title>I gave Claude access to my Garmin data: a free MCP server and TypeScript library</title>
      <dc:creator>Ivan Ficko</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:29:38 +0000</pubDate>
      <link>https://dev.to/dynamicsninja/i-built-a-garmin-connect-library-for-typescript-then-let-claude-use-it-g62</link>
      <guid>https://dev.to/dynamicsninja/i-built-a-garmin-connect-library-for-typescript-then-let-claude-use-it-g62</guid>
      <description>&lt;p&gt;I wear a Garmin watch every day, and for a long time my data lived in one place: the Garmin Connect app. I wanted it in my own code, and lately I wanted to ask an AI about it: "How did I sleep this week?", "Am I ready for intervals today?", "Build me a 6x400 session and put it on my watch."&lt;/p&gt;

&lt;p&gt;Garmin has no public API for personal Connect data, so I built two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/DynamicsNinja/garminconnect-js" rel="noopener noreferrer"&gt;garminconnect-js&lt;/a&gt;&lt;/strong&gt;, a TypeScript client for Garmin Connect with 200 typed methods and zero runtime dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Garmin MCP server&lt;/strong&gt; on top of it, so Claude (or any MCP client) can read and manage your Garmin data in plain language.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is free and open source, and the home for both is &lt;strong&gt;&lt;a href="https://garmin.ficdev.xyz/" rel="noopener noreferrer"&gt;garmin.ficdev.xyz&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it in a minute:&lt;/strong&gt; add &lt;code&gt;https://garmin.ficdev.xyz/mcp&lt;/code&gt; as a custom connector in Claude, then ask how you slept this week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65qrrb1laopkjfeii9f7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65qrrb1laopkjfeii9f7.png" alt="The garmin.ficdev.xyz homepage: the connector URL, and an example of Claude comparing two weeks of sleep" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Garmin in Claude, in about a minute
&lt;/h2&gt;

&lt;p&gt;If you just want to talk to your training data, you don't need to install anything. A hosted copy of the MCP server runs at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://garmin.ficdev.xyz/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;claude.ai (web, Desktop and the phone apps):&lt;/strong&gt; Settings, Connectors, Add custom connector. Paste the URL, sign in to Garmin in the window that opens (with your MFA code if you use one), and you're done. It follows your account to Claude Desktop and mobile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http garmin https://garmin.ficdev.xyz/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Any other MCP client:&lt;/strong&gt; add a streamable-HTTP server with that URL.&lt;/p&gt;

&lt;p&gt;The hosted server never stores your Garmin tokens. They're sealed inside the OAuth token your MCP client holds, so the server keeps no copy of them. If you'd rather run everything locally, there's a one-click Claude Desktop Extension (&lt;code&gt;.mcpb&lt;/code&gt;) on the &lt;a href="https://github.com/DynamicsNinja/garminconnect-js/releases/latest" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; and an npm package, &lt;code&gt;@dynamicsninja/garminconnect-mcp&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you can ask
&lt;/h3&gt;

&lt;p&gt;Plain questions work. Claude picks the tools and shows you which ones it used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How did I sleep this week compared with last week?"&lt;/li&gt;
&lt;li&gt;"What has my HRV done over the last month?"&lt;/li&gt;
&lt;li&gt;"Am I ready to train hard today?"&lt;/li&gt;
&lt;li&gt;"Find my runs over 10 km since June, longest first, and compare their average heart rate."&lt;/li&gt;
&lt;li&gt;"What are my race predictions?"&lt;/li&gt;
&lt;li&gt;"Log 72.4 kg for this morning."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the one I built it for:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Make me a 6x400 m session at 5k pace with 2-minute jog recoveries, put it on Thursday, and send it to my watch."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude previews the workout in readable form (steps, targets, paces in /km and /mi) and &lt;strong&gt;saves nothing until you confirm&lt;/strong&gt;. Then it saves it to your workout library, schedules it, and pushes it to your device. Strength, swim, HIIT, yoga, pilates, mobility, cycling and multi-sport work too, and exercise names are checked against Garmin's catalogue so a typo can't slip through.&lt;/p&gt;

&lt;p&gt;Tools that delete or overwrite data (activities, workouts, gear, weigh-ins) are marked destructive, so Claude asks you first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: Garmin in your code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;garminconnect-js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Garmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FileTokenStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garminconnect-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GarminClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileTokenStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GARMIN_EMAIL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GARMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mfa_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumeLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mfaState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;promptForCode&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Garmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSleepData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-22&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hrv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHrvData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-22&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Garmin's SSO and OAuth flow handled for you&lt;/strong&gt;, including MFA. &lt;code&gt;login()&lt;/code&gt; doesn't block on MFA. It returns a plain-JSON state you can carry across two HTTP requests, which is what you need in a serverless Next.js route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic token refresh.&lt;/strong&gt; Sessions roll forward as long as you make a call at least once every 30 days. Tokens go wherever you want: memory, disk, or your own database through a three-method &lt;code&gt;TokenStore&lt;/code&gt; interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;200 typed methods&lt;/strong&gt;: sleep, HRV, stress, Body Battery, daily summaries, activities (list, search, details, splits, download, upload), training readiness and status, race predictions, workouts, gear, courses, calendar events, devices, badges, weigh-ins, women's health, golf, nutrition and training plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An escape hatch.&lt;/strong&gt; &lt;code&gt;client.connectapi(path)&lt;/code&gt; calls any Garmin Connect endpoint with the same auth, for the rare thing the library doesn't wrap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero runtime dependencies&lt;/strong&gt;, Node 18+. It's server-only on purpose: your Garmin credentials should never reach a browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Want a running app first? The &lt;a href="https://github.com/DynamicsNinja/garminconnect-nextjs-starter" rel="noopener noreferrer"&gt;Next.js starter template&lt;/a&gt; signs in (MFA included) and charts your sleep and HRV, and there's a &lt;a href="https://garmin.ficdev.xyz/demo" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workout builder, or: Garmin's API is quietly hostile
&lt;/h2&gt;

&lt;p&gt;Writing workouts was the hardest part, because Garmin's workout JSON has traps that don't fail loudly. They succeed and store the wrong thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stepOrder&lt;/code&gt; is global.&lt;/strong&gt; Steps inside a repeat block don't restart at 1. They continue the outer sequence. Number them per block and the upload is rejected; number them per segment in a multi-sport workout and it's rejected again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pace targets are speeds in metres per second, fastest first.&lt;/strong&gt; Send "4:00 to 4:12 min/km" the obvious way and Garmin happily stores a nonsense target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rest in seconds isn't a &lt;code&gt;time&lt;/code&gt; step.&lt;/strong&gt; It's a different end condition entirely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every sport, step and condition is an id/key pair that must agree.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the library has a fluent builder that makes all four unreachable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;buildWorkout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;garminconnect-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildWorkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;6x400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;lapButton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;set&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minPerKm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recovery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cooldown&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;garmin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uploadWorkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pace converts to descending m/s for you, step orders are counted across the whole tree, and the sport/step ids come from Garmin's own enums.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exercise names fail silently, so they're type-checked
&lt;/h3&gt;

&lt;p&gt;My favourite trap: if you send a strength exercise name Garmin doesn't recognise, it &lt;strong&gt;stores an empty string and returns success&lt;/strong&gt;. Your "Barbell Back Squat" quietly becomes a blank step on your watch.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;garminconnect-js/exercises&lt;/code&gt; ships Garmin's catalogue as types: 1,830 names across 51 categories, every one uploaded to a live account and kept only if Garmin echoed it back unchanged. Once you pick a category, &lt;code&gt;name&lt;/code&gt; autocompletes to that category's exercises, and anything else is a compile error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;buildWorkout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Legs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;strength_training&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;exercise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SQUAT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BARBELL_BACK_SQUAT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;exercise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SQUAT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BARBELL_BENCH_PRESS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// tsc error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lives in a separate entry point, so the root bundle doesn't carry the catalogue.&lt;/p&gt;

&lt;p&gt;For input that can't hold callbacks (a database row, a form, an LLM tool call), &lt;code&gt;workoutFromSpec&lt;/code&gt; takes the same workout as plain JSON and comes with a JSON Schema. That's exactly what the MCP server hands to Claude, and when Claude gets something wrong, the error comes back with the JSON path and the closest valid exercise names, so it can fix its own mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Verified" means I read it back
&lt;/h2&gt;

&lt;p&gt;Since the endpoints are undocumented, a passing test suite against mocks proves very little. A mock written from an assumption agrees with the code written from the same assumption. Three real bugs shipped that way early on, including one that stored a 93 kg weigh-in as 93,000 kg.&lt;/p&gt;

&lt;p&gt;So every method carries a live-verification note, and a write only counts as verified once the &lt;strong&gt;stored value has been read back&lt;/strong&gt;. A 2xx on its own has hidden real defects more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "walking workout" helper got a 2xx from Garmin every time. Reading it back showed the workout was stored with &lt;strong&gt;no sport at all&lt;/strong&gt;, because Garmin has no walking workout type. The helper was removed.&lt;/li&gt;
&lt;li&gt;Pushing a workout to a watch was declared to return a workout. The first time it ran against a real device, it returned an array of device messages.&lt;/li&gt;
&lt;li&gt;Goal lists are 1-indexed, and &lt;code&gt;start=0&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt; with no error, indistinguishable from "you have no goals".&lt;/li&gt;
&lt;li&gt;Perceived effort (RPE) is stored times ten.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these are written down in &lt;a href="https://github.com/DynamicsNinja/garminconnect-js/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt;, which doubles as a briefing for AI coding agents, so Copilot or Claude Code doesn't confidently invent a method that doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the MCP server stays in sync
&lt;/h2&gt;

&lt;p&gt;The MCP server holds no Garmin knowledge of its own. A script reads the library's source with the TypeScript compiler and generates a manifest of every method: parameters as JSON Schema, plus a safety class (read, write or destructive). Each tool is generated from that manifest, so when a method is added to the library, the tool appears in Claude after the next build, already annotated. Arguments are validated against the schema before any request reaches Garmin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fine print
&lt;/h2&gt;

&lt;p&gt;This is &lt;strong&gt;unofficial&lt;/strong&gt; and not affiliated with Garmin. It uses the same undocumented endpoints as the Garmin Connect web and mobile apps, so Garmin can change them at any time. Don't build anything safety-critical on it. One gotcha worth knowing up front: on an EU account, every write returns a 412 until you've granted upload consent in Garmin Connect's settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Talk to your data in Claude:&lt;/strong&gt; add &lt;code&gt;https://garmin.ficdev.xyz/mcp&lt;/code&gt; as a custom connector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use it in code:&lt;/strong&gt; &lt;code&gt;npm install garminconnect-js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs, setup for every client, and the demo:&lt;/strong&gt; &lt;a href="https://garmin.ficdev.xyz/" rel="noopener noreferrer"&gt;garmin.ficdev.xyz&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source (MIT, a star helps):&lt;/strong&gt; &lt;a href="https://github.com/DynamicsNinja/garminconnect-js" rel="noopener noreferrer"&gt;github.com/DynamicsNinja/garminconnect-js&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build something with it, or find an endpoint that behaves differently on your account, I'd love to hear about it in the comments or in a GitHub issue. And if you try the connector, tell me the most useful question you asked Claude about your training.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>typescript</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
