# Fulcra Media

> Import media history (Watched / Listened / Read) into a Fulcra account via the `fulcra-media` CLI. Use when the user wants to track Netflix, Trakt, Spotify, Last.fm, Apple Podcasts, Letterboxd, Goodreads, YouTube, Plex/Jellyfin, or any other media source listed in `fulcra-media import --help`.

- **Type:** Skill
- **Install:** `agentstack add skill-ashfulcra-fulcra-tools-fulcra-media`
- **Verified:** Pending review
- **Seller:** [ashfulcra](https://agentstack.voostack.com/s/ashfulcra)
- **Installs:** 0
- **Category:** [Databases](https://agentstack.voostack.com/c/databases)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [ashfulcra](https://github.com/ashfulcra)
- **Source:** https://github.com/ashfulcra/fulcra-tools/tree/main/packages/media-helpers/skills/fulcra-media
- **Website:** https://fulcradynamics.com

## Install

```sh
agentstack add skill-ashfulcra-fulcra-tools-fulcra-media
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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) | `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  --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 `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](https://github.com/fulcradynamics/fulcra-api-python) CLI; `fulcra-media` has no auth
subcommand of its own.

---

## Quick orientation — what's already set up?

Always start with:

```bash
fulcra-media status
```

The output (JSON) tells you:
- Which annotation definitions exist (`watched_definition_id`, `listened_definition_id`, `read_definition_id`) — if `null`, run `fulcra-media bootstrap` to 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**:

```json
{
  "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` → success
- `ok: false` + `exit code 2` → failure. Inspect `errors[].stage` (one of `setup`, `auth`, `args`, `fetch`, `snapshot`):
  - `setup` → run `fulcra-media bootstrap` first
  - `auth` → creds file missing or invalid; direct the user to the wizard
  - `args` → flag parse error (bad timezone, missing path)
  - `fetch` → upstream API failed; the `message` is scrubbed of credential params
  - `snapshot` → 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 `webhook` server emits its own `bind` / `ready` / `shutdown` stages 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:

```bash
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):

```bash
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

```bash
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

```bash
fulcra-media bootstrap  # creates Watched, Listened, Read defs
```

Idempotent — subsequent calls are no-ops once defs exist.

### Reset everything (rare)

```bash
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:

```python
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:

1. **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](https://github.com/ashfulcra)
- **Source:** [ashfulcra/fulcra-tools](https://github.com/ashfulcra/fulcra-tools)
- **License:** MIT
- **Homepage:** https://fulcradynamics.com

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** yes
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: flagged — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-ashfulcra-fulcra-tools-fulcra-media
- Seller: https://agentstack.voostack.com/s/ashfulcra
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
