Skip to content

Latest commit

 

History

History
450 lines (348 loc) · 21.3 KB

File metadata and controls

450 lines (348 loc) · 21.3 KB

AI DJ

spotatui can pick music for you. It reads the listening history it already collects locally, and holds a conversation with a model that can search the catalogue, look at your queue, and queue tracks. It can also just talk: ask it what you have been listening to, or answer a question it asks you before it plays anything.

There are two ways to use it, and they share everything below the model — including the tool list, so both can do exactly the same things:

MCP server In-TUI DJ (this page)
Where you talk Your coding agent (Claude Code, Codex, …) A DJ screen inside spotatui
Needs an API key No Only for the API backends
Build with --features mcp-server --features ai-dj
Tools The full set, driven by your agent The same set, driven by spotatui

Use the MCP server if you would rather DJ from a window you already have open; one command sets it up, see docs/mcp-setup.md.

How a turn works

A turn is up to four steps. Each step the model either says something, asks for tools to be run, or both; spotatui runs what it asked for and hands back the results, so it can look something up before committing. A step with words and no tool calls ends the turn — which is what an ordinary conversational reply is.

you   something chill for focusing, nothing I already own
dj    Let me see what you've been on lately.
      · get_listening_history → 30 days, 412 plays
      · search_tracks → Says — Nils Frahm [spotify:track:…] [new]
dj    Six ambient and modern-classical picks, none already yours.
      · queue_tracks → Queued 6 track(s):

you   actually more beats, less piano
dj    Downtempo, or closer to house tempo?

The tools are the same table the MCP server publishes, so anything added there the in-TUI DJ can use too. The step cap matters most on an agent CLI, where every step is a fresh subprocess: a four-step turn costs four process launches and four times the quota of one. The …thinking (2/4) row tells you where a slow turn is.

Two paths deliberately never stop to ask a question, because nobody is watching the screen: the auto-queue refill and the vibe shift. They get two steps and are told to queue immediately.

Why the DJ is home-grown rather than Spotify's. Spotify's own AI DJ cannot be driven through the public API (issue #196), and Spotify restricted /recommendations to apps that already held extended quota on 2024-11-27. spotatui users register their own client ID, so that endpoint returns 403 for them. The model is the recommender here; Spotify and YouTube are only catalogue and player.


Quick start

cargo run --features ai-dj

Press Ctrl+J to open the DJ, type what you want, press Enter.

Out of the box the DJ uses whichever coding agent you already have installed, so there is no API key to configure. The default is claude -p.

The first time you open the DJ it asks which AI to use and which of that AI's models, because the default backend spends the coding subscription you already pay for, and the heaviest model exhausts a Claude Pro plan in a handful of turns. See Which AI, which model. Press Esc to keep whatever is already configured and it will not ask again; Ctrl+G reopens the picker whenever you want to change it. A config that already names a backend, a model or a key is left alone and never asked at all, which is why some installs never see it.

Keys

Key Action
Ctrl+J Open the DJ screen
Ctrl+T Toggle continuous auto-queue
Ctrl+Y Vibe shift: drop the DJ's queued tracks and change direction
Ctrl+O Toggle "only tracks I don't already have"
Ctrl+G Choose which AI and model the DJ uses
Enter Send the prompt
PgUp PgDn Scroll the transcript
Esc Clear the prompt, or leave the screen when it is empty

All five are rebindable, see docs/keybindings.md.

The DJ is also reachable from the AI DJ row in the sidebar. The global key exists because the sidebar's Library panel is only drawn for the Spotify source, so the row alone would make the DJ unreachable on YouTube.

Continuous auto-queue

With auto-queue on, the DJ tops the queue up as tracks finish, keeping roughly six tracks ahead. It refills when the queue drops to two — about 6–8 minutes of runway, comfortably more than the worst-case round trip.

Only tracks you don't already have

Ask for "songs like Remind Me to Forget" and a good recommender will name tracks you already own — they are the best answer to the question. Press Ctrl+O to switch that off and get only tracks that are new to you. The panel title shows the mode.

"Already have it" means:

  • anything in your Liked Songs, and
  • anything in a playlist you own or collaborate on.

Playlists you merely follow do not count. Discover Weekly, Release Radar and the large editorial playlists hold thousands of tracks between them, and treating those as yours would reject nearly every good recommendation.

Two things to expect the first time you turn it on:

  • A short pause while it indexes. Liked Songs are checked exactly, one call per batch, with nothing cached. Playlists have no such lookup, so spotatui crawls yours once per session and caches the result. The title reads fresh only (indexing…) while that runs.
  • Smaller batches sometimes. The DJ asks for roughly double what it needs so the rejections have somewhere to come from, and it gets one retry when most of a batch is filtered out. With a very large library it can still come up short; it says so rather than quietly queueing two tracks when you asked for six.

Start in this mode every session with:

behavior:
  dj_avoid_library: true

Over MCP the same knowledge is offered rather than imposed, because there an agent queues what it was asked to queue and silently dropping a track because you own it would be answering a question nobody asked. So search_tracks marks every result [owned] or [new] — the agent sees it while it is still choosing — and queue_tracks takes exclude_owned: true for an agent that wants the same hard guarantee this toggle gives you. See docs/mcp-setup.md.


Which AI, which model

The first time you open the DJ it shows a two-step picker: which AI, then which of that AI's models. It appears whether you got there with Ctrl+J or from the sidebar's AI DJ row, and Ctrl+G brings it back at any time.

Step 1, the AI. Every backend spotatui supports, and what each one bills against:

Row Bills against
claude your Claude Pro/Max plan
codex your ChatGPT plan
agy your Antigravity plan (Antigravity replaced Google's Gemini CLI)
copilot your GitHub Copilot plan
opencode whatever provider opencode is logged into
anthropic an Anthropic API key, per token
openai_compat nothing for a local model; per token for a hosted one

opencode names models as provider/model (openai/gpt-5.4), not a bare name; the picker says so where you type one.

An agent row you have not installed is marked not on PATH and is still selectable, because "I am about to install it" is a real answer. spotatui identifies them by looking for the binary on your PATH and never runs anything to find out, so opening the picker cannot stall the UI. The legacy gemini row is only offered if you actually have that binary.

Step 2, the model. A short list of known-good values, each with its cost, then two rows that are always present: Use the CLI's default (or Use the built-in default for the API backends), which passes no model at all, and Custom…, which takes any name you type. The lists are conveniences rather than a contract, since the vendor decides which models its CLI accepts, so the free-text row is the escape hatch when a list goes out of date. codex, copilot, opencode and the legacy gemini have no list at all: which model ids their subscription accepts is not knowable without asking them, and the picker never runs a subprocess.

Keys in the picker: (or j/k) to move, 19 to take a numbered row outright without a further Enter, Enter to choose the highlighted one, Esc to step back. Rows past the ninth are left unnumbered, because there is no key that would select them. In the free-text step j and k type themselves instead of navigating, the way any text field has to.

Esc from the first step closes the picker, leaves your current brain exactly as it was, and records that the question was answered (behavior.dj_configured: true), so it is not asked again on every visit. If that write fails the status bar says so, and the next launch asks again.

The picker deliberately never asks for an API key: behavior.dj_api_key is plaintext YAML, so it points you at SPOTATUI_DJ_API_KEY instead. It also leaves dj_base_url alone, so a self-hosted openai_compat endpoint is still configured by hand.

It never asked me

Then something in your config already counts as a deliberate choice, and spotatui does not interrogate a user who has already decided. Any one of these is enough:

  • dj_configured: true, which is what the picker writes when you finish or dismiss it
  • a dj_backend other than agent_cli
  • a dj_agent_command other than the shipped ["claude", "-p"]
  • any of dj_agent_model, dj_model, dj_api_key or dj_base_url set

Tuning dj_batch_size, dj_history_period, dj_avoid_library or the timeout does not count, because none of them is a choice of AI. Either way, Ctrl+G opens the picker on demand.


Backends

Set behavior.dj_backend in ~/.config/spotatui/config.yml, or let the picker write it for you.

The two ways of paying are not interchangeable, and that is why the model matters so much more in one of them. agent_cli costs no money and spends the usage limits of the subscription that CLI is logged into, so on a Claude Pro plan the model is the difference between a DJ that runs all evening and one that stops after a handful of turns. The API backends cost money per token and have no subscription limit to hit, so there the model is a bill rather than a quota.

agent_cli (default) — no API key

Runs a coding agent you already have installed and authenticated, so it costs nothing beyond the subscription you already pay for.

behavior:
  dj_backend: agent_cli
  dj_agent_command: ["claude", "-p"]
  dj_agent_model: haiku           # optional; that CLI's own model name
  # dj_agent_prompt_via: stdin    # only to override the preset; see below
  dj_agent_timeout_secs: 90

A bare binary name expands to a known preset, so this is enough:

  dj_agent_command: ["codex"]     # becomes ["codex", "exec", "-"]
  dj_agent_command: ["agy"]       # becomes ["agy", "-p"], with arg delivery
CLI Expands to With dj_agent_model set Prompt via
claude claude -p claude --model haiku -p stdin
codex codex exec - codex exec --model MODEL - stdin
agy (Antigravity) agy -p agy --model gemini-3.6-flash-low -p argv
gemini (legacy) gemini -p gemini -m MODEL -p argv

agy is Google's current CLI. The Gemini CLI it superseded is kept as a preset only so an existing config keeps working, and it is not offered in the picker unless the binary is installed.

Note where the model flag lands: before the flag that carries the prompt. For argv delivery the prompt is appended as the last argument, so the other order (agy -p --model X "…") would hand --model to -p as its value and the prompt would never arrive.

agy also ignores stdin entirely, so it needs arg delivery. Leave dj_agent_prompt_via out of your config and the preset picks the right one; the picker clears that key when you choose an agent, precisely so a stdin left behind by an older config cannot swallow the prompt.

What to put in dj_agent_model is whatever that CLI itself accepts. For claude the picker offers its public aliases, cheapest first: haiku (easiest on a Pro plan), sonnet, opus (a Pro plan hits its limit fast), and fable. For agy it offers a snapshot of what agy models prints: gemini-3.6-flash-low / -medium / -high, gemini-3.5-flash-low, gemini-3.1-pro-low / -high, claude-sonnet-4-6, claude-opus-4-6-thinking, and gpt-oss-120b-medium. Google changes that list server-side, so run agy models for the current one. A name the CLI does not know fails locally in milliseconds, before anything is sent, so a stale suggestion costs you one error message and nothing else.

Any other headless command works too, since spotatui writes the prompt and reads stdout and nothing is hardcoded about these four. A multi-part command you wrote yourself is used exactly as written and never gains a model flag, so your own argv keeps ownership of its own flags. The single exception is the shipped ["claude", "-p"]: it is byte-identical to what the preset produces, so it still counts as the preset shape and can still take a model.

Be clear about the cost of this mode: every step is a fresh subprocess with several seconds of startup latency, so a multi-step turn is several of those. That is what the four-step cap bounds. The API backends below keep one HTTP connection per step instead and are correspondingly quicker.

The agent runs in a scratch directory (~/.config/spotatui/dj-scratch), not your current directory — otherwise it reads the CLAUDE.md and source files of whatever project you happen to be in and starts recommending music about Rust.

anthropic — API key

Pay per token against an API key. A Claude Pro or Max subscription is not involved here: its usage limits do not apply, and it does not cover this bill either.

behavior:
  dj_backend: anthropic
  dj_model: claude-haiku-4-5       # optional; this is also the default
export SPOTATUI_DJ_API_KEY=sk-ant-...

The models the picker offers, with list price per million tokens:

Model Input / output per MTok Context
claude-haiku-4-5 (default) $1 / $5 200K
claude-sonnet-5 $3 / $15 1M
claude-opus-5 $5 / $25 1M
claude-opus-4-8 $5 / $25 1M
claude-fable-5 $10 / $50 1M

One step is a compact brief and a short reply, so a single ask is cheap on any of them; it is a multi-step turn, and continuous auto-queue over a long evening, that makes the choice add up. Any other model id works too, since the list is a convenience rather than a restriction.

openai_compat — API key or a local model

One adapter for anything speaking /chat/completions: OpenAI, OpenRouter, Ollama, LM Studio, vLLM, llama.cpp.

behavior:
  dj_backend: openai_compat
  dj_base_url: http://localhost:11434/v1   # Ollama; the default
  dj_model: llama3.2

A local model needs no key at all. For a hosted one, export SPOTATUI_DJ_API_KEY=….

There is no model list to offer here, because only your endpoint knows what it serves. The picker's second step gives you Use the built-in default, which is gpt-4o-mini, and a free-text row: an Ollama user wants the free-text row (llama3.2), not the default. dj_base_url is only ever set by hand.

The API key

SPOTATUI_DJ_API_KEY wins over behavior.dj_api_key, and never lands on disk — prefer it. The config field exists for convenience, but it is plaintext YAML; the config directory is 0700 on unix and carries a .gitignore, which is the same protection the Spotify token cache relies on, but a plaintext secret is still a plaintext secret. This mirrors how the Subsonic password is handled.


What is sent to the model

Track, artist, and album names only. Three sources, and nothing else:

  • the taste brief, built from your local listens.jsonl: top artists, tracks, and albums over the configured window, plus the last ~25 distinct plays so the DJ can be told not to repeat them
  • what is playing now, and your current steer if you gave one
  • the conversation itself — the last 24 lines of the DJ transcript, which is whatever you typed and whatever it said back

Not sent: timestamps, play counts tied to a clock, Spotify IDs, URIs, your account, or anything identifying you. For the brief that is a property of building it from aggregates rather than raw records, not a filter applied afterwards. The conversation is the exception you control directly: it is sent verbatim, so treat the prompt as you would any chat window.

Tune the window with behavior.dj_history_period (7d, 30d, month, year, all).

Nothing leaves your machine at all if you use openai_compat against a local model.


How it decides, and where it fails

The model returns names; spotatui resolves each one against Spotify (then YouTube, if that feature is on) and drops anything it cannot confidently match. Search always returns something, so a fuzzy near-miss would otherwise be queued in place of a track that does not exist. A remaster or a "/ Arpeggi" suffix still matches; a different artist with the same title does not.

Dropped tracks are logged at debug level and never shown as an error — a model inventing a track is routine. If most of a round fails, the DJ asks once more, naming the failures. Exactly once: the resolve step is what discovers failures, so without a cap the retry could loop.

Duplicates are skipped too, against both the current queue and the recent window. A DJ that re-queues the track that just finished reads as broken.

Batch size

behavior.dj_batch_size (default 6, max 8). Capped for three converging reasons: on an external Spotify Connect device each queued track costs its own Web API call, a deep queue cannot respond to a vibe shift, and one model invocation per batch rather than per track is what keeps latency and quota sane.


Troubleshooting

Symptom Fix
"could not run claude. Is it installed and on PATH?" Install the CLI, or point dj_agent_command at the right binary.
"did not answer within 90s" Raise dj_agent_timeout_secs. If you go much above ~180, also revisit auto-queue: the refill watermark assumes a round trip well under the queue's runway.
"could not read a decision out of … output" The CLI answered with prose and no JSON. Check it is logged in by running the same command by hand.
"There's an issue with the selected model (…)" dj_agent_model names something that CLI does not accept. It fails locally in milliseconds without calling anything, so nothing was spent: pick another model with Ctrl+G, or clear the key.
codex: "Not inside a trusted directory and --skip-git-repo-check was not specified" codex exec refuses to run in the DJ's scratch directory. Known limitation: use another agent (claude, agy) or an API backend, or trust ~/.config/spotatui/dj-scratch in your codex config.
The DJ answers a question you did not ask agy ignores stdin, so a config carrying dj_agent_prompt_via: stdin delivers the prompt nowhere. Remove that key so the preset decides, or set it to arg.
It never asked which AI to use Something in your config already counts as a deliberate choice. See It never asked me, or just press Ctrl+G.
"no API key for the Anthropic DJ backend" export SPOTATUI_DJ_API_KEY=….
"the model declined this request" A safety refusal. Rephrase, or switch backend.
Everything is "not found" Usually no Spotify session — log in from the UI. The catalogue lookup needs it.
"Very little listening history so far" The brief needs a handful of qualifying plays. Play some music, or just tell the DJ what you want.

Tracks shorter than 30 seconds and internet-radio streams never enter the history by design, so they never influence the DJ.


Config reference

Key Default Meaning
dj_backend agent_cli agent_cli, anthropic, or openai_compat
dj_agent_command ["claude", "-p"] argv for agent_cli; a bare name expands to a preset
dj_agent_model unset Model name passed as that CLI's own model flag (claude --model haiku). Unset passes no flag at all
dj_agent_prompt_via unset (the preset decides) stdin or arg
dj_agent_timeout_secs 90 Clamped to 5–600
dj_model unset Model id for the API backends; the agent CLIs use dj_agent_model instead
dj_base_url Ollama's local endpoint For openai_compat
dj_api_key unset Prefer SPOTATUI_DJ_API_KEY
dj_batch_size 6 Tracks per round, max 8
dj_history_period 30d 7d, 30d, month, year, all
dj_avoid_library false Start in "only tracks I don't already have" mode
dj_configured unset Written by the picker; true stops spotatui asking which AI to use

An invalid value is reported with a warning in the log and the default is kept — a bad config never stops spotatui from starting.