graph TD
Me((Me)) --> LLM[LLM / Claude]
LLM -->|tool calls| MCP[patchbay MCP server<br/>local, holds my token]
MCP -->|Web API over OAuth| API{{Spotify Web API}}
API --> Acct[(My account:<br/>liked songs + playlists)]
MCP -->|structured results| LLM
I’m a very organized person, and like a lot of security engineers I know, I’m also a Spotify slave. For years my system was no system at all: I’d like a song and let it rot in Liked Songs. That worked until it didn’t. Somewhere past 1,500 tracks the habit fell apart. Syncing everything offline for a short trip ate time and phone storage, and I was realistically listening to maybe 10% of the list.
So I did what everyone does and started making playlists, built around occasions rather than genres on purpose: I didn’t want to fight metadata I already get for free, and “music for focus” is a more useful question than “is this post-rock or math-rock.” The trouble is that doing it by hand made me stingy. I ended up with too few playlists, when what I really wanted was more of them, with songs deliberately repeating across several. The same track in Focus, Coffee, and Roadtrip because it earns its place in all three.
I don’t like repeating work, so hand-sorting hundreds of songs was never going to happen. I wanted something automatic. But (security engineer, remember) I was not about to paste some random, OAuth-hungry script into my account to get it. Spotify has no native feature for this, and no official MCP server to wire an LLM into my library. What it does have is a Web API, and that was enough to start dreaming.
This post is less about the tool than about how it got built. I built it in one long session with Claude Opus, and I want to be upfront about that, because the interesting part isn’t that a machine typed faster than me. It’s that the engineering method didn’t change at all. I still had to define the problem, weigh the approaches, set the constraints, and own every decision. The AI compressed the research and the implementation; it didn’t replace the judgment. Here’s how the work actually split.
Choosing the Approach
Before we wrote a single line, we spent the first stretch of the session in planning mode: no code, just talking through the problem, the options, and the ground rules. I can’t recommend this enough. It’s the same discipline as a design doc before a pull request, and it matters even more with an AI, which will happily start typing the moment you let it. So I didn’t let it. We agreed on the defaults first: what the thing had to be, and what it must never do. Only then did we pick an approach.
The first real fork was whether to build a “skill” (teach the model to call a script) or a proper MCP server. Claude laid out the trade-off; I made the call, and it wasn’t close.
A skill, or a pasted snippet, runs inside the model’s sandbox, in the conversation. It can’t safely hold my long-lived OAuth token, and it can’t reach Spotify as me without me handing secrets into a place I don’t control. That’s exactly what I refused to do. It’s also throwaway: the moment the chat ends, it’s gone. I wanted a capability I could keep, one that any MCP-capable client could pick up next year.
An MCP server gives me that. It runs on my machine, holds my token, and exposes a small, typed set of tools the model is allowed to call. That division of labor is the whole point: the model decides what should happen (“this track belongs in Focus”), and the server carries it out with least privilege and hands back structured results. I’ve read every line of the executor. The model never sees my token.
The server never decides anything on its own. Every tool takes explicit track and playlist IDs, and the client asks me to approve each write. The judgment lives in the conversation; the server is dumb plumbing on purpose.
Setting the Constraints
Of all the ground rules, the one that shaped the code most was about dependencies. It’s also the part the AI would never have imposed on itself.
Left to its defaults, Claude reached for the comfortable stack: requests for HTTP, python-dotenv for config, mcp for the protocol. Reasonable, popular, and more than I wanted holding write access to my account. So I set a rule: standard library unless a dependency earns its place, and I made it justify each one.
python-dotenv parses KEY=value lines. My .env has four of them; a ten-line stdlib function does the same job, environment-variable precedence included. And requests is lovely, but my entire HTTP surface is a few GETs, POSTs, and DELETEs with a bearer token. urllib covers that in about thirty lines, and now I own every byte on the wire:
def request(method, url, headers=None, params=None, json_body=None, form=None, timeout=30):
headers = dict(headers or {})
if params:
url = f"{url}?{urllib.parse.urlencode(params)}"
body = None
if json_body is not None:
body = json.dumps(json_body).encode()
headers.setdefault("Content-Type", "application/json")
elif form is not None:
body = urllib.parse.urlencode(form).encode()
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.headers, r.read()
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read()That left one dependency: mcp, the thing that defines the project. For a tool holding an OAuth token, a dependency graph of one is a feature, not austerity: smaller attack surface, less to audit, nothing to silently update out from under me. The rest is uv and Python 3.14. None of that trimming happens unless the engineer asks for it. The model optimizes for “works”; I optimize for “works, and I’d defend it in a review.”
Design
With the approach and constraints fixed, the design fell out quickly. patchbay1 is about 300 lines of Python behind two entry points: a one-time browser authorization using Authorization Code + PKCE (no client secret to store; token cached under ~/.config/patchbay/ at 600), and the MCP server itself, speaking stdio with eight tools: read liked songs, read playlists, read a playlist’s tracks, search the catalog, create a playlist, add tracks, remove tracks, and remove liked songs. Scopes limited to library and playlist read/write. Nothing else.
Under the hood, that’s the shape of an MCP server at its most minimal. Two deterministic Python scripts: patchbay-auth runs the browser flow and stashes the token; patchbay is the stdio server that reads it back and exposes those tools over JSON-RPC. At session start the LLM reads the tool schemas; during the session it decides when and with what arguments to call each one, and the server executes and returns structured results. Server never reasons. Model never touches Python. Small enough to read on a napkin, and a good first project for feeling out the protocol end-to-end.
With a Little Help of AI
If setting constraints is where I earned my keep, research is where Claude earned its. I walked in assuming I’d sort songs by audio features: Spotify’s per-track danceability, energy, valence, and tempo. Instead of trusting either of our memories, Claude went and checked the current API, and came back with bad news. Spotify pulled those endpoints for new apps back in November 2024, and the February 2026 changes trimmed the surface even further. A brand-new app today just gets a 403 on Audio Features. That’s an evening of building around a dead endpoint, saved before I typed a line.
I wasn’t ready to give up on metadata, though, so I asked the obvious follow-up: what about genre? Surely I could at least bucket things roughly by genre. We checked that too. Genre is still there, on the artist object rather than the track, so in principle patchbay could fetch each artist and read it. But two things killed the idea. Batch artist lookups are restricted for new apps, so it becomes one call per artist across hundreds of them. And, more damning, Spotify has been quietly hollowing the field out: people who track it have watched their genre coverage collapse over the past year, and a deprecated tag feels inevitable. Wiring my organization to a field Spotify is actively abandoning was a no. So we didn’t touch the code.
And here’s the thing: I didn’t need any of it. The model already knows the music. Give it a title, artist, and album, and it’ll tell you whether a song belongs in Roadtrip or Coffee about as well as a friend with good taste. The genre labels I was chasing live in the LLM already, richer and more current than a metadata field Spotify can’t be bothered to maintain. patchbay just moves songs; the taste stays in the conversation.
The First Reorg
The first live session ran about an hour, and confirmed both the workflow and the thesis. What made it work wasn’t the tooling — it was the prompt.
I opened the session with the whole context laid out: what I wanted (mood/occasion playlists, no genre or year splits), what wasn’t working (bloated playlists, songs never playing), what to preserve (Portuguese naming for BR-flavored variants), and the occasions I regularly listen for. That single message did the heavy lifting. Every downstream decision (the playlist set, the naming convention, the assignment rules) traced back to it. If I’d opened with “reorganize my playlists,” I’d have gotten a generic answer and spent the hour negotiating away from it.
The session ran in explicit phases with pauses between them. Discovery fanned out one subagent per playlist and one for Liked Songs, each returning a compact character sketch: top artists, sample tracks, genre mix, occasion fit. That was the only way the model could reason about ~1,900 unique tracks without blowing out the context window. Planning turned the sketches into 22 mood-anchored playlists, each with a one-line charter. I pushed back on a couple, added a Rain playlist for the beautiful-melancholy pocket, held the line on strict occasion buckets. Assignment cycled through two cleanup passes: the first attempt used a rule engine and dumped 173 low-confidence tracks into coffee_day as a fallback bucket; I called it out, and a second pass re-classified each track on individual judgment. Review, then execution.
Execution turned up a live example of the February 2026 changes mentioned earlier. Halfway through, DELETE /me/tracks returned a 403 — the endpoint had been consolidated into DELETE /me/library. First patch shipped the URIs in a JSON body; Spotify wanted them as a comma-separated query parameter capped at 40. Two commits, tests updated, session resumed. And earlier in the same session we’d already added a delete_playlist tool that patchbay had never had — Spotify models playlist deletion as unfollow your own playlist, and I’d never wired the endpoint. Ten more lines, committed; tool count from eight to nine.
The result: 22 new playlists live on Spotify, from Focus (39 instrumental tracks) to Nostalgia (319 MTV-era anthems), 2,737 total assignments across 1,872 unique tracks — average 1.46 playlist tags per track. The twelve old playlists are gone; Liked Songs is empty. I’m still spot-checking placements as I listen, and so far so good.
At the end of the session, we lifted the workflow into a /reorg-playlists skill, plus a companion /audit-playlists for monthly hygiene. Next reorg won’t need me to re-explain the phased structure or the “no fallback bucket” rule or the ISRC dedup — the skill carries those forward. That’s what “let the machine compress the typing” should actually look like: not a one-off session, but a reusable capability that ships the lessons of the run that made it.
Process
Day to day, nothing changed: I still like songs on impulse. The organizing splits by cadence, and both halves live in skills now.
Every few weeks I run /audit-playlists: it reports playlists bloating past 300 tracks, cross-remaster duplicates (same recording under multiple Spotify IDs), tracks that have leaked into more than three playlists, and any Liked Songs accumulation from the last cycle. Findings first, fixes only after I approve each one.
When the taxonomy itself feels stale (probably rare, once a year at most) /reorg-playlists runs the full teardown with the phase structure and the “no fallback bucket” rule baked in. The first one already happened; the next will look the same because the skill carries the lessons forward.
The safety invariant carries across both: build and fill first, unlink last, so a bad call never strands a track.
Wrap Up
The honest summary is that the tools changed and the method didn’t. The AI did the research I’d have lost an evening to, and the implementation I’d have lost a weekend to. Both well. But it reached for the popular libraries, not the minimal ones; it didn’t care about my supply chain, my threat model, the name, or the framing until I told it to. Every real decision was mine, and so is the accountability. That’s the job, and it’s still the job.
There’s a bigger decision hiding under this whole project, though, and I think it matters more than the project itself: knowing when to keep AI in the loop, and when to keep it out. My default is out. If a problem can be solved with a plain script, I write the script. It costs me more time up front, but once it ships it runs for free: no tokens, no latency, no bill that shows up every time it runs. A solution that calls an AI on every execution is the opposite: faster to build, because you let the model absorb the messy logic instead of coding it, but it charges you on every run, forever. Fixed cost versus recurring cost. In my experience the plain script usually wins.
Patchbay lands on the recurring-cost side of that line, and I want to be honest about it, because my first instinct was to file it as an exception. It isn’t. Yes, I used AI to build it, once, and the server itself is deterministic and burns nothing on its own. But the solution is useless without an AI driving it: every organizing session spends tokens, and it will for as long as I use it. That’s AI in the runtime loop, full stop. I’m fine with it here for two reasons. First, the recurring cost buys something a script genuinely can’t: the fuzzy, taste-based call of whether a song belongs in Focus or Coffee. Second, I kept that cost boxed in: the AI is invoked only for the decision, only when I choose to run it, a handful of times a year, while every mechanical step (auth, reading and writing the library) stays deterministic and free. The discipline was never “avoid recurring cost.” It’s “put the AI only where the loop actually needs a brain, and keep its footprint as small as the problem allows.” (And the plain-script side isn’t truly free either; Spotify’s API will change and I’ll have to touch this again. But “maintain occasionally” and “pay per run” are very different bills.)
Three sentences, then: define the problem and own the constraints yourself; let the machine compress the research and the typing; and remember that the moment you stop deciding is the moment you’ve stopped engineering. The code is on GitHub: github.com/lopes/patchbay, small enough to read in one sitting, which was the entire point.
Footnotes
The “patchbay” name and framing are mine, after the studio panel that consolidates every audio connection so you route signals with short patch cables instead of rewiring behind the rack.↩︎
Reuse
Citation
@online{lopes2026,
author = {Lopes, Joe},
title = {Patchbay: {Engineering} with {AI}},
date = {2026-07-10},
url = {https://lopes.id/log/patchbay-engineering-with-ai/},
langid = {en}
}