# Appletv

> Interact with and control the Apple TVs on the local network from the Mac you are on — scan for them, pair with one, and drive it: play, pause, skip, turn on or off, open an app or a deep link, type into the on-screen keyboard, navigate the menu, set the volume, and say what is playing — reading the device's state back after every command. Use when the user says "appletv", "control my apple tv", "connect to my apple tv", "find my apple tvs", "pair with the apple tv", "pause the apple tv", "turn off the tv", "what's playing on the apple tv", "open netflix on the living room tv", "skip the intro", "type stranger things into the tv", or wants to drive an Apple TV without picking up the remote. Built on pyatv over the Companion and AirPlay protocols.

- Skill: `natejswenson/appletv` (Agent Skill, multi-file: 36 files)
- Install (CLI): `npx skillmds@latest add natejswenson/appletv`
- Raw SKILL.md: https://api.skillmd.com/api/skills/natejswenson/appletv/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: natejswenson (https://skillmd.com/u/natejswenson)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/natejswenson/appletv

---


## 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 <device from doctor>." — one line, then work.

> Commands below run from the directory containing this `SKILL.md` (`$SKILL_DIR`).
> Resolve it once. Every command is `node 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

```bash
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:

1. After `turn_on` (or `open <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 vertical` for an app).
2. **Ask with a list** — `AskUserQuestion`, header `Who'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.
3. `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.

```bash
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.

```bash
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 <TV> — 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:

```bash
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`).

```bash
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

```bash
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:

```bash
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; `select`
  it and wait — sending `play` during the promo drops back to the list.
- "Latest episode" is the last tile *without* a "coming Friday" badge.
- A black capture while `state` says `playing` is DRM video: success.
- `open netflix` lands on the household's profile (`appletv pref`); with
  several profiles, always go through `open`, never `launch_app` alone.
- Screenshots are someone's TV: only the last 3 are kept, `screen --clean`
  deletes them, and none is ever captured into a run directory unless
  `--out` is 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 `screen` and followed by one. No
  tunnel, no navigation: say so.
- **Never ask what `doctor` or `scan` already 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.

<!-- press:runtime -->
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.
<!-- press:runtime -->

