caffeinelabs/skillsContrôle réussi
SKILL DETAIL
connector-spotify
caffeinelabs/skills/connector-spotify
>-
Installations · 225Voir la source
Installation
npx skills add https://github.com/caffeinelabs/skills --skill connector-spotify
Fichiers du skill
SKILL.md
Dernière synchronisation · 22 sept. 2026
SKILL.md›
---
name: connector-spotify
description: >-
MANDATORY recipe for every Caffeine build that reads Spotify catalog data or
drives a user's Spotify account from a canister. The supported path is the
`spotify-client` mops package (Spotify Web API) over outbound HTTPS with an
OAuth 2.0 bearer token minted off-chain. Hand-rolling `ic.http_request` calls
to `api.spotify.com` is a FORBIDDEN anti-pattern — it bypasses the
non-replicated-outcall safeguard (player state and `progress_ms` differ per
node and fail consensus), the generated JSON decoding, and the bearer
handling. Load this skill whenever the user, spec, or any prior task mentions
Spotify, a song, track, artist, album, playlist, music search, new releases,
genres, markets, "what's playing", recently played, the queue, player controls
(play / pause / skip / shuffle / repeat), a saved library, podcasts (shows or
episodes), chapters or audiobooks — and BEFORE writing any code that touches a
Spotify endpoint.
version: 0.3.0
caffeineai-subscription: [none]
compatibility:
mops:
spotify-client: "~0.3.0"
---
# Spotify with `spotify-client`
Motoko bindings for the
[Spotify Web API](https://developer.spotify.com/documentation/web-api),
generated from Spotify's official OpenAPI spec: **15 API modules, 270
operations**. The package is built in **icp-cli mode** (`mo:ic/Types` for the
management-canister interface), so it pulls `ic` as a dependency and pins
PocketIC in `[toolchain]`.
# Backend
Reading catalog data and driving the player. The token always comes from the
caller — the canister never holds a Spotify client secret:
```motoko filepath=src/backend/main.mo
import { getTrack } "mo:spotify-client/Apis/TracksApi";
import { getInformationAboutTheUsersCurrentPlayback; skipUsersPlaybackToNextTrack } "mo:spotify-client/Apis/PlayerApi";
import { type Config; defaultConfig } "mo:spotify-client/Config";
persistent actor {
func config(accessToken : Text) : Config = { defaultConfig with auth = ?#bearer accessToken };
// Catalog read — a client-credentials token suffices. `market = ""` omits the
// parameter; empty strings and zeroes are how optional query params are
// dropped, there is no `?Text` to leave null.
public func trackName(accessToken : Text, id : Text) : async ?Text {
let track = await* getTrack(config accessToken, id, "");
track.name;
};
// User read — needs a user token with `user-read-playback-state`. An idle
// player answers 204 with an empty body, which the generated decoder cannot
// parse, so it rejects; treat that as "nothing playing", not as an error.
public func nowPlaying(accessToken : Text) : async ?Bool {
try {
let state = await* getInformationAboutTheUsersCurrentPlayback(config accessToken, "", "");
state.is_playing;
} catch (_err) {
null;
};
};
// User write — needs `user-modify-playback-state`. Returns `()`, and since
// 0.3.0 a non-2xx status rejects, so a missing scope or an expired token
// surfaces here instead of looking like success.
public func skip(accessToken : Text) : async () {
await* skipUsersPlaybackToNextTrack(config accessToken, "");
};
}
```
## Auth: the canister never sees a client secret
Every call needs an **OAuth 2.0 bearer access token**, minted off-chain and
passed in. Two flows matter:
1. **Client Credentials** — server-to-server, no user. Reads public catalog
only: `search`, `getTrack`, `getAnAlbum`, `getAnArtist`, `getNewReleases`,
`getCategories`, `getAvailableMarkets`, public playlists, public shows. It
**cannot** touch any `/me/*` endpoint, the library, or the player.
2. **Authorization Code with PKCE** — user-facing. Required for every `/me/*`
call, playlist mutation, library write and player command, each gated by its
own scope (`user-read-playback-state`, `user-modify-playback-state`,
`playlist-modify-public`, `user-library-read`, …). Scopes absent from the
token surface as 403, not as a validation error.
Tokens expire after **one hour** and refresh is off-chain too. Treat a 401 as
"ask the client to refresh and retry", never as a permanent failure, and never
store a client secret in the canister.
## Calls are non-replicated by default
The package ships `is_replicated = ?false` in `defaultConfig`, and **91 of the
135 operations depend on it**. The generator already pins non-replication per
request for the 27 `PUT` and 17 `DELETE` operations, because the IC requires it
there — so playlist edits and library saves were never at risk. The default is
what covers the rest:
- the **7 `POST`s**, which include `addToQueue` and both skip endpoints. These
are not idempotent, so replicated they would queue a track ~13 times and skip
~13 tracks;
- all **84 `GET`s**, which are non-deterministic for the player —
currently-playing carries `timestamp` and `progress_ms`, differing per node —
and would cost ~13x the cycles even where they agree.
You don't set the flag yourself; the default is correct. Override with
`is_replicated = ?true` only together with a `transform` that strips the
volatile fields.
**Upgrading from 0.2.x — the old advice was backwards.** That skill told callers
to keep `is_replicated = null` for mutations "because consensus matters", and
showed `let userCfg = { cfg with is_replicated = null }`. Carrying that forward
is now actively harmful: `null` means replicated, so every `addToQueue` and
skip would fire once per replica. Delete any such override and take
`defaultConfig` as it comes.
## Everything in a response is optional
Spotify marks almost no response field required, so the models are all-optional:
`TrackObject.name : ?Text`, `.artists : ?[SimplifiedArtistObject]`,
`CurrentlyPlayingContextObject.is_playing : ?Bool`. Reach through with a `do ?`
block rather than nested `switch`es, and decide what absence means for your
caller — Spotify omits fields your token's scopes don't cover.
## IDs, not URIs
Endpoint parameters take Spotify's base-62 **IDs**
(`11dFghVXANMlKmJXsNCbNl`), not URIs (`spotify:track:11dFghVXANMlKmJXsNCbNl`)
and not URLs. When a user pastes a Spotify link, extract the segment after the
last `/` and before any `?`. The `uris` parameters on playlist operations are
the exception — those do take full `spotify:track:…` URIs.
## Starting playback
`PlayerApi.startAUsersPlayback` takes the four fields as scalars —
`context_uri : ?Text`, `uris : ?[Text]`, `position_ms : ?Int` — and
`transferAUsersPlayback` takes `play : ?Bool`. Pick one of `uris` (explicit
tracks) or `context_uri` (an album, artist or playlist), never both.
`offset` is the exception: it is a free-form object in the spec, so the
generator maps it to `?Map<Text, Text>` and serialises **every value as a
string**. `{"uri": "spotify:track:…"}` therefore works, while Spotify's other
documented form `{"position": 5}` goes out as `{"position": "5"}` and is
rejected. Offset into a context by URI, not by index.
## One operation does not work
`PlaylistsApi.uploadCustomPlaylistCover` — the spec declares the body as
`image/jpeg` (`format: byte`), but the generator only ever emits JSON request
bodies, so the call sends `Content-Type: application/json` with the base64
wrapped in JSON quotes. Spotify rejects it. This is not new in 0.3.0: 0.2.2
JSON-wrapped the raw `Blob` instead, equally wrong on the wire. Do not offer
custom playlist cover upload, and do not hand-roll it with `ic.http_request`;
raise it on
[`caffeinelabs/skills-internal`](https://github.com/caffeinelabs/skills-internal).
Everything else in the surface is sound — unlike some connectors, there are no
stubbed `oneOf` converters, no operation silently dropping its request body, and
no endpoint returning a non-JSON body through the JSON decoder.
## The idle player rejects instead of returning "nothing playing"
`PlayerApi.getInformationAboutTheUsersCurrentPlayback` and
`getTheUsersCurrentlyPlayingTrack` answer **204 with an empty body** when
playback is not active. The generated code treats every 2xx as the 200 schema
and runs the JSON decoder over it, so an idle player produces
`Error.reject(… Failed to parse JSON …)` rather than an absent value. Wrap both
in `try`/`catch` and read a rejection as "nothing playing", as the §Backend
sample does.
This is a codegen gap, not a Spotify quirk: the spec declares
`'204': Playback not available or active` for the first operation (the generator
ignores no-content 2xx responses and has no `?T` return shape for them), while
for the second Spotify returns 204 in practice without declaring it at all.
Modelling declared no-content responses would change the generated signature to
`?CurrentlyPlayingContextObject`, so it belongs in the plugin as its own
change.
## Every write now reports its status
Until 0.3.0 the 43 operations returning `async* ()` — `skip`, `pause`,
`addToQueue`, every library and playlist mutation — discarded the HTTP response
entirely, so a 401, 403 or 429 was indistinguishable from success. They now
reject on any non-2xx status. Expect `try`/`catch` around writes to actually
fire: a missing scope surfaces as 403 where it previously looked like a
successful no-op.
## Cycles and response sizes
`defaultConfig.cycles = 30_000_000_000` suits a typical single-object read.
Large pages need more: `search` with `limit=50`, `getAnAlbum` on a long
tracklist, or `getAudioAnalysis` (which returns a very large object) want
`cycles = 100_000_000_000` and an explicit
`max_response_bytes = ?2_000_000`.
## Errors
Non-2xx responses and decode failures `throw Error.reject(…)`. The client is
generated with `diagnostics`, so the message is
`HTTP <status> body[<n>B]=<first 100 chars>: <reason>` — enough to separate a
401 (expired token) from a 403 (missing scope) from a 404 (bad ID) without extra
logging. Catch with `try`/`catch` and surface `Error.message(err)`.