# Ytstudio

> Manage and automate a YouTube channel from the terminal with the ytstudio CLI - list and bulk-edit video metadata (search-replace titles/descriptions/tags), upload videos from YAML sidecars, query channel and per-video analytics, moderate comments, control live broadcasts, and run bulk playlist operations. Use when a task involves administering, scripting, or reporting on a YouTube channel rather than just watching or searching public videos.

- Skill: `jdwit/ytstudio` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add jdwit/ytstudio`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jdwit/ytstudio/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- License: MIT
- Author: jdwit (https://skillmd.com/u/jdwit)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jdwit/ytstudio

---


# ytstudio

`ytstudio` is a command-line tool over the official YouTube Data and Analytics
APIs. It exists to do at scale what YouTube Studio's web UI makes you click
through one item at a time: bulk metadata edits, batch uploads, scripted
analytics, comment moderation, live broadcast control, and playlist operations.

This skill teaches you to drive it. It is harness-agnostic: every instruction is
a shell command.

## Two things to know first

These two rules apply to almost every command and prevent the most common
mistakes:

1. **Ask for JSON.** Read commands print a human table by default. Pass
   `-o json` (alias for `--output json`) to get parseable output. Some commands
   also support `-o csv`. The auth/setup commands (`init`, `login`, `status`)
   have no JSON mode.
2. **Most mutations are dry-run by default.** Commands with `--execute`
   (`videos update/search-replace/upload`, `livestreams schedule/update`, and
   playlist writes) preview what they *would* do and change nothing until you
   re-run the exact command with `--execute`. Always preview first, show the
   user the preview when consequential, then re-run with `--execute`.

Some writes execute immediately because they have no `--execute`: comment
moderation/replies (`comments publish`, `comments reject`, `comments reply`) and
livestream transitions (`livestreams start`, `livestreams stop`). Treat these as
high-risk: confirm intent before running them.

Treat write operations as costly and irreversible-ish: they consume API quota
(see [Quota](#quota-awareness)) and act on a real, public channel.

## Prerequisites

### Install

`ytstudio` is on PyPI and needs Python 3.12+. Prefer an isolated install:

```bash
uv tool install ytstudio-cli      # recommended
# or: pipx install ytstudio-cli
# or: pip install --user ytstudio-cli
```

The CLI installs as `ytstudio`; `yts` is a short alias for the same entry point.
Verify with `ytstudio --version`.

### One-time OAuth setup

The user brings their own Google OAuth client (a "Desktop app" client created in
the Google Cloud Console with the YouTube Data API v3 and YouTube Analytics API
enabled). With the downloaded client-secrets JSON:

```bash
ytstudio init --client-secrets path/to/client_secret.json
ytstudio login                    # opens a browser to authorize
ytstudio status                   # confirms the authenticated channel
```

On a headless box, use `ytstudio login --headless`: it prints a URL to open in
any browser, then you paste the failed `127.0.0.1` redirect URL back in.

If `ytstudio login` fails with `access_denied`:

- Sign in with the Google account that owns the channel.
- If the OAuth app is still in "Testing", add that account under Google Cloud
  Console -> APIs & Services -> OAuth consent screen -> Test users.
- Click through the "Google hasn't verified this app" warning via Advanced ->
  Continue.
- Approve all requested (read-only) scopes; do not close the tab until it
  reports success.

Credentials live owner-only under `~/.config/ytstudio-cli/`. This step is
one-shot per channel; do not re-run it unless auth is actually broken.

If `ytstudio status` reports no authenticated channel, stop and ask the user to
complete OAuth setup; you cannot do the browser consent for them.

### Multiple channels (profiles)

One install can hold several channels, each a named profile. Commands act on the
**active** profile unless overridden:

```bash
ytstudio profile list             # active profile is marked
ytstudio profile use work         # switch active profile
YTSTUDIO_PROFILE=work ytstudio videos list   # override for one command (scripting)
```

Use the `YTSTUDIO_PROFILE=<name>` env override when scripting so you never mutate
the wrong channel by relying on global state.

## Command groups

Below is the minimum to operate each area. For the complete flag surface of any
command, read [references/reference.md](references/reference.md) on demand
rather than guessing - or run `ytstudio <group> <command> --help`.

### videos - the core use case

```bash
ytstudio videos list -n 100 -o json          # recent uploads, parseable
ytstudio videos list --sort views -n 100 -o json  # fetched videos sorted by lifetime views
ytstudio videos list --sort likes -n 100 -o json  # fetched videos sorted by lifetime likes
ytstudio videos list --scheduled             # only future-dated publishes
ytstudio videos show <video-id> -o json      # full metadata for one video
ytstudio videos categories                   # category ids assignable on upload

# Single-video edit (dry-run, then --execute)
ytstudio videos update <video-id> --title "New title"
ytstudio videos update <video-id> --tags one,two,three --execute

# Bulk search-replace across the channel (dry-run, then --execute)
ytstudio videos search-replace -s "2024" -r "2025" -f title
ytstudio videos search-replace -s 'season \d' -r 'season X' -f title --regex --execute
```

`videos list --sort views|likes` sorts the fetched uploads by lifetime Data API
statistics, so raise `-n` when you need broader channel coverage; use
`analytics query -d video` for exact period-specific rankings. `videos show -o
json` returns a flat object with fields such as `title`, `published_at`,
`views`, `likes`, `comments`, `privacy`, `tags`, `localizations`, and
`scheduled_publish_at`.

`search-replace` requires `-s/--search`, `-r/--replace`, and `-f/--field`
(`title` or `description`); `--limit` caps how many matches it acts on (default
10). Preview the dry-run, confirm the match set is what the user intended, then
add `--execute`.

### authoring metadata - transcript-grounded, on-brand

Writing a good title or description is per-video creative work. The CLI supplies
the two raw materials an agent needs to do it well; you write the copy, and the
existing `videos update` path applies it.

```bash
ytstudio profile brand show                   # house style / tone (system context)
ytstudio videos captions <id> -o json         # list caption tracks, pick a language/kind
ytstudio videos transcript <id> --lang nl     # spoken content, the source of truth for claims
ytstudio videos show <id> -o json             # current title/description/tags
ytstudio videos update <id> --title "..." --description "..." --tags a,b,c   # dry-run preview
ytstudio videos update <id> --title "..." --execute                          # apply
```

The authoring loop and its rules:

- **The brand file is the source of truth for tone; the transcript is the source
  of truth for claims.** Never state anything the transcript does not support,
  and never invent content.
- Set the brand voice once per channel with `ytstudio profile brand edit` (opens
  `$EDITOR`) or `... brand set --file <path>`; it is stored at
  `profiles/<name>/brand.md` and printed verbatim by `brand show`.
- **Prefer a `standard` (human) track over `ASR` (auto-generated).** `videos
  captions -o json` returns one object per track with `language`, `track_kind`
  (`standard`/`ASR`/`forced`), and `is_draft`; pick the `standard` track in the
  language you want. `transcript` defaults to clean plain text; `-o json` adds
  the track metadata.
- **Multi-language channels: pass `--lang` explicitly.** Without `--lang`,
  `transcript` auto-picks (preferring `standard` over `ASR`) across all tracks,
  which may not be the language you mean. Read the `language` codes from
  `videos captions` first, then pass the exact one, e.g. `--lang nl`. If the
  requested language has no track, `transcript` exits with an error listing the
  available codes - re-run with one of those.
- **When only ASR exists, use it but treat claims with extra care**, since
  auto-generated text is noisier; the brand-vs-transcript rule still holds.
- If there is no usable transcript (no tracks, or a track exists but is not
  downloadable - common for restricted ASR, where `transcript` prints a yellow
  warning and exits non-zero), fall back to `videos captions` to pick another
  track, and if none is usable **tell the user instead of authoring from
  nothing.**
- Draft, then **preview with `videos update` (dry-run), show the user when the
  change is consequential, then `--execute`** (see the two-rules-first section).

`videos captions` (~50 units) and `videos transcript` (~200 units) cost more
than ordinary reads, so author per-video rather than scanning a whole channel.

**Bound the batch against the 10,000 units/day budget.** Per fully-authored
video the worst case is ~50 (captions gate) + ~250 (transcript, which itself
lists then downloads) + ~50 (update) = ~350 units, so roughly 28 videos exhaust
a fresh daily budget on their own. The captions gate still pays for itself: it
skips the ~250-unit transcript entirely for videos with no usable track (its
only redundancy is the cheap list call, paid twice when a track does exist).
Estimate `candidates x 350` before you start, subtract any quota already spent
that day, and **warn the user before a run that could blow the budget** rather
than discovering `quotaExceeded` (HTTP 403) mid-batch. If you do hit it, the
budget is spent until the next midnight-Pacific reset; report how many videos
you finished so the rest can resume after reset.

#### Bulk authoring - backfill across a set of videos

Authoring is per-video, but you often want it across many videos at once
(backfill empty descriptions, rewrite titles for a topic). Do it as a loop with
one cheap selection pass up front, not a channel-wide transcript scan.

```bash
# 1. Select candidates cheaply (~1 unit). Weak metadata shows up in JSON fields:
ytstudio videos list -n 100 -o json     # then filter client-side on:
#   description == ""    -> missing description
#   tags == []           -> no tags
#   default_audio_language == null / localizations == {}   -> language gaps

# 2. Per candidate, check tracks BEFORE paying for a transcript (~50 vs ~200 units):
ytstudio videos captions <id> -o json   # any standard track? any track at all?

# 3. Only if a usable track exists, pull grounding and current state:
ytstudio videos transcript <id> --lang nl -o json   # ~200 units
ytstudio videos show <id> -o json

# 4. Draft on-brand, then preview each change (dry-run):
ytstudio videos update <id> --description "..." --tags a,b,c

# 5. Batch-confirm the whole set with the user, then apply each:
ytstudio videos update <id> --description "..." --tags a,b,c --execute
```

The bulk loop and its rules:

- **Select once, author per video.** Run `videos list -o json` a single time and
  filter the array yourself; do not call it per video. The fields that flag weak
  metadata are `description` (empty string), `tags` (empty list),
  `default_audio_language` / `default_language` (null), and `localizations`
  (empty dict).
- **Gate the expensive call.** Always run `videos captions` first; skip
  `videos transcript` for any video with no usable track. Captions list is ~50
  units, a transcript download is ~200 - the gate is most of the quota saving.
- **Collect all dry-run previews, then ask once.** Show the user the full set of
  proposed changes in one batch (not one prompt per video), get a single
  go-ahead, then re-run each `videos update` with `--execute`.
- **Skip rather than invent.** Any video without a usable transcript stays
  untouched and is reported to the user, per the single-video rules above; never
  backfill from nothing.

### videos upload - batch upload from YAML sidecars

`ytstudio videos upload <path>` pairs each video file with a sibling YAML
sidecar of the same basename, validates everything, and uploads. Dry-run by
default. See [assets/upload-sidecar.example.yaml](assets/upload-sidecar.example.yaml)
for the sidecar schema (`title`, `description`, `privacy`, `tags`,
`category_id` (required), languages, `made_for_kids`, optional `publish_at`).

```bash
ytstudio videos upload ./outbox               # validate + preview
ytstudio videos upload ./outbox --execute --max 3   # upload, capped for quota
```

Uploads are resumable: after each success the sidecar is patched with `video_id`
and `uploaded_at`, so re-running only retries sidecars that lack a `video_id`.
Use `--max` to bound a run because uploads are quota-heavy (~1600 units each).

### analytics - reporting (read-only)

```bash
ytstudio analytics overview -d 28 -o json     # channel overview, last 28 days
ytstudio analytics video <video-id> -o json   # per-video analytics
ytstudio analytics metrics                     # discoverable metric names
ytstudio analytics dimensions                  # discoverable dimension names

# Custom query straight against the Analytics API reports.query endpoint:
ytstudio analytics query -m views,likes -d day --days 7 -o json
ytstudio analytics query -m views -d country --sort -views -n 10 -o json
ytstudio analytics query -m views -d video --days 180 --sort -views -n 10 --resolve -o json
ytstudio analytics query -m views -d insightTrafficSourceType -f video==<id> -o json
```

`analytics query` needs `-m/--metrics`; `-d/--dimensions`, `-f/--filter`
(`key==value`, repeatable), `--sort` (prefix `-` for descending), `-n/--limit`,
and date range (`--days` or `-s/-e` start/end) are optional. `-d video` and
`-d playlist` return IDs; add `--resolve` to include `videoTitle` /
`playlistTitle` columns in table, JSON, or CSV output. When unsure which metric
or dimension exists, list them first with `analytics metrics` /
`analytics dimensions` instead of guessing names.

For `-d month` (and `-d week`), the CLI snaps `-s`/`-e` down to the boundary the
YouTube Analytics API requires (first of the month), so `--days` and arbitrary
dates just work. The range is inclusive on both ends, so the end month is the
last month you want, not the month after.

```bash
# 12 months, Jun 2025 through May 2026:
ytstudio analytics query -m views -d month -s 2025-06-01 -e 2026-05-01
```

### comments - moderation

```bash
ytstudio comments list -o json                        # recent comments across the channel
ytstudio comments list --status held -o json          # the moderation queue
ytstudio comments list --status spam -o json          # likely spam comments
ytstudio comments list --sort time -o json            # channel-wide newest first
ytstudio comments list -v <video-id> --sort relevance -n 50 -o json
ytstudio comments publish <comment-id> [<comment-id> ...]   # approve held; executes immediately
ytstudio comments reject <comment-id> --ban                 # reject (+ optional ban); executes immediately
ytstudio comments reply <comment-id> -t "Thanks!"           # executes immediately
```

`comments list` without `-v/--video` is channel-wide. Use `--status spam` for
spam review. Channel-wide listing supports `--sort time`; `--sort relevance`
requires `-v/--video` because of a YouTube API limitation. `publish`/`reject`
take one or more comment ids and execute immediately (no `--execute` dry-run).
`reply` also posts immediately. Confirm the exact comment ids/text first.
`--ban` on `reject` also bans the author - only use it when the user explicitly
asks to ban.

### livestreams - broadcast lifecycle

```bash
ytstudio livestreams list -s upcoming -o json
ytstudio livestreams show <broadcast-id> --ingest -o json   # ingest URL; key redacted
ytstudio livestreams schedule -t "Title" --scheduled-start 2026-07-01T19:00:00+02:00 --execute
ytstudio livestreams start <broadcast-id> --to testing      # executes immediately; or --to live
ytstudio livestreams stop <broadcast-id>                    # executes immediately
ytstudio livestreams update <broadcast-id> --privacy unlisted --execute
```

`schedule`/`update` are dry-run until `--execute`, but `start`/`stop` execute
immediately. `livestreams show --show-key` reveals the stream key - treat any
such output as a secret and never echo it into logs or chat. `start --to live`
publishes to viewers; prefer `--to testing` unless the user wants to go live
immediately.

### playlists - bulk operations

```bash
ytstudio playlists list -o json
ytstudio playlists items <playlist-id> -o json
ytstudio playlists create -t "Title" --privacy unlisted --execute
ytstudio playlists add <playlist-id> --from-search "topic" -n 20 --execute   # search costs 100 units/call
ytstudio playlists add <playlist-id> -v <video-id> -v <video-id> --execute
ytstudio playlists reorder <playlist-id> --by views --order desc --execute
ytstudio playlists remove <playlist-id> -v <video-id> --execute
ytstudio playlists delete <playlist-id> --execute -y         # -y skips the prompt
```

All playlist writes are dry-run until `--execute`. `delete` also prompts for
confirmation unless `-y/--yes` is passed; only add `-y` when running
non-interactively and the user has confirmed the deletion.

## Quota awareness

The YouTube Data API has a default budget of 10,000 units/day per project,
resetting at midnight Pacific. Rough costs:

| Operation | Cost |
|---|---|
| Read (list/show videos, comments, playlists; analytics) | ~1 unit |
| Caption list (`videos captions`) | ~50 units |
| Caption download (`videos transcript`) | ~200 units |
| Write (update video, moderate comment, playlist insert/reorder, schedule broadcast) | ~50 units |
| Search (`playlists add --from-search`) | ~100 units/call |
| Upload (`videos upload`) | ~1600 units |

Before kicking off a large bulk run (e.g. `search-replace` over hundreds of
videos, or several uploads), estimate the cost and warn the user if it could
exhaust the daily quota. A `quotaExceeded` response (HTTP 403) means the budget
is spent until the next reset; long-running jobs (`videos upload`) stop cleanly
and report how many succeeded so they can be resumed later.

## Recommended workflow for an agent

1. Confirm setup once with `ytstudio status` (and `profile list` if multiple
   channels may be in play). Select the channel with `YTSTUDIO_PROFILE=` when
   scripting.
2. Gather state with read commands using `-o json` and parse the result.
3. For any change, run the command without `--execute` first, inspect the
   preview, and surface it to the user when the change is consequential or bulk.
4. Re-run the identical command with `--execute` to apply.
5. Mind the quota for bulk and upload operations.

When a flag or behavior is unclear, consult
[references/reference.md](references/reference.md) or `--help` rather than
assuming.

