Imported from adamzelina1/garmin_agent (
AGENTS.md). Install upstream withnpx skills add adamzelina1/garmin_agent. Copyright stays with the author.
AGENTS.md
Project
Fetches daily Garmin Connect data into SQLite. Python 3.14, managed with uv. Windows environment — no system python/pip, always use uv run.
Commands
uv run garmin-fetch # incremental sync of all data types + activities + auto-parse
uv run garmin-fetch --type heart_rate # sync subset (repeatable); 'activities' works too
uv run garmin-fetch --type activities # activities only (skip day-keyed types)
uv run garmin-fetch --type profile # refresh HR-zone profile snapshot only (hr_zones)
uv run garmin-fetch --parse # incremental parse: only rows whose raw payload changed since last parsed
uv run garmin-fetch --parse hrv steps # incremental parse of specific types only
uv run garmin-fetch --parse activities # parse stored activity summaries + detail series/aggregates incrementally
uv run garmin-fetch --parse --full # forced full re-parse of every stored row (ignore change markers)
uv run garmin-fetch --list-types
uv run garmin-fetch --range 2026-01-01 2026-01-07
uv run garmin-ask "avg sleep last week?" # LLM agent over daily_metrics (read-only SQL)
uv run garmin-ask # interactive session; conversation context kept across questions
uv run garmin-ask --session meta.json # same, but history persists to a file and resumes on next run
uv run garmin-ask-web # browser chat UI (Gradio) over the same read-only agent
uv run garmin-ask-web --port 8000 # serve the UI on a custom port; --share for a public link
# web auto-persists and resumes the last session (web_session.json); --session FILE overrides it
uv run garmin-trace # print the recorded agent tool-call trace (ask_trace.jsonl)
uv run garmin-trace --full # no truncation of long args/outputs
uv run garmin-trace --tail 5 --tool run_sql # last 5 turns that used run_sql
uv run pytest -q # tests (fake client, no Garmin login; ask tests offline)
uv add <pkg> # add deps; --dev for dev deps
Architecture
- Two-step design: the fetcher stores raw JSON (the source of truth), and the parser projects it into typed daily tables. The parser is now built (
src/garmin_fetch/parser.py); it runs automatically after each sync. src/garmin_fetch/datatypes.py— registry for day-keyed types. A data type is aDataType(name, fetch_fn). Adding a type = add a fetch adapter + one registry entry (test intests/test_fetcher.py::test_custom_data_type_is_registerable).src/garmin_fetch/db.py— SQLite.metricstable (keyeddata_type + calendar_date, storesraw_json) is the sole store and the source of truth for "what's fetched".daily_metricsis the parsed projection (one wide row percalendar_date, columns auto-created on demand);activitiesstores raw activity summaries + details keyed byactivityId;activity_summariesis the parsed activity projection (one row peractivityId, fixed/curated schema of shared summary fields);activity_detail_seriesis the parsed intra-activity time series (one wide row per(activity_id, tick), metrics the sport's device didn't record are NULL). Profile settings (same-snapshot data) live inuser_profile(profile_typePK,raw_json,fetched_at) and project intohr_zones(one row per sport with derivedzoneN_min/zoneN_maxbpm ranges) andrace_predictions(a single current snapshot row; predicted times stored in minutes, converted from Garmin's seconds).src/garmin_fetch/parser.py—PARSERS[type_name] = fn(payload) -> {column: scalar}. Each extractor returns only leaf scalars, never containers, and never raises on malformed input. List-wrapped payloads (steps,body_battery,max_metrics) are unwrapped/aggregated.build_daily_rows(db, types)reads rawmetricsand merges intodaily_metricsinORDER BY calendar_date; sparse types in_FFILL_TYPES(currentlylactate_threshold) forward-fill — a date with no data keeps the last known value instead of NULL. Intraday series (HR ticks, stress/respiration arrays, sleep levels, BB curves) stay inmetrics— they are not collapsed to the scalar daily row. Activity summaries:parse_activity_summary(flat summary scalars, incl. respiration/elevation/speed fields, speeds stored m/s→km/h) +build_activity_summaries(db)project each activity intoactivity_summaries. Durations stored in hours, distance in km, time-in-zone in hours (converted from the summary's seconds). Fields a type lacks (e.g. distance on indoor cycling) are NULL/0. Activity details: the storeddetails_jsonis projected byparse_activity_details(scalar aggregates: avg/max cadence + avg/max power merged into the summary row) andparse_activity_detail_series(wide per-tick rows) —build_activity_details(db)rebuilds both. Series descriptors are matched by key (directHeartRate,directPower, cadence via a per-sport priority list,sumDistance, ...); the FakeClient's test details only carriesheart_rate/empty metrics so it yields no series. HR zones:parse_hr_zones(payload)+build_hr_zones(db)replace the wholehr_zonestable from theuser_profile('hr_zones')snapshot; zone N =[floor_N, floor_(N+1)), zone 5 runs tomaxHRUsed.- Activities do NOT use
metrics. They are keyed byactivityId(multiple per day, have timestamps, returned as a list, not per-date dicts), stored in their ownactivitiestable (activity_id,raw_json,fetched_at,details_json,details_fetched_at,weather_json,weather_fetched_at), and fetched viaDataFetcher.fetch_activities()usingget_activities_by_date(). They can't fit theDataTyperegistry — separate sync branch infetcher.py. - Activities are stored atomically: summary + details together. In
fetch_activities(), the detail payload (get_activity_details(id)) is fetched before storing; if details are unavailable the activity is skipped entirely — there are never summary-only (or details-only) rows. No backfill logic.details_jsonholds the per-activity detail payload (metricDescriptors+activityDetailMetricsarrays +heartRateDTOs). Observed weather is a best-effort enrichment:_fetch_activity_weather(id)runs for every new activity (andbackfill_activity_weather(db)fills any that lack it); a missing/failed weather call only logs a warning — the activity is still stored.parse_activity_weatherprojects it intoactivity_summariesweather columns (imperial→metric: degF→degC, mph→km/h). src/garmin_fetch/fetcher.py—DataFetcher.fetch_range()is the day-keyed sync loop: skip stored dates, fetch missing, write raw tometrics.sync_data()drives all day-keyed types + optionally the activities branch + the profile snapshots, then auto-parses the fetched types intodaily_metrics(skip withparse=False). Activity resume uses a rolling window +activities_last_finalizedmarker insync_state(see below). Profile:DataFetcher.fetch_profile()callsget_heart_rate_zones()once and stores the per-sport payload underuser_profile('hr_zones')(overwrite each run);fetch_race_predictions()stores the single latest snapshot underuser_profile('race_predictions');build_hr_zones(db)/build_race_predictions(db)re-project both on every parse.src/garmin_fetch/ask.py—garmin-askCLI: a Pydantic AI agent with read-only DB tools (list_tables,table_schema,date_range,run_sql), acharttool, a statelessweathertool (Open-Meteo: short forecast + historical weather for days/locations the stored activity weather doesn't cover; per-day degC/mm/km/h; home coords fromGARMIN_HOME_LAT/GARMIN_HOME_LON, per-calllat/lonoverride), and — when a memory path is configured — long-term-memory tools (get_memory/remember_memory/forget_memory) over a JSON profile (agent_memory.json, envGARMIN_MEMORY_FILE; the agent stores only user-volunteered facts — anything queryable from the DB is forbidden). The interactive session also understands/clear(drop the conversation and start fresh) and/new(compact the conversation into one summary via the model and restart from it). Safety is by construction: connections open per-call withPRAGMA query_only, an authorizer denying write opcodes, and a statement gate (SELECT/WITH/EXPLAIN/PRAGMA only).Weatheris stateless — each call is one HTTP request, nothing stored, so it can only ever contextualise, never substitute for DB facts. Model is OpenAI-compatible — default cloud viaOPENAI_API_KEY/LLM_MODEL, orLLM_BASE_URL→ local Ollama. Tests use Pydantic AI'sTestModel, so they need no API key.- Charts are spec-based, never data-passed. The
chart(spec)tool takes a model-written JSON spec —{"sql": "...", "traces": [...], "layout": {...}}— re-runs the read-only query to validate columns and that each trace is constructible, and returnsOK: <spec> (query returned N rows)(it never returns the rows). Each trace is free-form: a"go"key names ANYplotly.graph_objectsclass (Scatter/Scattergl/Violin/Heatmap/Pie/Candlestick/...), with compact aliasesline/scatter/area/bar/pie/histogram/boxfor the common cases (mapped via_LEGACY_TRACES); data columns are referenced by name viax/y/zor anywhere as{"column": "<name>"}, and every other key is passed verbatim to the Plotly constructor (mode,marker,line,opacity,orientation,colorscale, ...). E.g.{"go": "Pie", "labels": {"column": "sport_type"}, "values": {"column": "hours"}}. The model embeds the returned spec verbatim in<chart>…</chart>;ask_web._render_chartre-runsspec["sql"]through the same gate and calls_build_chart_figure(spec, result)(shared with the tool's validator,_chart_spec_error). The closing</chart>is optional —_extract_chartsraw-decodes a JSON prefix. So the graph is entirely model-authored, but no raw data ever travels through the and reply; the DB stays the single source. src/garmin_fetch/ask_web.py—garmin-ask-webbrowser chat (Gradio) over_build_agent. Each session auto-persists toweb_session.json(envGARMIN_WEB_SESSION_FILE, overridable with--session FILE) after every turn and the last session is loaded back into the UI on startup (seededchatbot.value);_extract_chartsturns<chart>blocks intogr.Plots by re-running the spec's SQL through the read-only gate.src/garmin_fetch/trace.py— per-turn agent tracing (bothgarmin-askandgarmin-ask-web).append_turnappends one JSON line per turn toask_trace.jsonl(envGARMIN_TRACE_FILE): question, model, orderedsteps(tool calls w/ JSON args, token usage (result.usage(): input/output, cache read/write +cache_hit_ratio, requests, tool calls) — this surfaces DeepSeek's automatic prompt caching per turn), tool returns w/ outcome, retries, texts and final answer.garmin-tracerenders it;--tail N,--tool NAMEfilter,--fulldisables truncation.
Gotchas / constraints
- Do NOT add migration/backfill logic (e.g. seeding
metricsfrom typed tables, or rebuilding typed tables frommetrics). User explicitly wants none — don't propose or add it. Parsing into typed tables is a separate later step (now built). - Re-running a sync is idempotent — but not byte-identical. Day-keyed types skip stored dates that are final (their
fetched_atlanded on a later calendar day than the data). A day still in progress when captured —fetched_aton the data's own day — is re-fetched (overwritingmetrics) on the next run: the sync resumes from the oldest still-incomplete day via_nonfinal_dates()/stored_fetches(), so a day synced mid-day is refreshed once its data settles, and only those days are re-hit. Activities skip by id, but re-list the rollingfreeze_dayswindow (see below). - Parsing is incremental, marker-tracked — not full every time. Each raw row records the
fetched_atit was last parsed as (metrics.parsed_at,activities.summary_parsed_at/details_parsed_at/weather_parsed_at).build_daily_rows/build_activity_summaries/build_activity_details/build_activity_weatherre-parse only rows whose rawfetched_atchanged since that stamp (a NULL stamp = never parsed → parsed on first run). Sparse_FFILL_TYPES(lactate_threshold) re-parse the whole type when any row changed — carry semantics chain forward.force=True(CLI--parse --full) ignores the markers and rebuilds everything, restamping as it goes. Markers are set even for rows that parse to nothing, so empty/fetch-only rows are not re-scanned; fetch-only types (training_readiness,hydration, …) have no parser and are never marked. .envholds real credentials and is gitignored. Never print or commit them.GARMIN_START_DATE(currently2025-01-01) sets the backfill window; empty means "latest stored + 1" per type.- No commit policy needed — only commit when asked.
- Auth: first run may prompt for MFA code interactively; tokens cached in
~/.garminconnect. - DB file uses WAL mode;
garmin.db-shm/-walare runtime files. agent_memory.jsonandweb_session.jsonare runtime aids (LLM-facing, not DB tables) — fine to ignore/delete; never treat them as feature stores.