# YouTube

Flaghand connects a YouTube channel with Google's own sign-in (OAuth 2.0) and works with the YouTube Data API as that
channel. An agent starts the connect; the channel's owner picks the channel on Google's account chooser and allows
access. Connecting doesn't publish anything: nothing posts without the owner's passkey approval of the exact post.

By connecting, the owner agrees to [YouTube's Terms of Service](https://www.youtube.com/t/terms) and
[Flaghand's Privacy Policy](https://flaghand.com/privacy). Google's own controls for the access Flaghand has are at
[Google security settings](https://security.google.com/settings/security/permissions).

## Routes

| Route | Who | Does |
|---|---|---|
| `POST /workspaces/{workspace}/connections/youtube/start` | `connections` scope | Starts connecting. The answer's `url` is the sign-in link, which leads to Google's sign-in and consent page, for the channel's owner to open (once, within 15 minutes). |
| `POST /workspaces/{workspace}/connections/authorize` | owner | The same, started by the owner (`{ "platform": "youtube" }`). |
| `GET /workspaces/{workspace}/connections` | `read` | The connection: `external_id` is the channel id (`UC…`), `handle` its handle, `scopes` what the owner allowed. Never the tokens. |
| `POST /workspaces/{workspace}/connections/{id}/verify` | `read` | Reads the channel the login belongs to and checks it's the pinned one. |
| `DELETE /workspaces/{workspace}/connections/{id}` | owner | Disconnects: revokes Flaghand's access at Google and deletes the stored login and the channel's details. |
| `POST /workspaces/{workspace}/drafts` | `drafts` | A `youtube_video` draft (a video or a Short), a `youtube_reply` to a comment, a `youtube_moderation` batch, a `youtube_playlist` change or a `youtube_post` the person posts themselves. |
| `POST /workspaces/{workspace}/drafts/{id}/check` | `read` | What YouTube would refuse or change, with fixes, and the snapshot an approval signs. |
| `GET /workspaces/{workspace}/jobs/{id}` | `read` | The upload or reply: `result.youtube` has its step, the id and link, and what YouTube shows against what was approved. |
| `POST /workspaces/{workspace}/jobs/{id}/status` | `read` | Reads the video on YouTube again. Only reads. |
| `POST /workspaces/{workspace}/jobs/{id}/cancel` | `cancel` | Before the upload, cancels it. After, takes a video YouTube holds for later off its schedule. |
| `GET /workspaces/{workspace}/connections/{id}/youtube-analytics` | `read` | The channel's numbers from YouTube Analytics, read live and kept nowhere. |
| `GET /workspaces/{workspace}/connections/{id}/youtube-quota` | `read` | How much of YouTube's daily quota the channel used today. Calls nothing on YouTube. |
| `GET /workspaces/{workspace}/connections/{id}/youtube/comments` | `read` | Comments on the channel's own videos, read live and kept nowhere. `status`: `published`, `held` or `likely_spam`. |
| `GET /workspaces/{workspace}/connections/{id}/youtube/comments/{thread_id}/replies` | `read` | All the replies in a comment thread, read live and kept nowhere. |

## Connecting

An agent with the `connections` scope calls `POST …/connections/youtube/start` and gives the owner the `url` and the
`for_person` text. To reconnect, send `connection_id`.

Flaghand asks Google for two permissions:

- **Manage your YouTube account** (`youtube.force-ssl`): upload and edit videos and comment as the channel. Without it
  there's nothing to connect, and the sign-in is refused: connect again and leave it ticked.
- **View YouTube Analytics reports** (`yt-analytics.readonly`): the channel's numbers. The owner can untick it; everything
  else still works, and `scopes` shows it's missing.

When the sign-in comes back, Flaghand reads which channel granted it and pins the connection to that channel's id: a
handle can change, the id can't. A Brand Account's channel connects the same way (pick it on the account chooser).
Reconnecting a connection as another channel is refused, and that sign-in's access is revoked at once. A Google account
with no channel doesn't connect.

The login and the channel's details are encrypted with a key of the connection's own. That key is never in a backup,
and disconnecting (or deleting the account) destroys it, so nothing from YouTube can be read back from a backup either.

Logins renew on their own. Flaghand also tries the login once a day, so access the owner removed in their Google
settings is noticed within a day: the connection shows `expired` (connect it again). An expired YouTube connection that
isn't reconnected is disconnected 6 days later, with its stored data deleted.

Once a week that try also reads again what Flaghand keeps from YouTube: the channel's name, handle and picture, and
whether each video and reply it posted is still there (`videos.list` and `comments.list`, 50 ids a request, about 1 unit
of YouTube's quota each). A week whose read fails is read the next day. A video or reply YouTube no longer has keeps its
job, in Flaghand's own words, but loses its ids: `result.youtube.gone_at` says when, and `video_id`, `comment_id` and
`url` are null from then on. The workspace export has the ids each job still holds.

## Drafts

```json
{
  "kind": "youtube_video",
  "connection_id": "con_…",
  "post_at": "2026-11-12T17:00:00Z",
  "body": {
    "title": "Plan a week in five minutes",
    "description": "The three lists, and what goes on each.",
    "tags": ["planning", "weekly review"],
    "media": [{ "media_id": "med_…" }],
    "short": true,
    "visibility": "public"
  }
}
```

- `media`: one MP4 or MOV, sent to YouTube as it is (up to Flaghand's 50 MB file size). It takes no
  `captions_media_id` or `cover_media_id`: a draft with either is refused; use `thumbnail` for a custom thumbnail.
- `title`: up to 100 characters. `description`: up to 5,000 bytes (most emoji count 4, accented letters 2). `tags`: up to
  500 characters in all, counting the commas between them and the quotes YouTube adds around a tag with a space. None of
  them can have `<` or `>`.
- `short`: meant as a Short. YouTube decides from the video itself: square or vertical, and up to 3 minutes. When the
  video isn't one, check says so and offers to post it as a regular video.
- `thumbnail`: a JPEG or PNG. YouTube sets custom thumbnails only on a verified channel, and takes videos over 15 minutes
  only from one: check asks YouTube where the channel stands and says so (verify at youtube.com/verify).
- `visibility`: `public` (default), `unlisted` or `private`. The owner can change it when approving.
- `altered_content`: altered or synthetic content that looks real (YouTube's label). `paid_promotion`: paid product
  placement, a sponsorship or an endorsement (YouTube's paid promotion label). Both default to off.
- `category_id` (default `"22"`, People & Blogs) and `default_language` (like `"en"`).

## Approving

YouTube asks one question of every upload: whether the video is made for kids. Only the owner answers it, in the
approval sheet; nothing is preselected and no agent can give it. It goes with the approval item as `youtube`:

```json
{ "draft_id": "drf_…", "rev": 2, "mode": "schedule", "youtube": { "made_for_kids": false, "visibility": "public" } }
```

Next to the button ("Approve and upload") the sheet shows YouTube's notice: that the upload complies with the
[YouTube Terms of Service](https://www.youtube.com/t/terms), including the Community Guidelines, and doesn't violate
others' copyright or privacy rights. The snapshot the passkey signs holds the channel, the exact request for YouTube,
the answers, the notice and the video file (its media id, type, size and dimensions); changing any of them changes the
hash.

## Timing

- `post_now`: the upload starts within a minute of approving.
- `schedule` with a public video: by default (`timed_by: "youtube"`) it uploads as soon as it's approved, private, and
  YouTube makes it public at `post_at`, even while Flaghand's queue is paused. `result.youtube.publish_at` is that
  time. With `timed_by: "flaghand"` it uploads at `post_at` instead.
- Unlisted and private videos always upload at `post_at`.

A job is `posting` while it uploads, then `processing` while YouTube processes the video, and `posted` once YouTube has
it as approved (for a held video, once it's public at its time). `result.urls` has its link (youtube.com/shorts/… for a
Short, youtube.com/watch?v=… otherwise).

## After the upload

Flaghand reads the video back from YouTube and compares it with what was approved, field by field:
`result.youtube.check.fields` says `matches`, `processing` (YouTube hasn't set it yet) or `differs`, with the reasons in
`check.issues`. YouTube failing or rejecting the video fails the job with YouTube's reason in our words, and reading stops.
A video deleted on YouTube before then fails it too; one deleted later loses its ids at the next weekly read (see Connecting).

While YouTube works on it, the video is read again 1, 2, 5 and 15 minutes after each read, then every hour, with no stop.
A day on, `result.youtube.still_processing` is `true` and the desk says YouTube is still processing, with Open on YouTube.

Uploads are resumable. If one stops partway, the next try asks YouTube how much it has and sends only the rest. Nothing
is ever uploaded twice: YouTube makes the video only when its last byte arrives, and the job records each step before
taking it. If the last answer never came and YouTube no longer knows the upload, the video may or may not be on the
channel. The job fails as unconfirmed (`result.youtube.step` is `unconfirmed`) and isn't sent again. Approving the same
content again is refused, so check the channel in YouTube Studio. To upload it anyway, change the draft and approve it
again.

YouTube allows each app so many uploads a day. When the day's quota is used up, the job waits and uploads at 12:00 AM
Pacific time, when it resets; `error` says so.

Canceling a job before its upload starts cancels it. Once YouTube holds a video for later, canceling takes it off
YouTube's schedule: it stays on the channel, private, with no time. A video that's public already is changed or deleted in
YouTube Studio. While a try that sent the video got no answer, canceling is refused (409 `job_state`) until the next try
has asked YouTube what it has. Disconnecting the channel then fails the job as unconfirmed, not canceled.

A Flaghand-timed upload that hasn't started by 2 hours after its time fails instead of going out late; approve it again.
An upload that has started continues from where YouTube says it is.

If YouTube changes a video's visibility after upload, the read-back shows it as `differs`; YouTube Studio says why,
and the owner can change it there. YouTube can show a video's tags shortly after the video itself: until it does, `fields.tags`
is `processing` and the job stays `processing`.

## Replies

A `youtube_reply` draft answers a comment as the connected channel. `reply_to` is the comment's link (in YouTube, open
the comment's time, like "2 days ago", and copy the address: it ends in `&lc=` and the comment's id) or its id; `text` is
the reply, up to 10,000 characters, sent as typed.

```json
{ "kind": "youtube_reply", "connection_id": "con_…", "body": { "reply_to": "https://www.youtube.com/watch?v=…&lc=…", "text": "Thanks for watching!" } }
```

Check reads the comment and its video from YouTube each time and returns them as `youtube_reply_to`: the comment in
full, the commenter's channel, whether YouTube holds it for review, the video's title and channel, and whether the
channel can reply there. The approval sheet shows them with the posting channel; Flaghand keeps none of it. YouTube
has one level of replies, so a reply to a reply goes in the same thread.

Approved to post now, the reply goes out at once (the button says "Post now"); scheduled, at `post_at`. It's
read back from YouTube: `result.youtube.check.fields` has `text`, `thread` and `channel`, or `text` is `missing` when
YouTube doesn't show it (the video's channel may hold replies for review). `result.urls` has the reply's link. If a
reply's request got no answer, the next try looks for it in the thread; one it can't find fails as unconfirmed and isn't
sent again. A posted reply is edited or deleted on YouTube.

## Comments

`GET …/connections/{id}/youtube/comments` (CLI `connections list-youtube-comments`, MCP `listYoutubeComments`) reads the
comments on the channel's own videos and Shorts from YouTube, newest first:

- `status`: `published` (the default) is what shows. `held` is what YouTube holds for the channel's review, and
  `likely_spam` is YouTube's spam guess, held the same way.
- `video` narrows it to one of the channel's videos, by its id or link.
- Each comment has its full text, the commenter's channel, its status, its link, the video's title, thumbnail and public
  view count, `can_reply`, and the replies YouTube sent with it. `…/youtube/comments/{thread_id}/replies`
  (`listYoutubeCommentReplies`) reads them all.
- One page per call, up to 100 comments; send `next` back to continue. A page costs 1 unit of YouTube's daily quota, and
  1 more for its videos' titles.

The words are other people's and are read live each time: Flaghand keeps none of them. It keeps only how many wait:
`comments_waiting` on the connection has the counts, read once a day with the login and on each read of the held list, so
they can be up to a day old. `more` means one list has over 100. Nothing checks for comments more often than that, because
YouTube has no push feed for them. The desk's Comments page reads the lists when it's opened.

Answer one with a `youtube_reply` draft (see Replies) whose `reply_to` is the comment's `id`.

`POST …/youtube/comments/{comment_id}/reply-suggestions` (CLI `connections suggest-youtube-replies`, MCP
`suggestYoutubeReplies`; the desk's Suggest replies) asks Claude for three different replies to one comment. It reads
the comment again from YouTube (2 quota units, 3 for a reply) and writes in the workspace's voice note. It spends
credits for what the model used, usually 1 to 3, and answers `out_of_credits` when the balance can't cover it. It drafts
nothing and keeps nothing: save the one you pick, edited or not, as a `youtube_reply` draft, which posts only after an
owner approves it.

## Playlists

`GET /workspaces/{workspace}/connections/{id}/youtube/playlists` (`listYoutubePlaylists`) lists the channel's playlists, read
from YouTube each time and kept nowhere. It costs 1 unit a page.

A `youtube_playlist` draft makes or changes one and adds videos, on one approval. `POST /drafts` with
`kind: "youtube_playlist"` and `body: { playlist, title, description, visibility, videos }`:

- Leave `playlist` out to make a new one. It needs a `title`, and it's private unless `visibility` says otherwise.
- Set `playlist` to a link or id to change one. Fields left out keep what YouTube has.
- `videos` are links or ids, added at the end. One already in the playlist isn't added again.

`check` returns `youtube_playlist`: the playlist as YouTube has it now and each video, which the approval sheet shows.
After the owner approves, the job reads it back: `result.youtube.check` says whether YouTube shows what was approved, and
`url` links to the playlist. Each write costs 50 units of YouTube's daily quota, and a change that runs into the quota goes on
at 12:00 AM Pacific. A request with no answer looks first. If the playlist or video isn't there, the job fails as
`youtube_unconfirmed` and doesn't send it again. Deleting a playlist or removing a video is done on YouTube.

## By hand on YouTube

YouTube's API has no way to make a Community post or a poll, pin or heart a comment, pin a post, add an end screen or
cards, or set a Short's related video. **These are human steps.** Flaghand never does them: there's no site
automation, no undocumented endpoint, and nothing touches YouTube's website, Studio or apps by script. It gives the person
the exact page and the words, and the person does the rest there.

**A Community post.** A `youtube_post` draft holds the text and an optional poll. `by_hand` is always `"all"`:

```json
{ "kind": "youtube_post", "connection_id": "con_…", "body": { "by_hand": "all", "text": "Which one should I take apart first?", "poll": { "options": ["Merges", "Search", "Encryption"] } } }
```

- `check` allows 5,000 characters and 2 to 5 poll options. Its mode is `approve`: asking to post now or schedule is a
  problem, because apps can't post to the Community tab.
- The owner's passkey approval records the decision and makes no job, so nothing can post it.
- The approved draft gains `handoff_url` (the channel's Posts tab) and `hand_off`: `url`, the approved `text`,
  `poll_options` and a `for_person` sentence. Give these to the person. Agents never open it for them.
- In the desk, **Post on YouTube** copies the approved text and opens the Posts tab. Each poll option has its own Copy.
- Once it's live, `POST /workspaces/{workspace}/drafts/{id}/posted-by-hand` with `{ "url": "https://www.youtube.com/post/…" }`
  (`recordPostedByHand`) marks it posted. The link is the only proof: Flaghand can't read Community posts back.

**After a job.** A posted video or Short, or a post marked as posted, lists what's left in the job's `hand_offs`: each
has an `action` (`pin_comment`, `heart_comments`, `end_screen`, `related_video` or `pin_post`), a `url` and a `for_person`
sentence. A Short has no `end_screen`. Pinning a post works only if YouTube offers it for that channel. The desk shows these
as **Finish on YouTube**.

**A comment.** Each comment's `url` from `listYoutubeComments` opens it first on its video, where the person can pin or heart
it. The Comments page has **Pin on YouTube** and **Heart on YouTube** on each published comment.

**Mark done** on these is the person's own note, kept in that browser only. Flaghand and agents can't see it, and
YouTube's API can't confirm a pin or a heart.

## Moderating comments

A `youtube_moderation` draft lists comments and what happens to each: `publish`, `hold` or `reject`, with `ban` on a reject
to also hide the commenter, or `delete`. One approval covers the whole list.

```json
{ "kind": "youtube_moderation", "connection_id": "con_…", "body": { "actions": [
  { "comment_id": "UgxAAAAAAAAAAAAAAAAAAAABAg", "action": "publish" },
  { "comment_id": "UgxBBBBBBBBBBBBBBBBBBBBBAg", "action": "reject", "ban": true }
] } }
```

- Check reads every comment from YouTube and returns it in `youtube_moderation`, with its act, for the approval sheet. The
  snapshot signs only each comment's id and act: no words are kept.
- The sheet shows every comment, its video and its act, and hiding a commenter as its own tick the owner can change. The
  owner approves them all with one passkey. Agents draft, check and request; they never approve.
- Hiding a commenter goes only with a reject: YouTube hides their comments on the channel from then on. Delete works on the
  channel's own comments; reject anyone else's. YouTube's refusal says so if it's asked otherwise.
- A job groups the list: comments with the same act go together, up to 50 a call, and each delete is its own call. Each call
  costs 50 units of YouTube's daily quota, so moderate 60 comments the same way for 100 units. When the quota is used up it
  waits for 12:00 AM Pacific and goes on from where it stopped.
- `result.youtube.items` has each comment, in the draft's order: `done`, `refused` with YouTube's reason, and `check`.
  YouTube doesn't say a comment's status when asked by id, so the read-back reads the channel's held list: `check` is
  `matches`, `differs`, or null when the list doesn't reach it. A refused comment doesn't stop the others; the job ends
  `failed` and says how many went through.

## Analytics

`GET …/connections/{id}/youtube-analytics` (CLI `reports get-youtube-analytics`, MCP `getYoutubeAnalytics`) reads the
channel's own numbers from YouTube Analytics as the channel, each time it's called:

- `totals`: views, YouTube's estimate of minutes watched, average view duration, likes, comments, shares and
  subscribers gained and lost, over the range;
- `days`: the same for each day;
- `traffic_sources`: where views came from (YouTube's type, like `SHORTS` or `YT_SEARCH`, and its name in words);
- `videos`: the channel's videos by views in the range, up to 200 (the most YouTube's report gives), each with its
  title and its public counts on YouTube now.

`start` and `end` are Pacific days, as YouTube counts them (`YYYY-MM-DD`; by default the last 28 days, ending today).
`video` narrows it to one of the channel's videos, by its id or link. The last two or three days can still be filling
in. A number YouTube doesn't have is `null`, never zero.

These are YouTube's numbers and are shown on their own: Flaghand doesn't add them to, mix them with or rank them
against another network's numbers or another channel's. Nothing of them is stored, so none of it is in exports, preview
links, calendar feeds, webhooks, chat alerts, push or email.

It needs **View YouTube Analytics reports**. Connected without it, the answer is `403 permission_not_granted` with a
`fixes` entry to connect the channel again; posting works either way. When YouTube's daily analytics quota is used up,
the answer is `502 upstream_failed` with `retry_at` (12:00 AM Pacific). A login that stopped working is
`409 connection_login_expired`.

## Quota

YouTube allows Flaghand 10,000 units and, in a bucket of their own, 100 uploads a day, for the whole app and shared by
every customer. Both reset at 12:00 AM Pacific. A reply, a moderation call, a playlist change or an edit costs 50 units; a
page of comments or a video read back costs 1. YouTube Analytics has a quota of its own and isn't counted here.

Insights shows each channel's use today, and `GET …/connections/{id}/youtube-quota` (CLI `reports get-youtube-quota`, MCP
`getYoutubeQuota`) answers the same, without calling YouTube:

- `units` and `uploads`: what this channel's calls cost since the reset. Flaghand counts them as they go out, at
  Google's published costs, because YouTube reports no number per channel.
- `units_used_up_at` and `uploads_used_up_at`: when YouTube answered that the day's quota was used up, or null. The work
  waits for `resets_at` and goes on by itself. Because the quota is shared, this can happen to a channel that used little.
- `daily`: the app's daily units and uploads.

These are counts only, kept one row per channel and started over each day. Flaghand doesn't limit a channel by them.

## Disconnecting

`DELETE …/connections/{id}` (owner) deletes the stored login and the channel's details at once and revokes Flaghand's
access at Google. Google keeps one access for a channel however many workspaces connected it, so while another
connection still posts as the same channel, the answer's `revoked` is `false` and `revoke_error` says so; the last one to
disconnect revokes it. Disconnecting is also how to delete Flaghand's YouTube data: the ids of what the channel posted go
from its jobs at once (a video still being read back drops its own at that read), and the jobs stay in Flaghand's own
words. It changes nothing on YouTube, where the videos and replies stay. Deleting the account disconnects every channel too. A disconnected connection can't be connected
again; connecting the channel makes a new connection.
