Install
$ agentstack add skill-ashfulcra-fulcra-tools-fulcra-media Open-source listing, not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Dangerous shell/eval execution.
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ● Shell / process execution Used
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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 --check-only --json then read would_post (file-based importers also take their `/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) | wouldpost: 0 (--check-only skips the POST; costs an API readback but no ingest) | **IMPORT** — wouldpost > 0 means there is new media; run fulcra-media import --json for real | | Already synced? | fulcra-media status and read watermarks[""] | 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:"] / watermarks["generic-rss:"], 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 wouldpost probe above | **IMPORT** — ISO watermark cold-start or stale; run the real import to advance it. For the no-watermark importers, re-check via wouldpost`; 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 --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|sentinel:YYYY|keep|ask. Twin-dedup: --twin-policy ask|auto-discard|keep. | | 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 | | Goodreads | goodreads --user-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 | | Pocket | 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 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 | {: {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.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 watermark
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: ashfulcra
- Source: ashfulcra/fulcra-tools
- License: MIT
- Homepage: https://fulcradynamics.com
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.