mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(spotify): consolidate tools (9→7), add spotify skill, surface in hermes setup (#15154)
Three quality improvements on top of #15121 / #15130 / #15135: 1. Tool consolidation (9 → 7) - spotify_saved_tracks + spotify_saved_albums → spotify_library with kind='tracks'|'albums'. Handler code was ~90 percent identical across the two old tools; the merge is a behavioral no-op. - spotify_activity dropped. Its 'now_playing' action was a duplicate of spotify_playback.get_currently_playing (both return identical 204/empty payloads). Its 'recently_played' action moves onto spotify_playback as a new action — history belongs adjacent to live state. - Net: each API call ships 2 fewer tool schemas when the Spotify toolset is enabled, and the action surface is more discoverable (everything playback-related is on one tool). 2. Spotify skill (skills/media/spotify/SKILL.md) Teaches the agent canonical usage patterns so common requests don't balloon into 4+ tool calls: - 'play X' = one search, then play by URI (not search + scan + describe + play) - 'what's playing' = single get_currently_playing (no preflight get_state chain) - Don't retry on '403 Premium required' or '403 No active device' — both require user action - URI/URL/bare-ID format normalization - Full failure-mode reference for 204/401/403/429 3. Surfaced in 'hermes setup' tool status Adds 'Spotify (PKCE OAuth)' to the tool status list when auth.json has a Spotify access/refresh token. Matches the homeassistant pattern but reads from auth.json (OAuth-based) rather than env vars. Docs updated to reflect the new 7-tool surface, and mention the companion skill in the 'Using it' section. Tests: 54 passing (client 22, auth 15, tools_config 35 — 18 = 54 after renaming/replacing the spotify_activity tests with library + recently_played coverage). Docusaurus build clean.
This commit is contained in:
parent
9d1b277e1d
commit
e5d41f05d4
6 changed files with 254 additions and 127 deletions
|
|
@ -500,6 +500,15 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
if get_env_value("HASS_TOKEN"):
|
if get_env_value("HASS_TOKEN"):
|
||||||
tool_status.append(("Smart Home (Home Assistant)", True, None))
|
tool_status.append(("Smart Home (Home Assistant)", True, None))
|
||||||
|
|
||||||
|
# Spotify (OAuth via hermes auth spotify — check auth.json, not env vars)
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import get_provider_auth_state
|
||||||
|
_spotify_state = get_provider_auth_state("spotify") or {}
|
||||||
|
if _spotify_state.get("access_token") or _spotify_state.get("refresh_token"):
|
||||||
|
tool_status.append(("Spotify (PKCE OAuth)", True, None))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Skills Hub
|
# Skills Hub
|
||||||
if get_env_value("GITHUB_TOKEN"):
|
if get_env_value("GITHUB_TOKEN"):
|
||||||
tool_status.append(("Skills Hub (GitHub)", True, None))
|
tool_status.append(("Skills Hub (GitHub)", True, None))
|
||||||
|
|
|
||||||
134
skills/media/spotify/SKILL.md
Normal file
134
skills/media/spotify/SKILL.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
---
|
||||||
|
name: spotify
|
||||||
|
description: Control Spotify — play music, search the catalog, manage playlists and library, inspect devices and playback state. Loads when the user asks to play/pause/queue music, search tracks/albums/artists, manage playlists, or check what's playing. Assumes the Hermes Spotify toolset is enabled and `hermes auth spotify` has been run.
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
prerequisites:
|
||||||
|
tools: [spotify_playback, spotify_devices, spotify_queue, spotify_search, spotify_playlists, spotify_albums, spotify_library]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [spotify, music, playback, playlists, media]
|
||||||
|
related_skills: [gif-search]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spotify
|
||||||
|
|
||||||
|
Control the user's Spotify account via the Hermes Spotify toolset (7 tools). Setup guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify
|
||||||
|
|
||||||
|
## When to use this skill
|
||||||
|
|
||||||
|
The user says something like "play X", "pause", "skip", "queue up X", "what's playing", "search for X", "add to my X playlist", "make a playlist", "save this to my library", etc.
|
||||||
|
|
||||||
|
## The 7 tools
|
||||||
|
|
||||||
|
- `spotify_playback` — play, pause, next, previous, seek, set_repeat, set_shuffle, set_volume, get_state, get_currently_playing, recently_played
|
||||||
|
- `spotify_devices` — list, transfer
|
||||||
|
- `spotify_queue` — get, add
|
||||||
|
- `spotify_search` — search the catalog
|
||||||
|
- `spotify_playlists` — list, get, create, add_items, remove_items, update_details
|
||||||
|
- `spotify_albums` — get, tracks
|
||||||
|
- `spotify_library` — list/save/remove with `kind: "tracks"|"albums"`
|
||||||
|
|
||||||
|
Playback-mutating actions require Spotify Premium; search/library/playlist ops work on Free.
|
||||||
|
|
||||||
|
## Canonical patterns (minimize tool calls)
|
||||||
|
|
||||||
|
### "Play <artist/track/album>"
|
||||||
|
One search, then play by URI. Do NOT loop through search results describing them unless the user asked for options.
|
||||||
|
|
||||||
|
```
|
||||||
|
spotify_search({"query": "miles davis kind of blue", "types": ["album"], "limit": 1})
|
||||||
|
→ got album URI spotify:album:1weenld61qoidwYuZ1GESA
|
||||||
|
spotify_playback({"action": "play", "context_uri": "spotify:album:1weenld61qoidwYuZ1GESA"})
|
||||||
|
```
|
||||||
|
|
||||||
|
For "play some <artist>" (no specific song), prefer `types: ["artist"]` and play the artist context URI — Spotify handles smart shuffle. If the user says "the song" or "that track", search `types: ["track"]` and pass `uris: [track_uri]` to play.
|
||||||
|
|
||||||
|
### "What's playing?" / "What am I listening to?"
|
||||||
|
Single call — don't chain get_state after get_currently_playing.
|
||||||
|
|
||||||
|
```
|
||||||
|
spotify_playback({"action": "get_currently_playing"})
|
||||||
|
```
|
||||||
|
|
||||||
|
If it returns 204/empty (`is_playing: false`), tell the user nothing is playing. Don't retry.
|
||||||
|
|
||||||
|
### "Pause" / "Skip" / "Volume 50"
|
||||||
|
Direct action, no preflight inspection needed.
|
||||||
|
|
||||||
|
```
|
||||||
|
spotify_playback({"action": "pause"})
|
||||||
|
spotify_playback({"action": "next"})
|
||||||
|
spotify_playback({"action": "set_volume", "volume_percent": 50})
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Add to my <playlist name> playlist"
|
||||||
|
1. `spotify_playlists list` to find the playlist ID by name
|
||||||
|
2. Get the track URI (from currently playing, or search)
|
||||||
|
3. `spotify_playlists add_items` with the playlist_id and URIs
|
||||||
|
|
||||||
|
```
|
||||||
|
spotify_playlists({"action": "list"})
|
||||||
|
→ found "Late Night Jazz" = 37i9dQZF1DX4wta20PHgwo
|
||||||
|
spotify_playback({"action": "get_currently_playing"})
|
||||||
|
→ current track uri = spotify:track:0DiWol3AO6WpXZgp0goxAV
|
||||||
|
spotify_playlists({"action": "add_items",
|
||||||
|
"playlist_id": "37i9dQZF1DX4wta20PHgwo",
|
||||||
|
"uris": ["spotify:track:0DiWol3AO6WpXZgp0goxAV"]})
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Create a playlist called X and add the last 3 songs I played"
|
||||||
|
```
|
||||||
|
spotify_playback({"action": "recently_played", "limit": 3})
|
||||||
|
spotify_playlists({"action": "create", "name": "Focus 2026"})
|
||||||
|
→ got playlist_id back in response
|
||||||
|
spotify_playlists({"action": "add_items", "playlist_id": <id>, "uris": [<3 uris>]})
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Save / unsave / is this saved?"
|
||||||
|
Use `spotify_library` with the right `kind`.
|
||||||
|
|
||||||
|
```
|
||||||
|
spotify_library({"kind": "tracks", "action": "save", "uris": ["spotify:track:..."]})
|
||||||
|
spotify_library({"kind": "albums", "action": "list", "limit": 50})
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Transfer playback to my <device>"
|
||||||
|
```
|
||||||
|
spotify_devices({"action": "list"})
|
||||||
|
→ pick the device_id by matching name/type
|
||||||
|
spotify_devices({"action": "transfer", "device_id": "<id>", "play": true})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical failure modes
|
||||||
|
|
||||||
|
**`403 Forbidden — No active device found`** on any playback action means Spotify isn't running anywhere. Tell the user: "Open Spotify on your phone/desktop/web player first, start any track for a second, then retry." Don't retry the tool call blindly — it will fail the same way. You can call `spotify_devices list` to confirm; an empty list means no active device.
|
||||||
|
|
||||||
|
**`403 Forbidden — Premium required`** means the user is on Free and tried to mutate playback. Don't retry; tell them this action needs Premium. Reads still work (search, playlists, library, get_state).
|
||||||
|
|
||||||
|
**`204 No Content` on `get_currently_playing`** is NOT an error — it means nothing is playing. The tool returns `is_playing: false`. Just report that to the user.
|
||||||
|
|
||||||
|
**`429 Too Many Requests`** = rate limit. Wait and retry once. If it keeps happening, you're looping — stop.
|
||||||
|
|
||||||
|
**`401 Unauthorized` after a retry** — refresh token revoked. Tell the user to run `hermes auth spotify` again.
|
||||||
|
|
||||||
|
## URI and ID formats
|
||||||
|
|
||||||
|
Spotify uses three interchangeable ID formats. The tools accept all three and normalize:
|
||||||
|
|
||||||
|
- URI: `spotify:track:0DiWol3AO6WpXZgp0goxAV` (preferred)
|
||||||
|
- URL: `https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV`
|
||||||
|
- Bare ID: `0DiWol3AO6WpXZgp0goxAV`
|
||||||
|
|
||||||
|
When in doubt, use full URIs. Search results return URIs in the `uri` field — pass those directly.
|
||||||
|
|
||||||
|
Entity types: `track`, `album`, `artist`, `playlist`, `show`, `episode`. Use the right type for the action — `spotify_playback.play` with a `context_uri` expects album/playlist/artist; `uris` expects an array of track URIs.
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- **Don't call `get_state` before every action.** Spotify accepts play/pause/skip without preflight. Only inspect state when the user asked "what's playing" or you need to reason about device/track.
|
||||||
|
- **Don't describe search results unless asked.** If the user said "play X", search, grab the top URI, play it. They'll hear it's wrong if it's wrong.
|
||||||
|
- **Don't retry on `403 Premium required` or `403 No active device`.** Those are permanent until user action.
|
||||||
|
- **Don't use `spotify_search` to find a playlist by name** — that searches the public Spotify catalog. User playlists come from `spotify_playlists list`.
|
||||||
|
- **Don't mix `kind: "tracks"` with album URIs** in `spotify_library` (or vice versa). The tool normalizes IDs but the API endpoint differs.
|
||||||
|
|
@ -148,7 +148,7 @@ def test_get_currently_playing_returns_explanatory_empty_payload(monkeypatch: py
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_spotify_activity_now_playing_returns_explanatory_empty_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_spotify_playback_get_currently_playing_returns_explanatory_empty_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
spotify_tool,
|
spotify_tool,
|
||||||
"_spotify_client",
|
"_spotify_client",
|
||||||
|
|
@ -159,11 +159,11 @@ def test_spotify_activity_now_playing_returns_explanatory_empty_result(monkeypat
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = json.loads(spotify_tool._handle_spotify_activity({"action": "now_playing"}))
|
payload = json.loads(spotify_tool._handle_spotify_playback({"action": "get_currently_playing"}))
|
||||||
|
|
||||||
assert payload == {
|
assert payload == {
|
||||||
"success": True,
|
"success": True,
|
||||||
"action": "now_playing",
|
"action": "get_currently_playing",
|
||||||
"is_playing": False,
|
"is_playing": False,
|
||||||
"status_code": 204,
|
"status_code": 204,
|
||||||
"message": "Spotify is not currently playing anything. Start playback in Spotify and try again.",
|
"message": "Spotify is not currently playing anything. Start playback in Spotify and try again.",
|
||||||
|
|
@ -242,3 +242,58 @@ def test_library_remove_uses_generic_library_endpoint(
|
||||||
{"uris": ",".join(expected_uris)},
|
{"uris": ",".join(expected_uris)},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_spotify_library_tracks_list_routes_to_saved_tracks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
class _LibStub:
|
||||||
|
def get_saved_tracks(self, **kw):
|
||||||
|
seen.append("tracks")
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
def get_saved_albums(self, **kw):
|
||||||
|
seen.append("albums")
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _LibStub())
|
||||||
|
json.loads(spotify_tool._handle_spotify_library({"kind": "tracks", "action": "list"}))
|
||||||
|
assert seen == ["tracks"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_spotify_library_albums_list_routes_to_saved_albums(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
class _LibStub:
|
||||||
|
def get_saved_tracks(self, **kw):
|
||||||
|
seen.append("tracks")
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
def get_saved_albums(self, **kw):
|
||||||
|
seen.append("albums")
|
||||||
|
return {"items": [], "total": 0}
|
||||||
|
|
||||||
|
monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _LibStub())
|
||||||
|
json.loads(spotify_tool._handle_spotify_library({"kind": "albums", "action": "list"}))
|
||||||
|
assert seen == ["albums"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_spotify_library_rejects_missing_kind() -> None:
|
||||||
|
payload = json.loads(spotify_tool._handle_spotify_library({"action": "list"}))
|
||||||
|
assert "kind" in (payload.get("error") or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_spotify_playback_recently_played_action(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""recently_played is now an action on spotify_playback (folded from spotify_activity)."""
|
||||||
|
seen: list[dict] = []
|
||||||
|
|
||||||
|
class _RecentStub:
|
||||||
|
def get_recently_played(self, **kw):
|
||||||
|
seen.append(kw)
|
||||||
|
return {"items": [{"track": {"name": "x"}}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _RecentStub())
|
||||||
|
payload = json.loads(spotify_tool._handle_spotify_playback({"action": "recently_played", "limit": 5}))
|
||||||
|
assert seen and seen[0]["limit"] == 5
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,16 @@ def _handle_spotify_playback(args: dict, **kw) -> str:
|
||||||
return tool_error("volume_percent is required for action='set_volume'")
|
return tool_error("volume_percent is required for action='set_volume'")
|
||||||
result = client.set_volume(volume_percent=max(0, min(100, int(args["volume_percent"]))), device_id=args.get("device_id"))
|
result = client.set_volume(volume_percent=max(0, min(100, int(args["volume_percent"]))), device_id=args.get("device_id"))
|
||||||
return tool_result({"success": True, "action": action, "result": result})
|
return tool_result({"success": True, "action": action, "result": result})
|
||||||
|
if action == "recently_played":
|
||||||
|
after = args.get("after")
|
||||||
|
before = args.get("before")
|
||||||
|
if after and before:
|
||||||
|
return tool_error("Provide only one of 'after' or 'before'")
|
||||||
|
return tool_result(client.get_recently_played(
|
||||||
|
limit=_coerce_limit(args.get("limit"), default=20),
|
||||||
|
after=int(after) if after is not None else None,
|
||||||
|
before=int(before) if before is not None else None,
|
||||||
|
))
|
||||||
return tool_error(f"Unknown spotify_playback action: {action}")
|
return tool_error(f"Unknown spotify_playback action: {action}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _spotify_tool_error(exc)
|
return _spotify_tool_error(exc)
|
||||||
|
|
@ -282,78 +292,33 @@ def _handle_spotify_albums(args: dict, **kw) -> str:
|
||||||
return _spotify_tool_error(exc)
|
return _spotify_tool_error(exc)
|
||||||
|
|
||||||
|
|
||||||
def _handle_spotify_saved_tracks(args: dict, **kw) -> str:
|
def _handle_spotify_library(args: dict, **kw) -> str:
|
||||||
|
"""Unified handler for saved tracks + saved albums (formerly two tools)."""
|
||||||
|
kind = str(args.get("kind") or "").strip().lower()
|
||||||
|
if kind not in {"tracks", "albums"}:
|
||||||
|
return tool_error("kind must be one of: tracks, albums")
|
||||||
action = str(args.get("action") or "list").strip().lower()
|
action = str(args.get("action") or "list").strip().lower()
|
||||||
|
item_type = "track" if kind == "tracks" else "album"
|
||||||
client = _spotify_client()
|
client = _spotify_client()
|
||||||
try:
|
try:
|
||||||
if action == "list":
|
if action == "list":
|
||||||
return tool_result(client.get_saved_tracks(
|
limit = _coerce_limit(args.get("limit"), default=20)
|
||||||
limit=_coerce_limit(args.get("limit"), default=20),
|
offset = max(0, int(args.get("offset") or 0))
|
||||||
offset=max(0, int(args.get("offset") or 0)),
|
market = args.get("market")
|
||||||
market=args.get("market"),
|
if kind == "tracks":
|
||||||
))
|
return tool_result(client.get_saved_tracks(limit=limit, offset=offset, market=market))
|
||||||
|
return tool_result(client.get_saved_albums(limit=limit, offset=offset, market=market))
|
||||||
if action == "save":
|
if action == "save":
|
||||||
uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), "track")
|
uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), item_type)
|
||||||
return tool_result(client.save_library_items(uris=uris))
|
return tool_result(client.save_library_items(uris=uris))
|
||||||
if action == "remove":
|
if action == "remove":
|
||||||
track_ids = [normalize_spotify_id(item, "track") for item in _as_list(args.get("ids") or args.get("items"))]
|
ids = [normalize_spotify_id(item, item_type) for item in _as_list(args.get("ids") or args.get("items"))]
|
||||||
if not track_ids:
|
if not ids:
|
||||||
return tool_error("ids/items is required for action='remove'")
|
return tool_error("ids/items is required for action='remove'")
|
||||||
return tool_result(client.remove_saved_tracks(track_ids=track_ids))
|
if kind == "tracks":
|
||||||
return tool_error(f"Unknown spotify_saved_tracks action: {action}")
|
return tool_result(client.remove_saved_tracks(track_ids=ids))
|
||||||
except Exception as exc:
|
return tool_result(client.remove_saved_albums(album_ids=ids))
|
||||||
return _spotify_tool_error(exc)
|
return tool_error(f"Unknown spotify_library action: {action}")
|
||||||
|
|
||||||
|
|
||||||
def _handle_spotify_saved_albums(args: dict, **kw) -> str:
|
|
||||||
action = str(args.get("action") or "list").strip().lower()
|
|
||||||
client = _spotify_client()
|
|
||||||
try:
|
|
||||||
if action == "list":
|
|
||||||
return tool_result(client.get_saved_albums(
|
|
||||||
limit=_coerce_limit(args.get("limit"), default=20),
|
|
||||||
offset=max(0, int(args.get("offset") or 0)),
|
|
||||||
market=args.get("market"),
|
|
||||||
))
|
|
||||||
if action == "save":
|
|
||||||
uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), "album")
|
|
||||||
return tool_result(client.save_library_items(uris=uris))
|
|
||||||
if action == "remove":
|
|
||||||
album_ids = [normalize_spotify_id(item, "album") for item in _as_list(args.get("ids") or args.get("items"))]
|
|
||||||
if not album_ids:
|
|
||||||
return tool_error("ids/items is required for action='remove'")
|
|
||||||
return tool_result(client.remove_saved_albums(album_ids=album_ids))
|
|
||||||
return tool_error(f"Unknown spotify_saved_albums action: {action}")
|
|
||||||
except Exception as exc:
|
|
||||||
return _spotify_tool_error(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_spotify_activity(args: dict, **kw) -> str:
|
|
||||||
action = str(args.get("action") or "now_playing").strip().lower()
|
|
||||||
client = _spotify_client()
|
|
||||||
try:
|
|
||||||
if action == "now_playing":
|
|
||||||
payload = client.get_currently_playing(market=args.get("market"))
|
|
||||||
if isinstance(payload, dict) and payload.get("empty"):
|
|
||||||
return tool_result({
|
|
||||||
"success": True,
|
|
||||||
"action": action,
|
|
||||||
"is_playing": False,
|
|
||||||
"status_code": payload.get("status_code", 204),
|
|
||||||
"message": payload.get("message") or "Spotify is not currently playing anything.",
|
|
||||||
})
|
|
||||||
return tool_result(payload)
|
|
||||||
if action == "recently_played":
|
|
||||||
after = args.get("after")
|
|
||||||
before = args.get("before")
|
|
||||||
if after and before:
|
|
||||||
return tool_error("Provide only one of 'after' or 'before'")
|
|
||||||
return tool_result(client.get_recently_played(
|
|
||||||
limit=_coerce_limit(args.get("limit"), default=20),
|
|
||||||
after=int(after) if after is not None else None,
|
|
||||||
before=int(before) if before is not None else None,
|
|
||||||
))
|
|
||||||
return tool_error(f"Unknown spotify_activity action: {action}")
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _spotify_tool_error(exc)
|
return _spotify_tool_error(exc)
|
||||||
|
|
||||||
|
|
@ -362,11 +327,11 @@ COMMON_STRING = {"type": "string"}
|
||||||
|
|
||||||
SPOTIFY_PLAYBACK_SCHEMA = {
|
SPOTIFY_PLAYBACK_SCHEMA = {
|
||||||
"name": "spotify_playback",
|
"name": "spotify_playback",
|
||||||
"description": "Control Spotify playback or inspect the active playback state.",
|
"description": "Control Spotify playback, inspect the active playback state, or fetch recently played tracks.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {"type": "string", "enum": ["get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume"]},
|
"action": {"type": "string", "enum": ["get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume", "recently_played"]},
|
||||||
"device_id": COMMON_STRING,
|
"device_id": COMMON_STRING,
|
||||||
"market": COMMON_STRING,
|
"market": COMMON_STRING,
|
||||||
"context_uri": COMMON_STRING,
|
"context_uri": COMMON_STRING,
|
||||||
|
|
@ -375,6 +340,9 @@ SPOTIFY_PLAYBACK_SCHEMA = {
|
||||||
"position_ms": {"type": "integer"},
|
"position_ms": {"type": "integer"},
|
||||||
"state": {"description": "For set_repeat use track/context/off. For set_shuffle use boolean-like true/false.", "oneOf": [{"type": "string"}, {"type": "boolean"}]},
|
"state": {"description": "For set_repeat use track/context/off. For set_shuffle use boolean-like true/false.", "oneOf": [{"type": "string"}, {"type": "boolean"}]},
|
||||||
"volume_percent": {"type": "integer"},
|
"volume_percent": {"type": "integer"},
|
||||||
|
"limit": {"type": "integer", "description": "For recently_played: number of tracks (max 50)"},
|
||||||
|
"after": {"type": "integer", "description": "For recently_played: Unix ms cursor (after this timestamp)"},
|
||||||
|
"before": {"type": "integer", "description": "For recently_played: Unix ms cursor (before this timestamp)"},
|
||||||
},
|
},
|
||||||
"required": ["action"],
|
"required": ["action"],
|
||||||
},
|
},
|
||||||
|
|
@ -466,12 +434,13 @@ SPOTIFY_ALBUMS_SCHEMA = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SPOTIFY_SAVED_TRACKS_SCHEMA = {
|
SPOTIFY_LIBRARY_SCHEMA = {
|
||||||
"name": "spotify_saved_tracks",
|
"name": "spotify_library",
|
||||||
"description": "List, save, or remove the user's saved Spotify tracks.",
|
"description": "List, save, or remove the user's saved Spotify tracks or albums. Use `kind` to select which.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"kind": {"type": "string", "enum": ["tracks", "albums"], "description": "Which library to operate on"},
|
||||||
"action": {"type": "string", "enum": ["list", "save", "remove"]},
|
"action": {"type": "string", "enum": ["list", "save", "remove"]},
|
||||||
"limit": {"type": "integer"},
|
"limit": {"type": "integer"},
|
||||||
"offset": {"type": "integer"},
|
"offset": {"type": "integer"},
|
||||||
|
|
@ -480,41 +449,7 @@ SPOTIFY_SAVED_TRACKS_SCHEMA = {
|
||||||
"ids": {"type": "array", "items": COMMON_STRING},
|
"ids": {"type": "array", "items": COMMON_STRING},
|
||||||
"items": {"type": "array", "items": COMMON_STRING},
|
"items": {"type": "array", "items": COMMON_STRING},
|
||||||
},
|
},
|
||||||
"required": ["action"],
|
"required": ["kind", "action"],
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
SPOTIFY_SAVED_ALBUMS_SCHEMA = {
|
|
||||||
"name": "spotify_saved_albums",
|
|
||||||
"description": "List, save, or remove the user's saved Spotify albums.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {"type": "string", "enum": ["list", "save", "remove"]},
|
|
||||||
"limit": {"type": "integer"},
|
|
||||||
"offset": {"type": "integer"},
|
|
||||||
"market": COMMON_STRING,
|
|
||||||
"uris": {"type": "array", "items": COMMON_STRING},
|
|
||||||
"ids": {"type": "array", "items": COMMON_STRING},
|
|
||||||
"items": {"type": "array", "items": COMMON_STRING},
|
|
||||||
},
|
|
||||||
"required": ["action"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
SPOTIFY_ACTIVITY_SCHEMA = {
|
|
||||||
"name": "spotify_activity",
|
|
||||||
"description": "Inspect now playing or recently played Spotify activity.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {"type": "string", "enum": ["now_playing", "recently_played"]},
|
|
||||||
"market": COMMON_STRING,
|
|
||||||
"limit": {"type": "integer"},
|
|
||||||
"after": {"type": "integer"},
|
|
||||||
"before": {"type": "integer"},
|
|
||||||
},
|
|
||||||
"required": ["action"],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,6 +460,4 @@ registry.register(name="spotify_queue", toolset="spotify", schema=SPOTIFY_QUEUE_
|
||||||
registry.register(name="spotify_search", toolset="spotify", schema=SPOTIFY_SEARCH_SCHEMA, handler=_handle_spotify_search, check_fn=_check_spotify_available, emoji="🔎")
|
registry.register(name="spotify_search", toolset="spotify", schema=SPOTIFY_SEARCH_SCHEMA, handler=_handle_spotify_search, check_fn=_check_spotify_available, emoji="🔎")
|
||||||
registry.register(name="spotify_playlists", toolset="spotify", schema=SPOTIFY_PLAYLISTS_SCHEMA, handler=_handle_spotify_playlists, check_fn=_check_spotify_available, emoji="📚")
|
registry.register(name="spotify_playlists", toolset="spotify", schema=SPOTIFY_PLAYLISTS_SCHEMA, handler=_handle_spotify_playlists, check_fn=_check_spotify_available, emoji="📚")
|
||||||
registry.register(name="spotify_albums", toolset="spotify", schema=SPOTIFY_ALBUMS_SCHEMA, handler=_handle_spotify_albums, check_fn=_check_spotify_available, emoji="💿")
|
registry.register(name="spotify_albums", toolset="spotify", schema=SPOTIFY_ALBUMS_SCHEMA, handler=_handle_spotify_albums, check_fn=_check_spotify_available, emoji="💿")
|
||||||
registry.register(name="spotify_saved_tracks", toolset="spotify", schema=SPOTIFY_SAVED_TRACKS_SCHEMA, handler=_handle_spotify_saved_tracks, check_fn=_check_spotify_available, emoji="❤️")
|
registry.register(name="spotify_library", toolset="spotify", schema=SPOTIFY_LIBRARY_SCHEMA, handler=_handle_spotify_library, check_fn=_check_spotify_available, emoji="❤️")
|
||||||
registry.register(name="spotify_saved_albums", toolset="spotify", schema=SPOTIFY_SAVED_ALBUMS_SCHEMA, handler=_handle_spotify_saved_albums, check_fn=_check_spotify_available, emoji="💽")
|
|
||||||
registry.register(name="spotify_activity", toolset="spotify", schema=SPOTIFY_ACTIVITY_SCHEMA, handler=_handle_spotify_activity, check_fn=_check_spotify_available, emoji="🕘")
|
|
||||||
|
|
|
||||||
|
|
@ -218,11 +218,10 @@ TOOLSETS = {
|
||||||
},
|
},
|
||||||
|
|
||||||
"spotify": {
|
"spotify": {
|
||||||
"description": "Native Spotify playback, search, playlist, album, library, and activity tools",
|
"description": "Native Spotify playback, search, playlist, album, and library tools",
|
||||||
"tools": [
|
"tools": [
|
||||||
"spotify_playback", "spotify_devices", "spotify_queue", "spotify_search",
|
"spotify_playback", "spotify_devices", "spotify_queue", "spotify_search",
|
||||||
"spotify_playlists", "spotify_albums", "spotify_saved_tracks",
|
"spotify_playlists", "spotify_albums", "spotify_library",
|
||||||
"spotify_saved_albums", "spotify_activity",
|
|
||||||
],
|
],
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ Shows whether tokens are present and when the access token expires. Refresh is a
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
|
|
||||||
Once logged in, the agent has access to 9 Spotify tools. You talk to the agent naturally — it picks the right tool and action.
|
Once logged in, the agent has access to 7 Spotify tools. You talk to the agent naturally — it picks the right tool and action. For the best behavior, the agent loads a companion skill that teaches canonical usage patterns (single-search-then-play, when not to preflight `get_state`, etc.).
|
||||||
|
|
||||||
```
|
```
|
||||||
> play some miles davis
|
> play some miles davis
|
||||||
|
|
@ -82,12 +82,12 @@ Once logged in, the agent has access to 9 Spotify tools. You talk to the agent n
|
||||||
All playback-mutating actions accept an optional `device_id` to target a specific device. If omitted, Spotify uses the currently active device.
|
All playback-mutating actions accept an optional `device_id` to target a specific device. If omitted, Spotify uses the currently active device.
|
||||||
|
|
||||||
#### `spotify_playback`
|
#### `spotify_playback`
|
||||||
Control and inspect playback.
|
Control and inspect playback, plus fetch recently played history.
|
||||||
|
|
||||||
| Action | Purpose | Premium? |
|
| Action | Purpose | Premium? |
|
||||||
|--------|---------|----------|
|
|--------|---------|----------|
|
||||||
| `get_state` | Full playback state (track, device, progress, shuffle/repeat) | No |
|
| `get_state` | Full playback state (track, device, progress, shuffle/repeat) | No |
|
||||||
| `get_currently_playing` | Just the current track | No |
|
| `get_currently_playing` | Just the current track (returns empty on 204 — see below) | No |
|
||||||
| `play` | Start/resume playback. Optional: `context_uri`, `uris`, `offset`, `position_ms` | Yes |
|
| `play` | Start/resume playback. Optional: `context_uri`, `uris`, `offset`, `position_ms` | Yes |
|
||||||
| `pause` | Pause playback | Yes |
|
| `pause` | Pause playback | Yes |
|
||||||
| `next` / `previous` | Skip track | Yes |
|
| `next` / `previous` | Skip track | Yes |
|
||||||
|
|
@ -95,6 +95,7 @@ Control and inspect playback.
|
||||||
| `set_repeat` | `state` = `track` / `context` / `off` | Yes |
|
| `set_repeat` | `state` = `track` / `context` / `off` | Yes |
|
||||||
| `set_shuffle` | `state` = `true` / `false` | Yes |
|
| `set_shuffle` | `state` = `true` / `false` | Yes |
|
||||||
| `set_volume` | `volume_percent` = 0-100 | Yes |
|
| `set_volume` | `volume_percent` = 0-100 | Yes |
|
||||||
|
| `recently_played` | Last played tracks. Optional `limit`, `before`, `after` (Unix ms) | No |
|
||||||
|
|
||||||
#### `spotify_devices`
|
#### `spotify_devices`
|
||||||
| Action | Purpose |
|
| Action | Purpose |
|
||||||
|
|
@ -127,18 +128,16 @@ Search the catalog. `query` is required. Optional: `types` (array of `track` / `
|
||||||
| `get` | Album metadata | `album_id` |
|
| `get` | Album metadata | `album_id` |
|
||||||
| `tracks` | Album track list | `album_id` |
|
| `tracks` | Album track list | `album_id` |
|
||||||
|
|
||||||
#### `spotify_saved_tracks` / `spotify_saved_albums`
|
#### `spotify_library`
|
||||||
|
Unified access to saved tracks and saved albums. Pick the collection with the `kind` arg.
|
||||||
|
|
||||||
| Action | Purpose |
|
| Action | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `list` | Paginated library listing |
|
| `list` | Paginated library listing |
|
||||||
| `save` | Add `ids` / `uris` to library |
|
| `save` | Add `ids` / `uris` to library |
|
||||||
| `remove` | Remove `ids` / `uris` from library |
|
| `remove` | Remove `ids` / `uris` from library |
|
||||||
|
|
||||||
#### `spotify_activity`
|
Required: `kind` = `tracks` or `albums`, plus `action`.
|
||||||
| Action | Purpose | Premium? |
|
|
||||||
|--------|---------|----------|
|
|
||||||
| `now_playing` | Currently playing (returns empty on 204 — see below) | No |
|
|
||||||
| `recently_played` | Last played tracks. Optional `limit`, `before`, `after` (Unix ms) | No |
|
|
||||||
|
|
||||||
### Feature matrix: Free vs Premium
|
### Feature matrix: Free vs Premium
|
||||||
|
|
||||||
|
|
@ -147,14 +146,12 @@ Read-only tools work on Free accounts. Anything that mutates playback or the que
|
||||||
| Works on Free | Premium required |
|
| Works on Free | Premium required |
|
||||||
|---------------|------------------|
|
|---------------|------------------|
|
||||||
| `spotify_search` (all) | `spotify_playback` — play, pause, next, previous, seek, set_repeat, set_shuffle, set_volume |
|
| `spotify_search` (all) | `spotify_playback` — play, pause, next, previous, seek, set_repeat, set_shuffle, set_volume |
|
||||||
| `spotify_playback` — get_state, get_currently_playing | `spotify_queue` — add |
|
| `spotify_playback` — get_state, get_currently_playing, recently_played | `spotify_queue` — add |
|
||||||
| `spotify_devices` — list | `spotify_devices` — transfer |
|
| `spotify_devices` — list | `spotify_devices` — transfer |
|
||||||
| `spotify_queue` — get | |
|
| `spotify_queue` — get | |
|
||||||
| `spotify_playlists` (all) | |
|
| `spotify_playlists` (all) | |
|
||||||
| `spotify_albums` (all) | |
|
| `spotify_albums` (all) | |
|
||||||
| `spotify_saved_tracks` (all) | |
|
| `spotify_library` (all) | |
|
||||||
| `spotify_saved_albums` (all) | |
|
|
||||||
| `spotify_activity` (all) | |
|
|
||||||
|
|
||||||
## Sign out
|
## Sign out
|
||||||
|
|
||||||
|
|
@ -172,7 +169,7 @@ To revoke the app on Spotify's side, visit [Apps connected to your account](http
|
||||||
|
|
||||||
**`403 Forbidden — Premium required`** — You're on a Free account trying to use a playback-mutating action. See the feature matrix above.
|
**`403 Forbidden — Premium required`** — You're on a Free account trying to use a playback-mutating action. See the feature matrix above.
|
||||||
|
|
||||||
**`204 No Content` on `now_playing`** — nothing is currently playing on any device. This is Spotify's normal response, not an error; Hermes surfaces it as an explanatory empty result.
|
**`204 No Content` on `get_currently_playing`** — nothing is currently playing on any device. This is Spotify's normal response, not an error; Hermes surfaces it as an explanatory empty result (`is_playing: false`).
|
||||||
|
|
||||||
**`INVALID_CLIENT: Invalid redirect URI`** — the redirect URI in your Spotify app settings doesn't match what Hermes is using. The default is `http://127.0.0.1:43827/spotify/callback`. Either add that to your app's allowed redirect URIs, or set `HERMES_SPOTIFY_REDIRECT_URI` in `~/.hermes/.env` to whatever you registered.
|
**`INVALID_CLIENT: Invalid redirect URI`** — the redirect URI in your Spotify app settings doesn't match what Hermes is using. The default is `http://127.0.0.1:43827/spotify/callback`. Either add that to your app's allowed redirect URIs, or set `HERMES_SPOTIFY_REDIRECT_URI` in `~/.hermes/.env` to whatever you registered.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue