Playlist in, Yoto MYO card out. One command.
Point it at a playlist, tick the tracks you want, and it fetches the audio, converts it to MP3, uploads each track to Yoto, and assembles a playlist where every track is its own chapter with a proper title. The card lands in your Yoto app library ready to link to a physical MYO card.
Runs entirely on your own machine, on your own network, under your own Yoto account.
Leia runs on macOS, Linux, and Windows. Install Node 22.18 or newer, yt-dlp,
and FFmpeg, then make sure all three are available on your PATH. There is no
build step: Node runs the TypeScript directly.
On macOS, Homebrew installs both in one line:
brew install yt-dlp ffmpegOn Ubuntu/Debian, apt has FFmpeg but its yt-dlp package lags upstream, so pull yt-dlp straight from its release binary:
sudo apt install ffmpeg
sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
sudo chmod a+rx /usr/local/bin/yt-dlpgit clone https://github.com/nsokin/leia.git
cd leia
npm installCheck it over before going further:
npm run doctorThat reports node, yt-dlp, ffmpeg, your client ID and your sign-in state, and tells you what to fix if anything is missing.
npm run typecheck
npm testYou need your own Yoto app. It takes two minutes and cannot be shared: the client ID is tied to the account that creates it.
- Go to dashboard.yoto.dev and create a Public app.
- Set the redirect URL to exactly
http://127.0.0.1:8787/callback. - Tick
user:content:manage, then press Update Application. Miss it and the browser bounces straight back withaccess_denied. - Copy the client ID and set it for the current shell:
On macOS or Linux:
export YOTO_CLIENT_ID=your_client_idIn Windows PowerShell:
$env:YOTO_CLIENT_ID = 'your_client_id'For a persistent configuration, write {"clientId":"your_client_id"} to
~/.leia/config.json on macOS/Linux or %USERPROFILE%\.leia\config.json on
Windows.
Then sign in once:
npm run loginSign in with your normal Yoto customer account, the one your player and family library live in. It does not have to be the account that made the app in step 1, and it usually is not.
That opens your browser, catches the callback on localhost, and saves the token
locally in Leia's .leia directory.
Two things the dashboard does not make obvious, both learned the hard way:
offline_accessis not available to public apps. Asking for it fails the entire authorize call with "scopes that have not been pre-approved". So there is no refresh token, and the access token lasts 24 hours. The tool re-opens the browser when it expires, which is one click while your Yoto session is still live.- Scopes marked "included automatically" are not in the issued token unless
you request them. They are on the app's allowlist, not in the grant. Miss one
and you get a 403 at the point of use rather than an error at login. The tool
asks for
user:content:manage,user:icons:manageanduser:content:view.
The repo ships a skill following the Agent Skills open standard. Clone the repo, start your agent inside it, and it is already there. No install step.
| Agent | Invoke with |
|---|---|
| Claude Code | /yoto <url> |
| Codex | $yoto <url> |
/yoto https://www.youtube.com/playlist?list=...
The skill handles what the CLI cannot: reading the listing, spotting repeat
uploads, working out how many cards the runtime needs, picking items that make
sensible chapters, and writing the --strip pattern for that channel's title
boilerplate. It checks with you before anything long-running.
One file serves both agents. It lives at .agents/skills/yoto/SKILL.md, which
is where Codex looks, and .claude/skills/yoto is a symlink to it for Claude
Code. Both scan from your working directory up to the repository root, so
starting the agent anywhere inside the repo works.
To use it from any directory, link it into your personal skills:
ln -s "$PWD/.agents/skills/yoto" ~/.claude/skills/yoto # Claude Code
ln -s "$PWD/.agents/skills/yoto" ~/.agents/skills/yoto # CodexNone of this is required. Every step is a plain CLI flag and the tool works standalone.
Look first, download nothing:
node src/cli.ts "<playlist-url>" --list --spokenThen build from what you saw:
node src/cli.ts "<playlist-url>" \
--select "1,2,3,4,9,10" --dedupe --spoken \
--title "Ben and Holly" \
--strip "Show Name|Full Episode!?|Cartoon for Kids"Finally, open the Yoto app, find the playlist in your library, and tap Link to a card onto a blank MYO. That last step has no API and has to be done by hand, once per card.
Two separate things, easily confused:
- The dashboard.yoto.dev account owns the app, which is only an OAuth client identity. It never owns content.
- The account you sign into at the Yoto login page during
--loginis whose library receives the cards.
They do not have to match. Keep the same client ID and sign in as your normal Yoto customer account, the one your player and your family library live in. Nothing in the dashboard needs changing.
Check any time with:
node src/cli.ts --whoamiIf the MYO playlist count is lower than you expect, you are signed into the
wrong account. --logout, then --login again with the right one.
Account identity is recorded where it matters, because uploaded media and card IDs belong to the account that made them:
- The upload cache is keyed per account, so switching accounts re-uploads rather than pointing a card at media the new account does not own.
- Each manifest records its account. Run against a manifest written by a
different one and the tool creates a fresh card rather than failing on a
cardIdit cannot touch.
# The normal case: pick from a playlist, build a card
node src/cli.ts "https://www.youtube.com/playlist?list=..."
# Take everything without prompting
node src/cli.ts "<url>" --all --title "Bedtime Stories"
# Choose without prompting, for scripts and repeat runs
node src/cli.ts "<url>" --select "1-12,15" --title "Aesop"
# Audiobooks: 64 kbps mono, roughly half the file size, no audible loss on speech
node src/cli.ts "<url>" --all --spoken
# Check what you would get without touching Yoto
node src/cli.ts "<url>" --dry-runFull option list: node src/cli.ts --help
Three flags exist for the case where a playlist's own titles and ordering are not what you want on the card.
--select returns tracks in the order you write them, so a playlist
uploaded out of sequence can still come out in order. --select "9,3,7" puts
track 9 first. Ranges expand ascending, and repeating a position keeps its first
place rather than moving it.
--number prefixes each chapter title with its position, 01. Snow, padded to
the width of the track count. Without it the number only reaches the player's
screen, via overlayLabel, and the app's chapter list gives no clue which track
is playing. The track inside the chapter keeps the clean title, so nothing ever
renders 01. 01. Snow. Numbering happens at push time from the final ordering,
so appending later renumbers everything rather than leaving stale prefixes.
--titles <file> overrides chapter titles from a JSON file of
{"<source id>": "Chapter title"}. This is for uploads whose real names are
nowhere in the metadata, the Season 2 | Episode 24 kind, where no --strip
can recover a name that was never there. Keys are source ids rather than
positions, so the file survives the playlist being reordered or added to, and
any id you leave out falls back to the usual cleaned title.
node src/cli.ts "<url>" \
--select "4,10,22,38" --number \
--titles cards/my-card.titles.json--icon sets one fallback icon for the whole card. --icons gives each chapter
its own, and takes either of two forms, because they suit different jobs.
A directory of 16x16 PNGs, matched to the selection by sorted filename.
Name them to sort the way the tracks are numbered, 01-peter-rabbit.png,
02-tom-kitten.png. The count must match the selection exactly, or the command
fails rather than guessing which icon belongs to which chapter. This is the
quick way to dress a card you are building in one go.
node src/cli.ts "<url>" --all --spoken --title "Peter Rabbit" --icons ./iconsA JSON file of {"<source id>": "<png path or yoto:#id>"}, which binds art
to the track rather than to its position, so it survives the selection being
reordered or added to, and it can point at icons already uploaded to Yoto.
node src/cli.ts "<url>" --select "4,10,22" --icons cards/my-card.icons.jsonEither way, uploads are cached in ~/.leia/icon-cache.json by account and file
hash, so re-running does not re-upload unchanged art, and a run that omits
--icons keeps whatever icons the manifest already has rather than clearing
them. Anything not covered falls back to --icon, so a card can mix specific
icons with one default for the rest.
Yoto renders these at 16x16, so use art drawn at icon size. A photograph or a large render turns to mush.
--cover <jpg|png> sets the artwork shown for the card in the app. It is
displayed card-shaped (portrait, matching the physical MYO card), not square, so
build the image to that aspect ratio for the best fit.
node src/cli.ts "<url>" --card <cardId> --cover ./covers/peter-rabbit.jpgOne selected track becomes one chapter containing one track, so the player's back and forward buttons step between them, which is what you want on a card a child is operating.
Per card, Yoto allows 100 tracks and 500 MB. The tool refuses to exceed
the track limit rather than letting the API reject the upload, and warns when
your audio is heading past the size limit. If you hit either, split the
selection across two cards with --select and different --title values.
Yoto re-encodes everything to opus on upload, so what counts against the 500 MB
is their output, not your MP3. That output still scales with what you send: a
34 minute test at --spoken uploaded as 15.6 MB of MP3 and landed as 14.0 MB on
the card. The size warning measures your local files, which errs slightly high,
which is the safe direction.
Every run writes a manifest to ./cards/<slug>.json holding the cardId. Run
the same command again and it updates that card in place instead of creating a
duplicate, so a card already linked to physical NFC stays linked.
Two caches make re-runs cheap:
- Converted MP3s stay in
./downloads, keyed by video ID and quality settings, so nothing is fetched twice. - Uploaded audio is remembered in
~/.leia/media-cache.json, keyed by the local file hash, so nothing is uploaded twice even across different cards.
--append adds new tracks to what the manifest already holds rather than
replacing the card's contents.
Pass --icon either a 16x16 PNG path (uploaded once, then reused for every
chapter) or an existing Yoto icon reference such as
--icon "yoto:#gCgNJrpHZ186Hd1ttD-k0R2Cf38FbPW3riwe27WAiJA". Public icon IDs
are listed at yoto.dev/icons/using-icons.
Cards work fine without icons; they just show the default.
"Sign in to confirm you're not a bot"
YouTube's bot detection. On a home connection this is rare. If it happens, sign
into YouTube in your browser and pass --cookies-from chrome. Keep
--concurrency low (the default of 3 is deliberate). Do not run this on a VPS:
datacenter IPs get flagged far more aggressively and proof-of-origin tokens no
longer get you past the check.
Extraction suddenly fails on everything
YouTube changed something and yt-dlp needs to catch up. brew upgrade yt-dlp.
A track fails mid-run
The run carries on with the rest and tells you what it skipped. Re-run the same command to retry; everything already done is cached.
Leia is an independent command-line tool. It is not affiliated with,
endorsed by, or supported by Yoto. It uses the OAuth client ID and Yoto account
you configure, and keeps its credentials and cache data locally in
~/.leia; it does not operate a hosted service or collect user data.
Yoto's API guidelines require that you hold the rights to what you upload. This tool is for content you are entitled to use: public domain recordings such as LibriVox, Creative Commons audio, podcasts, and your own recordings. Downloading commercial music or audiobooks from YouTube is a breach of YouTube's terms and, in most places, of copyright.
