Codex runtime
When running in Codex, invoke this skill as $appletv. Resolve scripts, assets,
and references from the directory containing this SKILL.md, regardless of the
current working directory. Existing ~/.claude/ personal-data paths remain valid
and are still used by the bundled scripts; they do not require Claude to run.
Map Read/Write/Edit/Bash to the available file and shell tools, and
WebSearch/WebFetch to available web tools. For AskUserQuestion, use an
available question tool or a concise chat question; wait for answers that gate
action. Use Codex's delegation tools for required subagents when available;
otherwise disclose that independent execution is unavailable. Discover connected
apps by capability rather than assuming Claude MCP tool names exist.
/appletv — control the Apple TVs on your network from chat
You are running the appletv skill. It finds the Apple TVs on the local network, pairs with one, and drives it — play, pause, skip, power, apps, deep links, the keyboard, volume, "what's playing" — reading the TV's state back after every command.
Announce at start: "Using appletv on ." — one line, then work.
Commands below run from the directory containing this
SKILL.md($SKILL_DIR). Resolve it once. Every command isnode scripts/appletv.js <cmd>.
The one rule
Never report a command as done until the Apple TV's state has been read back and agrees with it — a keypress over the network fails silently, so every send ends in exactly one of verified, mismatch or unverifiable, and only the first is ever called done.
send enforces this in code (scripts/lib/verify.mjs): it reads state before,
sends, polls the read-back until the expected field moves (or a per-command
ceiling passes), and prints a verdict per step. Three things it will not do:
call a state that already matched before the send "verified" (turning on a TV
that was on proves nothing), call a read-back that never moved a "mismatch" on
the TV app (it is known to freeze at skip points), or call a launch "verified"
unless the now-playing owner changed to the target. Your job is to say the
verdict honestly — "sent menu, can't confirm from here; the screen shows
Netflix" is a fine answer; "done" over a sent row is the failure this skill
exists to prevent.
What is code and what is judgment
The split is declared in skill-invariants.json and checked — a deterministic
step whose command does not exist fails skillfactory verify.
| Deterministic — the machine decides | Command |
|---|---|
| check python and pyatv, make the venv | node scripts/appletv.js doctor |
| discover every Apple TV on the network, with the unicast fallback | node scripts/appletv.js scan |
| pair one named device over AirPlay and Companion and store credentials | node scripts/appletv.js pair --device <name> |
| bind a room alias and the default device | node scripts/appletv.js alias <room> --device <name> |
| read power, app, focus, volume and now-playing back | node scripts/appletv.js state --device <name> |
| send a command and verify it by read-back, with a verdict per step | node scripts/appletv.js send --device <name> <command> |
| list apps and resolve a name or deep link to a launch target | node scripts/appletv.js apps --device <name> |
| type into the focused field and read it back | node scripts/appletv.js type --device <name> <text> |
| render a captured run as the report | node scripts/appletv.js report --from <dir> |
| take a screenshot of the TV over the developer tunnel | node scripts/appletv.js screen |
| press to a named person's tile on a picker and select | node scripts/appletv.js who <name> |
| open an app on the household's preferred profile | node scripts/appletv.js open <app> |
| play a title by deep link where the service honours one, verified by read-back | node scripts/appletv.js play <url> |
| Model judgment — nothing on disk answers it | Why |
|---|---|
| pick which Apple TV the user means when the phrase is a room, not a device name, and offer to alias it | 'the living room one' is a fact about the house, not the network |
| turn an intent into a command sequence — 'skip the intro' into skip_forward, 'put on Severance' into a deep link, 'movie night' into wake + launch + play | an intent is an app, a menu path and several keypresses that no table records |
| read the PIN off the screen through the user, one protocol at a time | pairing shows a code on the TV that only a person in the room can see |
| decide whether to confirm first — turning off or switching apps while something is playing asks, pause and skip do not | the cost of a wrong action depends on who is watching, which the state hints at and the person knows |
| show a string before typing it when it looks like a password, and never echo it back | the on-screen keyboard is where credentials get entered and a transcript is forever |
| explain a mismatch or an unverifiable result and propose the next move | the TV was asleep, the wrong app had focus, tvOS hides that field, or the command was refused — the state says which, the fix is a judgment |
| read a screenshot — which tile is highlighted, which episode is the latest that is not "coming Friday", whether that black frame is DRM video or a sleeping TV | pixels are the only foreground read-back tvOS has, and only a model can read them |
| ask who is using the TV when a picker shows, as a list of the household, and never pick for them | a shared TV's session is a fact about the room, and choosing wrong logs someone into someone else's profile |
The flow
1. Detect — never ask what you can read
node scripts/appletv.js doctor
One table: python, the venv (created on first run — takes a minute, say
installing pyatv…), pyatv, whether credentials exist, how many TVs are
remembered with or without a default, whether screenshots work, and which
services the household subscribes to. Never ask about anything in it.
If doctor shows a remembered device with credentials, skip straight to the
request — the user does not want to hear about setup twice.
1a. Who is using the TV — ask, never guess
A cold boot lands on tvOS's user picker ("who's watching?" — every family member as a tile), and apps like Netflix have their own. The skill never picks a person by itself:
- After
turn_on(oropen <app>),screen. If a picker is showing, and the household is not yet recorded, read the names off it left→right (or top→bottom) and save them once:appletv pref users "Nathaniel, McKenzie, Angie"(appletv pref netflix --profiles "…" --layout verticalfor an app). - Ask with a list —
AskUserQuestion, headerWho's watching, one option per member, the household default first and marked "(Recommended)". Do this every time a picker shows; a household's TV is shared, and yesterday's answer is not today's. appletv who <name> [--app <app>]presses from tile 1 to that person and selects, then takes a screenshot; confirm the picker is gone before going on.
If the user names the person in the request ("turn on the TV for McKenzie"),
skip the question and who McKenzie. If no picker shows, do nothing — say
whose session it is only if screen makes it obvious.
1b. Someone is watching
appletv pref hold on (or state showing playing with a title) means a person
is in front of the TV. Then: pause, volume, and "what's playing" are fine;
anything that changes the screen — send navigation, open, play, type,
turning off — is refused by the CLI (on_hold) and, when not on hold but
playing, asked about first in one line with the title in it. "I am watching,
don't test" is pref hold on, and stays until they say otherwise.
2. First contact: scan, pair, alias — once per TV
Only when doctor says no devices or no credentials.
node scripts/appletv.js scan # multicast; --hosts <ip> for unicast
Show the table. An empty scan prints the likely cause and the fix — relay it,
then try --hosts <ip> if the user knows the address. Do not loop scanning.
node scripts/appletv.js pair --device "<name>" # run in the BACKGROUND
Pairing is two protocols, one PIN each, and the session must stay alive while
the code is read off the screen. Before starting, say once: "Stand in front
of — it will show a 4-digit code the moment pairing starts, then a second
one; tell me each as you see it." Run pair with run_in_background (the
window is 10 minutes per code), watch for ▶ … is showing the airplay code NOW, ask for the four digits, then:
node scripts/appletv.js pair --pin <code> # leading zeros count — pass it as typed
It moves on to Companion by itself; repeat the ask once. The result table says
what each protocol unlocks. A refusal names the TV setting that fixes it
(references/pairing.md).
node scripts/appletv.js alias "living room" --device "<name>" --default
Ask at most one question here: what the user calls this TV, if they have more than one. With one TV, alias nothing — it is already the default.
3. Do what was asked — and read back
node scripts/appletv.js state [--device <room>] # "what's playing"
node scripts/appletv.js send [--device <room>] pause # one step
node scripts/appletv.js send [--device <room>] "turn_on,launch_app=com.netflix.Netflix"
node scripts/appletv.js apps [--device <room>] netflix # id for a name, or a deep link
node scripts/appletv.js type [--device <room>] "stranger things"
references/intents.md maps the twenty common asks to commands and says which
ones confirm first. Two that always do, when state shows something
playing: turn_off and switching apps. Ask in one line with the title in it:
"The Bear is playing on Living Room — turn it off anyway?"
Before type, if the text looks like a password (the field is a login, or the
user says so), show it once and ask; after typing, never repeat it. The capture
records the field's read-back, so with --out a password would be on disk —
never pass --out on a type that carries a secret.
3a. Which service — never assume one
doctor lists the services the household pays for (appletv pref services "netflix, disney+, apple tv, paramount+" sets them; local config, never the
repo). "Put on X" means: find X on one of those, in that order of
preference if it is on several, and if it is on none of them say so and stop
— never launch a store page, a rental, or an app they do not have. If the
list is empty, ask once ("what do you subscribe to?") and save the answer.
3b. Navigating inside an app — look, press, look
Nothing on the network says what is on screen: state reports the now-playing
owner, which changes only once something plays. Netflix disabled deep links
on tvOS in Sept 2025. So any task that needs "find X in the app" runs the loop:
node scripts/appletv.js screen # Read the PNG: what is highlighted?
node scripts/appletv.js send <one press>
node scripts/appletv.js screen # did it do what you predicted?
node scripts/appletv.js state # the end: app == target and playing
Never send a navigation press you cannot picture the result of. One press
(or one obvious run of the same press) per look. A wrong guess on a TV opens
the wrong app in front of whoever is watching — this happened, and it is the
reason this section exists. If screen is unavailable (no tunnel — doctor
says screenshots off), screen opens a Terminal window for the one sudo
line; say "type your Mac password in the Terminal window that just opened"
and wait. Asking the user what is on the screen is a failure of this skill,
not a fallback — with eyes, never ask; without eyes, say the task needs the
tunnel and stop.
Narrate the loop with at most one lowercase status line (navigating Apple TV+ → Silo → Season 3 → E1…), not a sentence per press. Report once at
the end. Delete the captures when the task is done: appletv screen --clean.
Things the eyes have taught (references/screen.md has the rest):
- Netflix resumes wherever it was left; its episode list highlights the in-progress episode, not E1. Look before counting presses.
- Apple TV+ runs a promo before an episode with Skip focused;
selectit and wait — sendingplayduring the promo drops back to the list. - "Latest episode" is the last tile without a "coming Friday" badge.
- A black capture while
statesaysplayingis DRM video: success. open netflixlands on the household's profile (appletv pref); with several profiles, always go throughopen, neverlaunch_appalone.- Screenshots are someone's TV: only the last 3 are kept,
screen --cleandeletes them, and none is ever captured into a run directory unless--outis passed on purpose.
4. Report — one table, one sentence, stop
send, open and play print a compact | Step | Command | Result | table —
verified (off → on), sent for keypresses (the look is their verification),
mismatch — why, already on — with runs of the same keypress collapsed
(down ×5). --verbose shows every read-back. Show that table once, then one
sentence in the verdict's own words:
| Verdict | Say |
|---|---|
| verified | "Paused Severance on Living Room." |
| sent / unverifiable | "Sent menu to Living Room — can't confirm from here; the screen shows Netflix." |
| mismatch | "Sent pause but Living Room still reads playing — YouTube may be ignoring it; try play_pause?" |
Never "Done." on the last two. A mismatch exits non-zero on purpose. When the proof came from the screen rather than the counter (the TV app freezes its report at skip points), say which: "playing — from the screen; the TV app's counter stuck at 1:41".
Commands
| Command | Returns |
|---|---|
appletv doctor [--install] |
python, venv, pyatv version, credentials store, remembered devices — creates the venv when missing |
appletv scan [--hosts ip,ip] [--timeout s] |
every Apple TV: model, tvOS, address, paired protocols, what still needs pairing, alias; names the fix when empty |
appletv pair --device <name> [--protocol airplay|companion|all] [--force] |
pairs AirPlay then Companion, one PIN each, credentials to ~/.pyatv.conf; per-protocol result and what it unlocks |
appletv pair --pin <code> |
delivers the on-screen PIN to the waiting pairing session |
appletv alias [<room> --device <name> [--default]] |
binds a room name; sets the default; no args lists them |
appletv state [--device <x>] |
power, foreground app, playback, title/series/episode/position, keyboard focus, volume; a field tvOS cannot report says known-unsupported, never blank |
appletv send [--device <x>] <cmd[=arg][,cmd…]> |
one connection for the whole sequence; read-back polled until the expected field moves; a compact result per step (--verbose for every read); stops at the first mismatch; exits non-zero on any. select=hold / select=double for long and double presses |
appletv apps [--device <x>] [<name or url>] |
installed apps with bundle ids; resolves a name or a deep link to a launch target |
appletv type [--device <x>] <text> [--append] | --clear | --get |
puts text in the focused field and reads it back; refuses when nothing is focused |
appletv report --from <dir> |
the same tables from a captured run, verdicts re-derived — exits non-zero if a recorded verdict no longer follows from its capture |
appletv screen [--width 1280] |
a screenshot over the developer tunnel (~2.5 s), downscaled; Read the path it prints. --pair does the one-time developer pairing, --install-tunnel writes the LaunchDaemon |
appletv pref <app> --profile <name> --position <n> |
this household's profile per app, on this Mac only (never the repo) |
appletv pref services "<a, b, c>" |
the services the household subscribes to; apps marks them and the model only ever plays on these |
appletv open <app> |
turn on, launch, then — with eyes — take a screenshot for the model to pick the profile from; without eyes, press the preferred tile and say so |
appletv pref users \"A, B, C\" [--layout] [--default A] |
the household as the tvOS picker shows them, in tile order (local only) |
appletv pref <app> --profiles \"A, B, C\" [--layout vertical] |
an app's own profile picker, in tile order |
appletv who <name> [--app <app>] |
press from tile 1 to that person and select; screenshot to confirm. No name lists the members |
appletv play <deep link> [--title <expected>] |
for services that honour deep links (YouTube, Disney+, Apple TV+, Hulu, Peacock); verified when the app is the now-playing owner and playing |
--device takes an alias, a name, an identifier or an IP; omit it for the
default. --out <dir> on any live command writes its JSON captures there.
Rules that are not negotiable
- Never report a command as done until the Apple TV's state has been read back and agrees with it — a keypress over the network fails silently, so every send ends in exactly one of verified, mismatch or unverifiable, and only the first is ever called done.
- Never claim a result you did not observe. Say what you verified and what you did not.
- Never turn off or switch apps over something playing without asking. The state tells you; the person in the room decides.
- Never echo a typed password, and never capture one with
--out. - Never navigate blind. A deep link is verifiable; a keypress is not — so
every navigation press is preceded by
screenand followed by one. No tunnel, no navigation: say so. - Never ask what
doctororscanalready answered, and never ask more than one question in a row. - Never put a title on a service the household does not have. The list is
in
doctor; if the title is only elsewhere, say where and stop. - Never pick a person on a picker without asking. The household is a list; the answer is theirs, every time.
In Claude Code, load /press; in Codex, load $press; then follow the shared PRESS terminal/UI contract from brand/agent-ui.md. Do not copy or override that contract here.