fulcra-media — Import a user's media history into Fulcra
fulcra-media is a Python CLI that imports media events (watches, listens, books read) into Fulcra as DurationAnnotations. Each import is idempotent (re-runs are safe), and every command supports a --json flag for machine-readable output and a --check-only dry-run.
This skill helps you (the AI agent) run imports on the user's behalf, schedule periodic syncs, and interpret the results.
Runtime-agnostic. Everything below is shell I/O. This skill works the same in Claude Code, OpenCode, Codex, Gemini CLI, Copilot CLI, or any agent runtime that can execute a subprocess. No Claude-Code-specific tools are required — the only contract is fulcra-media on PATH and the JSON envelope schema.
Where to start — the re-entrancy probes
Before running an import, probe how far this user already got. The states are a prefix of the flow —
authed? → defs bootstrapped? → nothing pending? → already synced? — so enter at the first
probe that fails (per the repo's skill-quality pattern, docs/skill-quality-pattern.md). Every
state is safely re-enterable: bootstrap is idempotent, and each import is idempotent (deterministic
source_ids + the watermark layer), so re-probing or re-importing never double-counts. The rows below
consolidate the probe concepts used later in this skill (the --check-only/would_post probe in
"Is there anything new to post?" and the heartbeat contract's "Inspect first / Probe before paying"
steps) into one ordered entry point — see those sections for the fuller treatment.
| Probe (run in order) | Command | Passes when | If it fails, enter at |
|---|---|---|---|
| Authed? | fulcra auth print-access-token |
exits 0 and prints a non-empty token (the CLI mints/refreshes it; FULCRA_ACCESS_TOKEN in the env also satisfies this) |
AUTH — tell the user to run fulcra auth login (interactive browser flow); see "Fulcra Life API auth" below |
| Defs bootstrapped? | fulcra-media status |
the JSON's watched_definition_id / listened_definition_id / read_definition_id are non-null for the category you're importing (Watched for netflix/trakt/youtube/…, Listened for lastfm/deezer/spotify/…, Read for goodreads) |
BOOTSTRAP — run fulcra-media bootstrap (idempotent; no-op once defs exist) |
| Nothing pending? | fulcra-media import <name> --check-only --json then read would_post (file-based importers also take their <path>/URL argument — e.g. netflix, youtube, spotify-extended, spotify-ifttt, apple-takeout, generic-csv take a file path; generic-rss takes a feed URL — so --check-only still needs it) |
would_post: 0 (--check-only skips the POST; costs an API readback but no ingest) |
IMPORT — would_post > 0 means there is new media; run fulcra-media import <name> --json for real |
| Already synced? | fulcra-media status and read watermarks["<name>"] |
the pass-criterion depends on the importer's watermark class (only ISO watermarks are ever WRITTEN — watermarks.py also defines a snapshot shape, but no importer currently calls it): API-poll importers (lastfm, deezer, letterboxd, goodreads, generic-rss) store an ISO 8601 timestamp — pass when it's present and recent (note goodreads and generic-rss key theirs as watermarks["goodreads:<user_id>"] / watermarks["generic-rss:<feed_url>"], not the bare name). Everything else — apple-podcasts, the one-shot GDPR/export uploads (spotify-extended, spotify-ifttt, youtube, apple-takeout, netflix), trakt, and generic-csv — never advances a watermark, so this probe is N/A for them: fall back to the would_post probe above |
IMPORT — ISO watermark cold-start or stale; run the real import to advance it. For the no-watermark importers, re-check via would_post; for the export uploads specifically, re-run only when the user hands you a fresh export |
All probes pass with would_post: 0 and a fresh watermark → this importer is already synced; move on
to the next source or tell the user they're up to date. A brand-new user fails the first probe.
The fulcra … (not fulcra-media …) auth probe belongs to the separate
fulcra-api CLI; fulcra-media has no auth
subcommand of its own.
Quick orientation — what's already set up?
Always start with:
fulcra-media status
The output (JSON) tells you:
- Which annotation definitions exist (
watched_definition_id,listened_definition_id,read_definition_id) — ifnull, runfulcra-media bootstrapto create them - Per-importer watermarks (
watermarks["lastfm"],watermarks["deezer"], ...) — empty means cold-start, ISO timestamp means incremental - Service-tag UUIDs the user has already created
If the user is brand new, walk them through fulcra-media setup (interactive picker) — but don't run setup non-interactively, it expects stdin input from a TTY.
The import envelope — the only output you need to parse
Every fulcra-media import <importer> --json writes exactly one line of JSON to stdout. Schema is stable and append-only:
{
"importer": "lastfm",
"ok": true,
"total": 248,
"skipped_existing": 0,
"posted": 248,
"verified": 248,
"since_watermark": "2026-05-16T22:00:00+00:00",
"new_watermark": "2026-05-17T08:42:00+00:00",
"would_post": null,
"errors": []
}
ok: true+exit code 0→ successok: false+exit code 2→ failure. Inspecterrors[].stage(one ofsetup,auth,args,fetch,snapshot):setup→ runfulcra-media bootstrapfirstauth→ creds file missing or invalid; direct the user to the wizardargs→ flag parse error (bad timezone, missing path)fetch→ upstream API failed; themessageis scrubbed of credential paramssnapshot→ a snapshot-source importer (e.g.apple-podcasts) couldn't read the on-device DB (stalled/inaccessible); check the file path and permissions- (the long-running
webhookserver emits its ownbind/ready/shutdownstages on its status lines — those are not import-envelope error stages)
--check-only adds a non-null would_post integer (how many events would post) and skips the actual POST. Use this for cheap "is there anything to import?" probes.
since_watermark / new_watermark describe the incremental cursor. On the first run both are null. On subsequent runs since_watermark reflects what was passed (watermark - overlap_hours for services with reorder hazard); new_watermark is the high-water mark from the just-completed run, written back to state.
Never parse stdout in human mode — only the --json envelope is a stable contract.
Importer roster (what works today)
Run fulcra-media import --help for the live list. As of this skill version:
| Command | Category | Auth | Notes |
|---|---|---|---|
lastfm |
listened | API key + username | Universal music sidecar — covers Tidal/Apple Music/Amazon Music/SoundCloud/Pandora/YouTube Music via in-app or Web Scrobbler. Public scrobbles only. |
deezer |
listened | OAuth access token | Direct API, no per-call cap. Cleaner than Spotify. Manual token mint. |
spotify-extended |
listened | GDPR zip | Full history; one-shot, slow to arrive. |
spotify-ifttt |
listened | Legacy GDrive zip | Pre-Extended-API backfill; cross-applet dedup native. |
trakt |
watched | OAuth | Covers Apple TV+ via Universal Trakt Scrobbler. Cluster handling: `--clusters drop |
netflix |
watched | GDPR CSV | Slim (in-app) or rich (full GDPR) — auto-detected. |
apple-podcasts |
listened | Local SQLite | macOS only. Use apple-podcasts-timemachine for snapshot recovery. |
apple-takeout |
watched | privacy.apple.com CSV | Apple TV Playback Activity. |
letterboxd |
watched | Username | Public RSS. |
goodreads |
read | User ID | Public RSS of the 'read' shelf. |
youtube |
watched | Google Takeout zip | watch-history.json. Recurring exports every 2 months. |
generic-csv |
watched/listened | n/a | Any column-mapped CSV. |
generic-rss |
watched/listened | n/a | Any RSS/Atom feed. |
(server) webhook |
watched | bearer token | Long-running HTTP receiver for Plex/Jellyfin push. Not an import subcommand. |
Recipes
Daily cron / agent-driven incremental sync
Per-importer pattern:
RES=$(fulcra-media import lastfm --json)
echo "$RES" | jq -r '
if .ok then "lastfm: +\(.posted) (watermark→\(.new_watermark // "n/a"))"
else "lastfm FAILED: \(.errors[0].stage): \(.errors[0].message)" end
'
For multi-service runs, iterate over the user's configured importers (anything in state.watermarks is fair game):
fulcra-media status | jq -r '.watermarks | keys[]' | while read importer; do
fulcra-media import "$importer" --json
done
The watermark layer makes each run cheap when nothing's new (skipped_existing will trend up, posted will be 0).
"Is there anything new to post?" — without paying ingest cost
fulcra-media import lastfm --check-only --json | jq '.would_post'
# > 14 means 14 events would land; 0 means nothing new
Useful for high-frequency polling (every minute) without ingest churn — only run the real import when would_post > 0.
Bootstrap a fresh user
fulcra-media bootstrap # creates Watched, Listened, Read defs
Idempotent — subsequent calls are no-ops once defs exist.
Reset everything (rare)
fulcra-media reset --confirm # soft-deletes all three defs, clears watermarks + twin cache
⚠️ Fulcra has no per-event delete. Events under reset-soft-deleted defs stay visible in queries forever (Fulcra limitation). The next bootstrap creates fresh defs with new UUIDs, so future imports namespace cleanly. Use this only after a meaningful pipeline change you want to re-run against (e.g. a cluster-policy change).
Picking an importer — decision tree
User says they want to track X →
| User mentions | Run |
|---|---|
| Spotify (recent) | lastfm (if scrobbling) else spotify-extended (one-shot full history) |
| Apple Music / Tidal / SoundCloud / Pandora / YouTube Music / Amazon Music | lastfm (with Web Scrobbler browser extension if needed) |
| Netflix | netflix (slim CSV from privacy settings, or full GDPR export) |
| Hulu / Disney+ / Max / Prime Video / Peacock | No direct path — use trakt for ongoing, GDPR-export-and-generic-csv for backfill |
| Apple TV+ | trakt (with Universal Trakt Scrobbler browser ext) for ongoing; apple-takeout for backfill |
| YouTube (videos) | youtube against the Takeout watch-history.json (recurring 2-month exports) |
| Apple Podcasts | apple-podcasts (macOS only); add apple-podcasts-timemachine for replay recovery |
| Letterboxd | letterboxd --username <user> |
| Goodreads | goodreads --user-id <id> |
| Workouts (Strava etc.) | Not currently wired — workouts will land in Fulcra's native workout data type in a future revision. The strava importer module is still in the repo (frozen for rewrite); don't suggest it yet. |
| Plex | webhook (run the receiver, point Plex webhooks at it) |
| Jellyfin | webhook (run the receiver, point jellyfin-plugin-webhook at it) |
| Anything else with a CSV they got somewhere | generic-csv with column flags |
| Anything else with an RSS feed | generic-rss |
| Service is dead (shut down 2025-07). Redirect to whatever the user migrated to (Readwise Reader / Instapaper / Raindrop.io). |
Wizards — when to invoke them
fulcra-media wizard <name> prints onboarding instructions for the given service. The output is plain text for the user, not for you. Don't try to parse it. If the user asks "how do I set up X," just call fulcra-media wizard X and show them the output.
Available wizards: netflix trakt apple-podcasts spotify spotify-ifttt apple-takeout ifttt pipedream lastfm deezer letterboxd goodreads youtube plex jellyfin.
The interactive fulcra-media setup walks the user through category-picking and then drops them into the right wizard. Only invoke setup from a TTY — it expects keyboard input.
Credential file locations
All creds live under ~/.config/fulcra-media/ with mode 0600:
| File | For | Shape |
|---|---|---|
state.json |
All importers | {watched_definition_id, listened_definition_id, read_definition_id, tag_ids, watermarks} |
lastfm.json |
Last.fm | {username, api_key} |
deezer.json |
Deezer | {access_token} |
trakt.json |
Trakt | OAuth blob managed by the importer |
twin_cache.json |
All | {<content_fingerprint>: {source_id, importer, start_time, confidence}} for cross-batch dedup |
Don't echo these files' contents to stdout — especially in --json mode where logs may be captured. The CLI scrubs auth-bearing URL params from exception messages via safe_exc_message, but the raw creds files are sensitive.
Periodic invocation for agentic workers
Recommended cadence:
| Importer | Cadence |
|---|---|
lastfm, deezer, trakt |
hourly (API + watermark) |
letterboxd, goodreads, generic-rss |
daily (RSS feeds change slowly) |
apple-podcasts |
hourly (cheap — local SQLite snapshot) |
netflix, spotify-extended, apple-takeout, spotify-ifttt, youtube |
on-demand (user-uploaded zips / recurring Takeouts) |
webhook (Plex/Jellyfin) |
persistent service (launchd / systemd) |
A simple agent driver:
import json, subprocess
for importer in ("lastfm", "deezer", "trakt"):
res = subprocess.run(
["fulcra-media", "import", importer, "--json"],
capture_output=True, text=True,
)
env = json.loads(res.stdout) if res.stdout.strip() else {"ok": False}
if not env.get("ok"):
notify_user(f"{importer} failed at {env.get('errors', [{}])[0].get('stage')}")
elif env.get("posted"):
log(f"{importer}: +{env['posted']} (now at {env['new_watermark']})")
The webhook receiver is a long-running process — don't poll it; start it once and supervise it (launchctl, systemd, pm2, whatever).
Running on a heartbeat (Hermes, openclaw, OpenHands, and friends)
Several agent runtimes natively support recurring/scheduled wake-ups:
| Runtime | Mechanism | Notes |
|---|---|---|
| Hermes Agent (NousResearch) | First-class heartbeat primitive — recurring wake-ups that fire a bounded micro-action and report back. Also has built-in cron. |
The phrase "heartbeat" in NousResearch's sense means scheduled wake-up with fresh agent context. |
| openclaw | First-class Cron jobs tool (alongside bash/process/read/write/edit). Skills live at ~/.openclaw/workspace/skills/<skill>/SKILL.md. |
Drop this SKILL.md into that path verbatim. |
| OpenHands (formerly OpenDevin) | Sandboxed automations — scheduled tasks. Loader auto-picks up SKILL.md, AGENTS.md, CLAUDE.md, GEMINI.md. |
Bash sandboxing is built-in. |
| Claude Code / Aider / Cursor / Continue.dev | No native heartbeat — use host cron / launchd / systemd-timer. |
These read AGENTS.md / CLAUDE.md from the repo root. A copy of this skill lives at AGENTS.md for that reason. |
| Letta / MemGPT | Different meaning — request_heartbeat: bool is an intra-turn flag that keeps the tool loop going within a single user turn, not a scheduler. Still needs an external scheduler to run fulcra-media periodically. |
Don't conflate. |
| SmolAgents / AutoGen / CrewAI / LangGraph | Function-calling orchestration; no native scheduler. | Run from host cron, or wrap calls in a single execute_code tool. |
The recurring-task contract
When running this skill on a heartbeat (any runtime), the action you fire each tick should be bounded, idempotent, and stateful in this exact shape:
Inspect first.
fulcra-media status(cheap, local, no API) tells you which importers have watermarks and how stale each one is. Skip importers whosewatermarks[<name>]is fresher than their recommended cadence.Probe before paying. For each candidate importer, fire
fulcra-media import <name> --check-only --jsonand readwould_post. If 0, skip. This costs an API readback but no ingest.Import when warranted.
fulcra-media import <name> --json. Parse the envelope. Onok: false, surfaceerrors[0].stageto the user via whatever channel the runtime provides — don't retry mechanically from inside the heartbeat tick.Cap per-tick wallclock. A single heartbeat should not import every source on every tick. Round-robin or pick whichever importer is most stale; ingestion of a large historical zip (
spotify-extended,netflixfull GDPR) is a one-shot, not a heartbeat job — surface those as "user-uploaded" and run them ad-hoc.Lock if your runtime can re-enter. If the runtime can fire two heartbeats concurrently (it generally shouldn't), put a flock around the import:
flock -n /tmp/fulcra-media.lock fulcra-media import lastfm --jsonReport status to wherever your runtime expects it. Hermes has a default "consolidate memory / write status file" maintenance heartbeat — write the last-tick envelope summary there. openclaw cron jobs can pipe to its sessions log. For host cron, route stdout/stderr to a logfile the next tick can read.
A heartbeat-tick template
The CLI already manages its own per-importer watermarks, so a heartbeat tick can be small:
#!/usr/bin/env bash
# Single heartbeat tick. Idempotent; safe to fire as often as your runtime allows.
# The CLI's watermark layer guarantees no double-imports — re-runs are cheap.
set -euo pipefail
LOG=/var/log/fulcra-media-heartbeat.log
for importer in lastfm deezer trakt apple-podcasts letterboxd goodreads; do
# Cheap probe — no ingest cost, just an API readback.
pending=$(fulcra-media import "$importer" --check-only --json | jq '.would_post')
[ "$pending" = "0" ] && continue
fulcra-media import "$importer" --json >> "$LOG" 2>&1
done
Drop that in a cron job (openclaw), heartbeat (Hermes), automation (OpenHands), or host cron/launchd — same script, same result. Portable across BSD (macOS) and GNU (Linux) because it leans on the CLI for all timestamp arithmetic.
Common failure patterns
ok: false, errors[0].stage = "auth"
Creds file missing or expired. Tell the user to run fulcra-media wizard <importer>. For OAuth-based importers (Trakt, Deezer), the wizard explains the manual token mint flow.
ok: true, verified: 0, posted > 0
Fulcra's ingest-to-query indexing lag (seconds to minutes for bulk imports). This is not a failure — events are accepted, just not yet visible in readback. The next --check-only will report 0 to post.
posted > 0 but events don't appear in fulcra get-records DurationAnnotation
Three possible causes:
- Indexing lag (wait 1-5 min)
- You're querying a different annotation def — check
fulcra-media statusto confirm the active def UUID matches what your query expects - Source-id collision with an older soft-deleted def — events still exist but their source_id points at a deleted def. Either filter client-side or
fulcra-media reset --confirm+bootstrap+ re-import.
Trakt import shows thousands of events on the same 1-2 days
Trakt stamps historical backfill with the user's signup date when the source service didn't provide a real timestamp. The importer detects clusters (default --cluster-threshold 5) and offers handling: --clusters drop (discard), --clusters sentinel:2015 (shift to Jan 1 2015 so they don't pollute current data), --clusters keep (leave at signup-day, low confidence). On a TTY the default is interactive ask.
Fulcra Life API auth
The CLI shells out to fulcra auth print-access-token (from the fulcra-api package) to mint tokens. If a user has never logged in, that command will fail with "credentials not found" — tell them to run fulcra auth login (interactive browser flow).
Token override: FULCRA_ACCESS_TOKEN=... skips the shell-out. Useful for non-interactive agents that have a long-lived token via another route.
Architectural references (for the curious agent)
~/Developer/FulcraMediaHelpers/docs/superpowers/research/2026-05-17-media-service-pathways.md— full landscape of media services and the recommended pathway per service~/Developer/FulcraMediaHelpers/docs/superpowers/specs/2026-05-16-fulcra-media-helpers-design.md— original design doc~/Developer/FulcraMediaHelpers/docs/superpowers/plans/2026-05-17-lastfm-and-periodic-architecture.md— the periodic-update + watermark architecture- Sibling project
fulcra-csv-importer(/<repo>/skills/fulcra-csv/SKILL.md) — general CSV → any Fulcra annotation, used internally bygeneric-csv
Don't
- Don't run
setupnon-interactively — it expects TTY input - Don't re-implement watermark logic in the agent — let the CLI manage
state.json - Don't parse human-mode stdout — only
--jsonis a stable contract - Don't print creds files to stdout — they're 0600 for a reason
- Don't soft-delete the user's annotation defs without explicit confirmation — events under deleted defs stay visible (Fulcra has no per-event delete)
- Don't assume an importer maps to a single category —
generic-csvandgeneric-rssare caller-specified; check--help