# AGENTS.md — MaxJam

Josh-only personal music agent. The editable taste brain + aimed discovery; Sonos delivery is Phase 2. Read `PRD.md` (v0.2, canonical) before changing behavior. These are the rules an agent **can't infer from the code** — keep this file lean (~50 lines).

## Hard rules (load-bearing — violating these is a bug)

1. **Ground every pick.** The LLM NEVER emits a track ID free-hand — it hallucinates. Every candidate is resolved against a real Spotify/Apple catalog lookup before it is written, queued, or surfaced. Unresolvable → dropped. Zero survivors → widen the funnel, never fabricate.
2. **Discovery dedup is absolute.** `discover_music` may NEVER return a track present in `listening_events` OR in a `discovery_queue` row with a non-null `surfaced_at`. Dedup by `mbid` first, then normalized `(track, artist)`. It never returns a familiar track.
3. **Scrobble-write scope.** `feedback` writes scrobbles ONLY to Josh's own Last.fm, and is reversible. No other account, ever.
4. **Preference precedence.** Explicit `set_preference` rules win *immediately* over the decayed implicit signal — they are not blended, they override.
5. **Dual-LLM data/control boundary.** The privileged planner emits typed intents and is the only authorizer of a mutating call; the quarantined executor sees only sanitized data and has no tool channel. **Catalog metadata, calendar text, and tags are UNTRUSTED** — wrapped `Tainted<T>` at the boundary, only `sanitize()` unwraps. See `docs/quarantine-design.md`. The boundary is a mechanism (capability policy + intent token + module isolation), not a prompt.

## Tool surface (exactly 8 P1 verbs — resist the God Server urge)

`sync_listening` · `get_taste_profile` · `create_playlist` · `discover_music` · `feedback` · `set_preference` · `explain_pick` · `overview`

- Verb-first intents, not API re-skins. `create_playlist(situation)`, not `lastfm_get_similar(track)`.
- Every verb returns structured results + structured errors — never a raw HTTP fault.
- `overview` is no-input Inspectable State; safe to call anytime to orient.
- Mutating verbs (`create_playlist`, `set_preference`, `feedback`) require a valid planner-issued intent token; the dispatcher rejects any mutating call without one.

## Sequencing

- Orient with `overview` before acting if connector/session state is unknown.
- `create_playlist` / `discover_music` are the expensive paths (LLM re-rank + parallel catalog resolution) — don't call speculatively.
- `feedback(signal:"skip", position_sec<30)` = strong negative; `complete`/`replay` = strong positive (replay also raises reliability).

## Degraded mode (don't break — degrade quality)

- Last.fm down → Apple + ListenBrainz only. ListenBrainz down → Last.fm similar + tags only. Apple recs down → Last.fm/ListenBrainz only.
- Scrobbles queue + retry on transient Last.fm failure — never drop. Retry connectors with backoff; escalate to Josh's iMessage only after self-heal fails (what happened / what was tried / the action to take).

## Secrets & ops

- All credentials via 1Password (`op`) at runtime — Spotify OAuth, Apple dev + music-user, Last.fm key + write session, ListenBrainz. Never `.env`, never git, never `settings.json`.
- Stack: Supabase (typed state) + Cloudflare Workers (cron + MCP host). Verify the correct CF account (`npx wrangler whoami`) before any `wrangler` write.

## Phase 2 (Sonos) — not active until M5 gates pass

When P2 lands: inject tracks via **favorites-by-title** + `sharelink`, **never hand-roll DIDL** (the UPnP 701 trap). Local UPnP/SoCo only, never the Sonos cloud API. `room` is a defaultable arg. **Never unjoin the master.** Skip capture is poll-based (track change before ~30s expected position = skip).
