Files
amethyst/quartz/plans/2026-06-11-runstr-interop.md
T
Claude 6775ad8e62 docs: add RUNSTR interop research plan (kind 1301 + related events)
Catalogs every Nostr kind the RUNSTR app publishes/consumes, the exact
1301 tag dialect, the Supabase-migration caveats, and a phased plan for
Quartz event classes and Amethyst fitness screens.

https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
2026-06-11 20:12:30 +00:00

11 KiB
Raw Blame History

RUNSTR interop: Quartz events + Amethyst fitness screens

Research date: 2026-06-11. Source: RUNSTR-LLC/RUNSTR @ main (commit 398cdffab452c9e482b3ecf3e5b956b3c0fe1b7b), its docs/KIND_1301_SPEC.md, docs/ARCHITECTURE.md, and the companion RUNSTR-LLC/runstr-fitness-skill repo. Upstream spec context: NIP-101h PR nostr-protocol/nips#1937 (health metric kinds 13511399; not implemented by RUNSTR today).

1. The critical architecture finding

RUNSTR (React Native + NDK) is no longer a pure-Nostr app. Teams, clubs, chat, competitions, and global leaderboards migrated to Supabase. The current app:

  • builds and signs kind 1301 workout events in the NIP-101e dialect, but submits them to Supabase instead of relays (src/services/nostr/workoutPublishingService.ts:1-13,130-140,373-376);
  • still consumes kind 1301 from relays with intentionally lax parsing ("nuclear pattern": src/services/fitness/Nuclear1301Service.ts, src/services/competition/Competition1301QueryService.ts) for workout history import and club/event leaderboards;
  • keeps Nostr for identity (kind 0), social posts (kind 1 + reactions/reposts), encrypted workout backups (kind 30078), event discovery (kind 31923), WoT (kind 30382), and published leaderboards (kind 30150, external aggregator).

Consequences for us:

  1. Amethyst-published 1301s will be seen by RUNSTR (history import, club and event leaderboards aggregate members' relay 1301s).
  2. We should not expect new RUNSTR workouts to appear on relays from the current app — but historical RUNSTR 1301s, RUNSTR-iOS, and other NIP-101e clients do publish them.
  3. RUNSTR's global daily leaderboards/rewards require their Supabase submission + anti-cheat (v rolling code, wot_score) — not reachable via relays. Out of scope.

2. Event catalog — what to implement in Quartz

Quartz has no fitness kinds today (verified: no 1301/1351-1357/workout code anywhere). All of this is net-new. Suggested package: quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip101eFitness/ (name mirrors how RUNSTR refers to the draft; can be renamed if upstream numbering lands differently).

2.1 Kind 1301 — WorkoutRecordEvent (the core interop surface)

Regular (non-addressable) event, but RUNSTR always emits a d tag (UUID) and dedupes on it — parse it, don't rely on it. Authoritative dialect: docs/KIND_1301_SPEC.md in the RUNSTR repo.

Tags RUNSTR always emits (treat all as optional when parsing — their own parser validates nothing):

Tag Format Notes
d UUID string client-side dedupe id
title free text e.g. "Morning Run"
exercise lowercase verb running|walking|cycling|hiking|swimming|rowing|strength|yoga|meditation|diet|fasting
duration HH:MM:SS parser must also accept raw seconds
source gps|manual also seen: healthkit, runstr
client ["client","RUNSTR",version] 3-element form, not NIP-89
t Capitalized hashtag Running, Strength, … fallback Fitness

Conditional metric tags: ["distance", "5.20", "km"|"mi"] (2-decimal string), ["elevation_gain"/"elevation_loss", n, "m"|"ft"], ["calories", n], ["steps", n], ["avg_pace","MM:SS","min/km"|"min/mi"], ["split", kmNumber, "HH:MM:SS"] (cumulative), ["split_pace", n, seconds], ["avg_heart_rate"/"max_heart_rate", bpm] (spec-defined), strength: ["sets",n], ["reps",n], ["weight",n,"lbs"], ["weight_set", setNum, weight, "lbs"]; plus meditation_type, meal_type, meal_size, exercise_type, data_points, recording_pauses, workout_start_time (unix seconds string).

Competition/reward tags (parse, surface, never required): ["team", id|"self"], ["club", id], ["charity", id, name, lud16?], ["lightning", lud16], ["reward_destination", "user"|"charity"|"ppq"], ["challenge", slug], ["wot_score","0".."100"], ["v", rollingCode] (anti-bot, from kind-30150 note), verified/verification_* tags.

Content is plain text, never JSON (user notes / human-readable summary).

Unit handling for parsing (match RUNSTR's lax rules): default km/m/lbs when unit missing; mi×1609.344 m; ft×0.3048 m.

When Amethyst publishes, emit the strict canonical form above so RUNSTR's leaderboard aggregation (fastest_time needs distance + duration; most_distance sums distance; participation counts events) scores us correctly.

2.2 Kind 30078 — RUNSTR encrypted workout backup (clean interop win)

NIP-78 app-data event, d = "runstr-workout-backup". Plaintext metadata tags: ["client","RUNSTR",v], ["encrypted","nip44"], ["compression","gzip"], ["backup_version","1"], ["workout_count",n], optional habit_count, journal_count, date ranges. Content = JSON → gzip → base64 → NIP-44 self-encrypt (to own pubkey). Decode: nip44-decrypt → base64 → gunzip. Backup relays: damus + nos.lol. Quartz already has NIP-44 and NIP-78 machinery; we need the gzip step (JVM/Android trivial; check iOS source set) and a RunstrWorkoutBackupEvent wrapper. This lets Amethyst import a user's entire RUNSTR history with just their key — the highest-leverage interop feature, immune to the Supabase migration.

2.3 Kind 31923 — RUNSTR fitness events (already-implemented base)

Quartz has NIP-52 (nip52Calendar, kind 31923 time-based calendar event). RUNSTR layers extra tags on it (src/services/events/RunstrEventPublishService.ts): discovery marker ["t","runstr"] (their filter key) + activity-type and distance hashtags, and RUNSTR-specific tags: scoring (fastest_time|most_distance|participation), payout, join_method, duration_type, distance (value+unit), pledge_cost, pledge_destination, captain_lightning_address, entry_fee, prize_pool, suggested_donation, activity_type, image, team_competition. Plan: extension accessors on the existing calendar event (or a thin RunstrEventTags helper) rather than a new kind. Note their kind-31925 RSVP is typed but dead code — joining is local/Supabase; don't build RSVP interop expecting RUNSTR to read it.

2.4 Kind 30150 — published leaderboard note (read-only)

External aggregator (pubkey 611021eaaa2692741b1236bbcea54c6aa9f20ba30cace316c3a93d45089a7d0f, d = "runstr-leaderboards", on damus + nos.lol, refreshed ~5 min). Content is JSON: {v:1, updatedAt, competitions:[{id,name,activityType,scoringMethod, status,entries:[{r,p,n,s,w}]}]} (rank/npub/name/score/workout-count). A small RunstrLeaderboardEvent (addressable) + Jackson DTO gives Amethyst live RUNSTR leaderboards for free. This is also where the rolling v anti-bot code is published.

2.5 Kind 30000 participant lists (already-implemented base)

Season participant lists are plain NIP-51 follow sets authored by the admin pubkey (e.g. d = "runstr-season-2-participants"). Quartz nip51Lists already parses these; only the well-known author/d-tag constants are needed.

2.6 Explicitly skip (dormant post-migration)

Kinds 33404 (team), 30100/30101 (league/event), 31013 (competition), 30002 (participant list), 1104/1105 (join requests), 9321/37375 (nutzap/wallet — they reverted to LNURL/NWC). Code exists in their repo but nothing publishes or reads them anymore. Their custom notification kinds 11011103 are backend-published and Supabase-coupled; revisit only if their backend keeps emitting them. 21301 (paid anti-cheat request) is RUNSTR-business-specific — skip.

NIP-101h (kinds 13511399, NIP-44-encrypted health metrics, nips#1937) has no code in RUNSTR today; implement later as its own nip101hHealth package if/when we want health-profile interop.

2.7 Quartz mechanics (per codebase survey)

  • Event class per kind following e.g. nip25Reactions/ReactionEvent.kt (regular) / nip53LiveActivities/streaming/LiveActivitiesEvent.kt (addressable): KIND constant, build() via eventTemplate + TagArrayBuilder DSL, tag-accessor functions, @Immutable.
  • Register each kind in quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt.
  • Unit tests with real RUNSTR event JSON captured from relays (damus/primal/ nos.lol) as fixtures; amy can fetch live samples for interop checks.

3. Amethyst screens

Survey of existing UI says: no fitness UI exists; Vico charts (v3.1.0) is already a dependency (used in the notifications summary chart at amethyst/.../notifications/chart/ShowChart.kt); new kinds render via the when(event) dispatch in amethyst/.../ui/note/NoteCompose.kt (RenderNoteRow) with per-type composables under ui/note/types/.

Phased UI plan (ViewModels in commons/.../viewmodels/, shared cards in commons per commons/ARCHITECTURE.md; screen scaffolding/nav per platform):

  1. Workout card in feedsRenderWorkoutRecordEvent showing activity icon, title, distance/duration/pace/elevation/calories chips, and splits. This alone makes Amethyst display every 1301 on the network.
  2. Profile "Fitness" tab / workout history feedAdditiveFeedFilter over kind 1301 by author; stats header (weekly distance, streak) with Vico.
  3. Workout composer — manual entry first (type, duration, distance, notes), publishing canonical 1301s. GPS tracking is a much bigger, Android-only follow-up (foreground service, location permissions).
  4. RUNSTR events discovery — feed of kind 31923 with t=runstr, detail screen showing scoring/entry-fee/prize tags, and a client-side leaderboard computed from participants' 1301s using RUNSTR's scoring rules (and/or the pre-computed 30150 note).
  5. Backup import — settings action: fetch 30078:user:runstr-workout-backup, decrypt, and ingest workouts into LocalCache (optionally re-publish as 1301s with user consent).

4. Relays & constants

  • RUNSTR defaults: wss://relay.damus.io, wss://relay.primal.net, wss://nos.lol; backups on damus + nos.lol.
  • Admin/aggregator pubkey: 611021eaaa2692741b1236bbcea54c6aa9f20ba30cace316c3a93d45089a7d0f.
  • WoT assertions: kind 30382 from Brainstorm (3eaeb02c4f94a0aabf016527c35222a2ede49b3981df32aa9096f5db2dad58e2) on wss://nip85.brainstorm.world — only needed if we ever want RUNSTR reward parity; skip initially.

5. Suggested implementation order

  1. nip101eFitness package: WorkoutRecordEvent (kind 1301) + tag classes + EventFactory registration + fixture tests.
  2. Feed card (phase-1 UI) — immediate visible interop.
  3. RunstrWorkoutBackupEvent (30078 dialect) + import flow.
  4. RUNSTR tag accessors on NIP-52 31923 + events discovery screen.
  5. RunstrLeaderboardEvent (30150) + leaderboard rendering.
  6. Workout composer (manual), then evaluate GPS tracking as its own plan.