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.
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
/recommendationsto 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.
cargo run --features ai-djPress 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.
| 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.
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.
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: trueOver 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.
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,
1–9 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.
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_backendother thanagent_cli - a
dj_agent_commandother than the shipped["claude", "-p"] - any of
dj_agent_model,dj_model,dj_api_keyordj_base_urlset
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.
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.
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: 90A 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.
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 defaultexport 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.
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.2A 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.
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.
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.
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.
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.
| 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.
| 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.