A Strava running coach you access by text message. Connect your Strava once, then text the bot for your runs, training stats, heart rate, and personal records — and it'll nudge you to lace up. It even follows up on what it remembers ("how are the knees since those back-to-backs?").
Built on Linq Blue and powered by Claude (Anthropic). Same open-source pattern as the ai-agent-example, linq-resy-agent, and granola-agent.
- Recent runs — "how were my runs this week?" → distance, pace, time per activity
- Training stats — this week, recent 4 weeks, year-to-date, all-time totals
- Heart rate — average & max HR per run, flags unusually high readings (never fabricates — says so when a run has no HR)
- Best efforts / PRs — fastest 1 mile, 5k, 10k, etc. across recent runs, with all-time PRs flagged 🏅
- Run reminders — "remind me to run every day" → a daily nudge + a personalized, context-aware check-in
- Builds context over time — remembers injuries, goals, races, PRs, and cross-training, and brings them up later
- Proactive — shows your real last run the instant you connect
User ──iMessage──▶ Linq Blue ──webhook──▶ Receiver Lambda ──SQS──▶ Processor Lambda ──▶ Claude (tool-use loop)
▲ (HMAC verify, (FIFO) │ │
│ read receipt + typing, │ ◀── tools ◀─────┘
│ enqueue ~150ms) │ strava_recent_activities
│ │ strava_activity_detail
User ◀─iMessage────────┴──────────────── Linq Blue ◀──────API──────────── │ strava_athlete_stats
│ strava_best_efforts
EventBridge (daily cron) ──▶ Reminder Lambda ──▶ personalized check-ins │ strava_athlete_profile
+ drains the waitlist when capacity opens ▼ set_run_reminder
DynamoDB (state, encrypted creds)
Three Lambdas:
- Receiver — verifies the Linq webhook HMAC, fires read-receipt + typing instantly, enqueues to SQS, and serves the Strava OAuth pages (
/auth/strava,/auth/strava/callback,/auth/setup). - Processor — SQS-triggered; runs the Claude tool-use loop against the Strava API and replies via Linq.
- Reminder — EventBridge cron; sends personalized check-ins to subscribers and drains the capacity waitlist.
State lives in a single DynamoDB table; Strava tokens are AES-256-GCM encrypted at rest.
- Node.js 20+
- An Anthropic API key
- A Linq Blue account + phone number and partner API token
- A Strava API application (Client ID + Secret)
- For deploy: AWS account + AWS SAM CLI
npm install
cp .env.example .env # fill in your keys
npm run dev # Express server on :3000
# in another terminal, expose it:
cloudflared tunnel --url http://localhost:3000 # (or: ngrok http 3000)Then point a Linq webhook at https://<your-tunnel>/linq-webhook:
linq webhooks create --url https://<your-tunnel>/linq-webhook --events message.receivedText your Linq Blue number to start.
npm run build:lambda # esbuild → dist/handlers/{receiver,processor,reminder}.js
cd infra
sam deploy --guided \
--parameter-overrides \
LinqApiToken=... AnthropicApiKey=... CredentialEncryptionKey=<64 hex> \
LinqAgentBotNumbers=+1XXXXXXXXXX \
StravaClientId=... StravaClientSecret=... \
LinqWebhookSigningSecret=...Generate the encryption key with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
The stack outputs an ApiUrl — create a Linq webhook pointing at ${ApiUrl}/linq-webhook, and set that domain as your Strava app's Authorization Callback Domain.
The
@anthropic-ai/sdkships as a Lambda layer. If you don't already have one in the account, runnpm run build:layerand pointAnthropicLayerArnat it.
- Create an app at strava.com/settings/api. Set the Authorization Callback Domain to your deployed API Gateway domain.
- New apps are capped at 1 connected athlete; self-upgrade to 10 in the dashboard.
- Beyond 10, submit your app for review (requires 10 connected athletes first).
When at capacity, the agent waitlists new users (and still sends them generic run nudges) instead of showing Strava's 403 page — then auto-texts them a connect link the moment a slot opens. Set the cap via the STRAVA_ATHLETE_QUOTA env var.
src/
├── handlers/
│ ├── receiver.ts # API Gateway: webhook (HMAC) + SQS enqueue + OAuth pages
│ ├── processor.ts # SQS: Claude tool-loop + Strava + reply
│ └── reminder.ts # cron: personalized check-ins + waitlist drain
├── claude/client.ts # system prompt, tools, tool-use loop (prompt-cached)
├── strava/
│ ├── client.ts # Strava API v3 client (OAuth + data endpoints)
│ ├── index.ts # token refresh + metric formatting + best efforts
│ └── types.ts # Strava type definitions
├── auth/ # OAuth, encrypted creds, magic links, waitlist, reminder registry
├── linq/client.ts # Linq Blue V3 API (messages, typing, read, reactions, contact card)
├── state/conversation.ts # conversation history + persistent user profiles
├── webhook/types.ts # Linq webhook payload types
└── db/dynamodb.ts # single-table DynamoDB helpers
infra/template.yaml # SAM: 3 Lambdas + DynamoDB + SQS (FIFO) + EventBridge
- Strava tokens AES-256-GCM encrypted at rest; secrets passed as SAM
NoEchoparams (never in the repo). - Inbound webhooks HMAC-SHA256 verified (
X-Webhook-Signatureover{timestamp}.{body}) — forged requests are rejected. - Magic links: 32-byte random, single-use, 15-minute TTL.
- Phone numbers redacted in logs.
The agent shares a Linq contact card so users save it as "Strava Coach" with the Strava logo (set via POST /v3/contact_card, shared once per chat via share_contact_card).
⚠️ Avatar format matters. Use a truecolor RGB(A) square PNG (≈512×512). Apple's contact-photo / iMessage avatar pipeline renders indexed/palette PNGs (colortype 3) inconsistently — they show on some devices and silently fall back to the gray monogram on others, which reads as "the logo disappeared." The canonical asset lives atpublic/strava-avatar.png(512×512, truecolor RGB). Verify a candidate with:python3 -c "import struct; d=open('img.png','rb').read(); print('colortype', d[25])" # want 2 (RGB) or 6 (RGBA), NOT 3Note: iOS caches contact photos per-device — after updating, users may need to tap the shared card → Update Contact once.
MIT — Linq Technologies Incorporated.
Powered by Strava. Built on Linq + Claude.