Back to skills
SkillHub ClubAnalyze Data & AIFull StackBackendDevOps

spotify-controller

Control Spotify playback and devices from an AI agent using spotify.py and the official Spotify Web API. Use when users ask to check current track, play/pause, next/prev, set volume, search tracks, play first search result, list devices, switch active device, or play a specific Spotify URL. Works on headless VPS and Docker setups.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,084
Hot score
99
Updated
March 20, 2026
Overall rating
C5.0
Composite score
5.0
Best-practice grade
B81.2

Install command

npx @skill-hub/cli install openclaw-skills-spotify-controller

Repository

openclaw/skills

Skill path: skills/egemenyerdelen/spotify-controller

Control Spotify playback and devices from an AI agent using spotify.py and the official Spotify Web API. Use when users ask to check current track, play/pause, next/prev, set volume, search tracks, play first search result, list devices, switch active device, or play a specific Spotify URL. Works on headless VPS and Docker setups.

Open repository

Best for

Primary workflow: Analyze Data & AI.

Technical facets: Full Stack, Backend, DevOps, Data / AI.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install spotify-controller into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding spotify-controller to shared team environments
  • Use spotify-controller for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: spotify-controller
description: Control Spotify playback and devices from an AI agent using spotify.py and the official Spotify Web API. Use when users ask to check current track, play/pause, next/prev, set volume, search tracks, play first search result, list devices, switch active device, or play a specific Spotify URL. Works on headless VPS and Docker setups.
homepage: https://developer.spotify.com
metadata: {"clawdbot":{"emoji":"🎵","requires":{"env":["SPOTIFY_CLIENT_ID","SPOTIFY_CLIENT_SECRET","SPOTIFY_REFRESH_TOKEN"],"bins":["python3"]},"primaryEnv":"SPOTIFY_CLIENT_ID"}}
---

# Spotify Controller Skill

Control Spotify playback from your AI agent using the official Spotify Web API.

This works across setups (local machine, Docker, VPS, and hybrid environments). It is especially useful for fixing Spotify control pain in headless VPS deployments. The server does **not** need a browser or a local Spotify client.

---

## What this skill provides

- A CLI workflow around `spotify.py`
- Playback control (`play`, `pause`, `next`, `prev`)
- Track lookup (`search`) and quick play (`playsearch`)
- Direct URI playback (`playtrack spotify:track:...`)
- Device management (`devices`, `setdevice`)
- Volume control (`volume 0-100`, where supported)

---

## Requirements

- Python 3 available in runtime/container
- `requests` package installed
- Spotify Premium account
- Spotify Developer app credentials
- Environment variables:
  - `SPOTIFY_CLIENT_ID`
  - `SPOTIFY_CLIENT_SECRET`
  - `SPOTIFY_REFRESH_TOKEN`

Install dependency:

```bash
uv pip install requests --system
```

(Alternative: `pip install requests`)

If you build OpenClaw in Docker, add this to your `Dockerfile` when `requests` is not already present:

```dockerfile
RUN uv pip install requests --system
```

---

## Setup (Step-by-step)

### 1) Create a Spotify Developer App

1. Go to: https://developer.spotify.com/dashboard
2. Click **Create App**
3. Enter any app name/description
4. Add Redirect URI:
   - `http://127.0.0.1:8888/callback`
5. Enable **Web API** access
6. Save and copy:
   - **Client ID**
   - **Client Secret**

### 2) Get a refresh token (one-time, on local machine)

Open this URL in your browser (replace `YOUR_CLIENT_ID`):

```text
https://accounts.spotify.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://127.0.0.1:8888/callback&scope=user-modify-playback-state%20user-read-playback-state%20user-read-currently-playing
```

Approve access, then copy the `code` value from the redirected URL.

Exchange code for tokens:

```bash
curl -s -X POST "https://accounts.spotify.com/api/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=http://127.0.0.1:8888/callback&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET"
```

From response JSON, copy `refresh_token`.

> `refresh_token` is typically long-lived, but can be invalidated if app access is revoked, app settings change, or credentials rotate.

### 3) Add credentials to `.env`

```env
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
SPOTIFY_REFRESH_TOKEN=your_refresh_token
```

### 4) Pass env vars to Docker compose service

In `docker-compose.yml` service `environment:` section:

```yaml
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
```

### 5) Restart service/container

```bash
docker compose down
docker compose up -d openclaw-gateway
```

```bash
chown <runtime_user>:<runtime_group> /path/to/workspace/spotify.py
chmod 664 /path/to/workspace/spotify.py
```

---

## Usage

Run commands from workspace:

```bash
python3 spotify.py <command>
```

| Command                                          | Description |
|--------------------------------------------------|---|
| `python3 spotify.py status`                      | Show current playback state and track |
| `python3 spotify.py play`                        | Resume playback |
| `python3 spotify.py pause`                       | Pause playback |
| `python3 spotify.py next`                        | Skip to next track |
| `python3 spotify.py prev`                        | Go to previous track |
| `python3 spotify.py volume 80`                   | Set volume (0–100) where supported |
| `python3 spotify.py search track`                | Search tracks (top results) |
| `python3 spotify.py playsearch "track"`          | Search and play first result |
| `python3 spotify.py playtrack spotify:track:URI` | Play specific track URI |
| `python3 spotify.py devices`                     | List available Spotify devices |
| `python3 spotify.py setdevice "BEDROOM-SPEAKER"` | Set active device by name or id |

---

## VPS / Headless behavior notes

- Headless server control works because playback is executed on Spotify Connect devices (phone/desktop/web), not the server audio output.
- You still need at least one **active Spotify device session**.

If you see `NO_ACTIVE_DEVICE`:
1. Open Spotify on target device
2. Start any track manually once
3. Run `python3 spotify.py devices`
4. Retry command

---

## Known Spotify API limitations (expected)

- Some devices/content contexts may return `403 Restriction violated` for `play`, `prev`, or other controls.
- Some devices may reject remote volume changes (`VOLUME_CONTROL_DISALLOW`).
- Device handoff can lag; immediate `status` after transfer may briefly show stale state.

These are Spotify-side constraints, not necessarily script bugs.

---

## Operational guidance for automations

- Treat non-zero exit codes as command failures.
- Validate environment vars at startup.
- Log command + status code for troubleshooting.
- Retry once for transient network errors.
- Don’t hardcode real credentials in files.

---

## Security notes

- Never commit `.env` with live secrets.
- Rotate app credentials if leaked.
- Use least-access scopes required for your workflow.
- The script only communicates with accounts.spotify.com and api.spotify.com

---

## Quick troubleshooting checklist

1. `python3 spotify.py devices` shows your target device?
2. Device is active in Spotify app?
3. Env vars loaded inside container/runtime?
4. Premium account confirmed?
5. Refresh token still valid?
6. Any Spotify 403 restriction reason in response?

---

## Claim accuracy

- Uses official Spotify Web API ✅
- Works on headless VPS ✅
- Practical for personal usage ✅
- Subject to normal Spotify API behavior/limits ✅


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "egemenyerdelen",
  "slug": "spotify-controller",
  "displayName": "Spotify Controller",
  "latest": {
    "version": "1.0.1",
    "publishedAt": 1772155834421,
    "commit": "https://github.com/openclaw/skills/commit/b7f073fa25627adc7850e9e2dd53ee73abfc4772"
  },
  "history": []
}

```

### scripts/spotify.py

```python
#!/usr/bin/env python3
"""Spotify controller.

Usage:
  python3 spotify.py status
  python3 spotify.py play|pause|next|prev
  python3 spotify.py volume <0-100>
  python3 spotify.py search <query>
  python3 spotify.py playtrack <spotify:track:...>
  python3 spotify.py playsearch <query>
  python3 spotify.py devices
  python3 spotify.py setdevice <device_id_or_exact_name>
"""

import os
import sys
from urllib.parse import quote

import requests

CLIENT_ID = os.environ.get("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET")
REFRESH_TOKEN = os.environ.get("SPOTIFY_REFRESH_TOKEN")


def fail(msg: str, code: int = 1):
    print(f"❌ {msg}")
    raise SystemExit(code)


def ensure_env():
    missing = [
        name
        for name, value in {
            "SPOTIFY_CLIENT_ID": CLIENT_ID,
            "SPOTIFY_CLIENT_SECRET": CLIENT_SECRET,
            "SPOTIFY_REFRESH_TOKEN": REFRESH_TOKEN,
        }.items()
        if not value
    ]
    if missing:
        fail(f"Missing env vars: {', '.join(missing)}")


def get_access_token() -> str:
    ensure_env()
    r = requests.post(
        "https://accounts.spotify.com/api/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": REFRESH_TOKEN,
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        timeout=20,
    )
    if r.status_code != 200:
        fail(f"Token refresh failed ({r.status_code}): {r.text[:200]}")

    token = r.json().get("access_token")
    if not token:
        fail("Token response missing access_token")
    return token


def spotify(method: str, endpoint: str, **kwargs) -> requests.Response:
    token = get_access_token()
    headers = kwargs.pop("headers", {})
    headers["Authorization"] = f"Bearer {token}"
    url = f"https://api.spotify.com/v1{endpoint}"
    return requests.request(method, url, headers=headers, timeout=20, **kwargs)


def print_active_device_hint():
    print("💡 Hint: Open Spotify on phone/desktop/web and start any track once to activate a device.")
    print("   Then run: python3 spotify.py devices")


def print_player_state(resp: requests.Response):
    if resp.status_code == 204:
        print("Nothing playing (or no active device)")
        print_active_device_hint()
        return
    if resp.status_code != 200:
        print(f"Player status error ({resp.status_code}): {resp.text[:200]}")
        return

    d = resp.json()
    item = d.get("item")
    if not item:
        print("No track info available")
        return

    artists = item.get("artists") or []
    artist = artists[0].get("name") if artists else "Unknown artist"
    icon = "▶" if d.get("is_playing") else "⏸"
    print(f"{icon} {item.get('name', 'Unknown track')} — {artist}")


def require_arg(index: int, message: str) -> str:
    if len(sys.argv) <= index:
        fail(message)
    return sys.argv[index]


def usage():
    print(__doc__.strip())


def print_api_error(prefix: str, r: requests.Response):
    print(f"{prefix} ({r.status_code}): {r.text[:200]}")
    if r.status_code == 404:
        print_active_device_hint()
    if r.status_code == 403 and "VOLUME_CONTROL_DISALLOW" in r.text:
        print("💡 This device does not allow remote volume control via Spotify API.")


def get_search_results(query: str, limit: int = 5):
    q = quote(query)
    r = spotify("GET", f"/search?q={q}&type=track&limit={limit}")
    if r.status_code != 200:
        fail(f"Search error ({r.status_code}): {r.text[:200]}")
    return (r.json().get("tracks") or {}).get("items") or []


def get_devices():
    r = spotify("GET", "/me/player/devices")
    if r.status_code != 200:
        fail(f"Devices error ({r.status_code}): {r.text[:200]}")
    return r.json().get("devices") or []


def cmd_status():
    r = spotify("GET", "/me/player")
    print_player_state(r)


def cmd_play_pause_skip(endpoint: str, ok_text: str, err_prefix: str):
    r = spotify("PUT" if endpoint in ("/me/player/play", "/me/player/pause") else "POST", endpoint)
    if 200 <= r.status_code < 300:
        print(ok_text)
    else:
        print_api_error(err_prefix, r)
        raise SystemExit(1)


def cmd_volume():
    vol_raw = require_arg(2, "Usage: python3 spotify.py volume <0-100>")
    try:
        vol = int(vol_raw)
    except ValueError:
        fail("Volume must be an integer 0-100")
    if not 0 <= vol <= 100:
        fail("Volume must be between 0 and 100")

    r = spotify("PUT", f"/me/player/volume?volume_percent={vol}")
    if 200 <= r.status_code < 300:
        print(f"🔊 Volume set to {vol}%")
    else:
        print_api_error("Volume error", r)
        raise SystemExit(1)


def cmd_search():
    query = " ".join(sys.argv[2:]).strip()
    if not query:
        fail("Usage: python3 spotify.py search <query>")

    items = get_search_results(query, limit=5)
    if not items:
        print("No results")
        return

    for t in items:
        artists = t.get("artists") or []
        artist = artists[0].get("name") if artists else "Unknown artist"
        print(f"{t.get('name', 'Unknown track')} — {artist} | {t.get('uri', '-')}")


def cmd_playtrack(uri: str):
    r = spotify("PUT", "/me/player/play", json={"uris": [uri]})
    if 200 <= r.status_code < 300:
        print(f"▶ Playing {uri}")
    else:
        print_api_error("Playtrack error", r)
        raise SystemExit(1)


def cmd_playsearch():
    query = " ".join(sys.argv[2:]).strip()
    if not query:
        fail("Usage: python3 spotify.py playsearch <query>")

    items = get_search_results(query, limit=1)
    if not items:
        print("No results")
        return

    t = items[0]
    uri = t.get("uri")
    artists = t.get("artists") or []
    artist = artists[0].get("name") if artists else "Unknown artist"
    name = t.get("name", "Unknown track")
    if not uri:
        fail("Search returned track without URI")

    r = spotify("PUT", "/me/player/play", json={"uris": [uri]})
    if 200 <= r.status_code < 300:
        print(f"▶ Playing {name} — {artist}")
    else:
        print_api_error("Playsearch error", r)
        raise SystemExit(1)


def cmd_devices():
    devices = get_devices()
    if not devices:
        print("No Spotify devices found")
        print_active_device_hint()
        return

    for d in devices:
        active = "*" if d.get("is_active") else " "
        name = d.get("name", "Unknown")
        dev_id = d.get("id", "-")
        typ = d.get("type", "?")
        vol = d.get("volume_percent")
        print(f"{active} {name} [{typ}] id={dev_id} vol={vol}%")


def cmd_setdevice(target: str):
    devices = get_devices()
    if not devices:
        fail("No devices available to set")

    chosen = None

    # Exact ID first
    for d in devices:
        if d.get("id") == target:
            chosen = d
            break

    # Exact (case-insensitive) name fallback
    if not chosen:
        for d in devices:
            if (d.get("name") or "").lower() == target.lower():
                chosen = d
                break

    # Partial name fallback if unique
    if not chosen:
        matches = [d for d in devices if target.lower() in (d.get("name") or "").lower()]
        if len(matches) == 1:
            chosen = matches[0]
        elif len(matches) > 1:
            print("Multiple device matches found:")
            for d in matches:
                print(f"- {d.get('name')} (id={d.get('id')})")
            fail("Please provide exact name or id")

    if not chosen or not chosen.get("id"):
        fail("Device not found. Run: python3 spotify.py devices")

    r = spotify("PUT", "/me/player", json={"device_ids": [chosen["id"]], "play": False})
    if 200 <= r.status_code < 300:
        print(f"✅ Active device set to: {chosen.get('name')}")
    else:
        print_api_error("Setdevice error", r)
        raise SystemExit(1)


cmd = sys.argv[1] if len(sys.argv) > 1 else "status"

if cmd in {"-h", "--help", "help"}:
    usage()
    raise SystemExit(0)

if cmd == "status":
    cmd_status()
elif cmd == "play":
    cmd_play_pause_skip("/me/player/play", "▶ Playing", "Play error")
elif cmd == "pause":
    cmd_play_pause_skip("/me/player/pause", "⏸ Paused", "Pause error")
elif cmd == "next":
    cmd_play_pause_skip("/me/player/next", "⏭ Skipped", "Next error")
elif cmd == "prev":
    cmd_play_pause_skip("/me/player/previous", "⏮ Previous", "Previous error")
elif cmd == "volume":
    cmd_volume()
elif cmd == "search":
    cmd_search()
elif cmd == "playtrack":
    cmd_playtrack(require_arg(2, "Usage: python3 spotify.py playtrack <spotify:track:...>"))
elif cmd == "playsearch":
    cmd_playsearch()
elif cmd == "devices":
    cmd_devices()
elif cmd == "setdevice":
    cmd_setdevice(require_arg(2, "Usage: python3 spotify.py setdevice <device_id_or_exact_name>"))
else:
    usage()
    fail(f"Unknown command: {cmd}")

```

spotify-controller | SkillHub