# TikTok

Flaghand posts photo slideshows and videos to a TikTok account through TikTok's Login Kit and Content Posting API.
An agent drafts, checks and asks; the account owner approves on TikTok's posting screen with their passkey, and the post
goes out then, or at its time (`mode: "schedule"` with the draft's `post_at`, the default when the draft has one). Just before a scheduled post
goes out, Flaghand reads TikTok's settings for the account again: if the owner's choices no longer fit them (who can
view it, comments, Duet, Stitch, the video length limit, or the account can't post right now), nothing is sent and the
job is `held` with `error_code: "tiktok_settings_changed"`, and the owner is told. They retry it once TikTok's settings
fit again, or approve it again.

## Routes

| Route | Who | Does |
|---|---|---|
| `POST /workspaces/{workspace}/connections/tiktok/start` | `connections` scope | Starts connecting. The answer's `url` is the sign-in link, which leads to TikTok's sign-in and consent page, for the account's owner to open (once, within 15 minutes). |
| `POST /workspaces/{workspace}/connections/authorize` | owner | The same, started by the owner (`{ "platform": "tiktok" }`). |
| `GET /workspaces/{workspace}/connections` | `read` | The connection, with TikTok's creator info (`creator`) and the app's `audit_posture`. Never the tokens. |
| `POST /workspaces/{workspace}/connections/{id}/creator-info` | `read` | Asks TikTok for the account's current posting options. |
| `POST /workspaces/{workspace}/connections/{id}/verify` | `read` | Checks the login still works and is the pinned account. |
| `DELETE /workspaces/{workspace}/connections/{id}` | owner | Disconnects: revokes Flaghand's access at TikTok and deletes the stored tokens. |
| `POST /workspaces/{workspace}/drafts` | `drafts` | Creates a `tiktok_photo` or `tiktok_video` draft. |
| `POST /workspaces/{workspace}/slideshows`, `POST …/slideshows/{id}/render` | `drafts` | A photo post whose slides Flaghand draws from JSON, and rendering them. See [slideshows](https://app.flaghand.com/docs/slideshows). |
| `POST /workspaces/{workspace}/drafts/{id}/check` | `read` | Everything that would stop it posting, and `owner_choices`: what the owner picks on the posting screen. |
| `GET /workspaces/{workspace}/media/{id}/content?rendition=tiktok` | `read` | A photo exactly as it goes to TikTok. |
| `POST /workspaces/{workspace}/approval-requests` | `request-approval` | Asks the owner to approve it. |
| `POST /workspaces/{workspace}/approvals/options`, `POST …/approvals` | owner | Approving, with the owner's posting-screen choices and their passkey. |
| `GET /workspaces/{workspace}/jobs/{id}` | `read` | The attempt: its TikTok publish id and status. |
| `POST /workspaces/{workspace}/jobs/{id}/status` | `read` | Reads the status from TikTok again. Never sends the post again. |
| `POST /workspaces/{workspace}/jobs/{id}/metrics` | `drafts` | Records how a post performed (views, likes, comments, shares, saves, watch time, completion). |
| `GET /workspaces/{workspace}/jobs/{id}/metrics` | `read` | Every record, newest first, with rates as a percent of views. |

## Connecting

An agent with the `connections` scope calls `POST …/connections/tiktok/start` and gives the owner the `url`. TikTok's
consent screen is TikTok's own: the owner signs in there (their TikTok password is never typed into Flaghand) and
approves `user.info.basic`, `video.publish` and `video.upload`. The connection is pinned to that TikTok account:
reconnecting it as another account is refused. Logins renew on their own: TikTok's access token lasts 2 hours and its
refresh token 180 days, and each renewal brings a new refresh token, which Flaghand keeps. A login not renewed in 180
days, or one the owner revoked in TikTok, shows `expired`: connect it again.

## Drafts

```json
{
  "kind": "tiktok_photo",
  "connection_id": "con_…",
  "body": {
    "title": "Three ways to plan a week",
    "description": "Save this for Sunday.",
    "media": [{ "media_id": "med_…" }, { "media_id": "med_…" }, { "media_id": "med_…" }],
    "cover_index": 0,
    "post_mode": "direct"
  }
}
```

- `tiktok_photo`: 1 to 35 photos (JPEG, PNG or WebP) in `media`, or a `slideshow` of 1 to 35 slides that Flaghand
  renders to photos ([slideshows](https://app.flaghand.com/docs/slideshows)), a `title` of up to 90 characters and a `description` of up to 4,000.
  Each photo is converted to TikTok's specs: lossless WebP inside 1080x1920 (1920x1080 for landscape), turned upright,
  metadata removed, with every pixel checked. Check shows each copy's SHA-256, and
  `GET …/media/{id}/content?rendition=tiktok` returns it. `auto_add_music` lets TikTok add music (default on).
- `ai_generated` on either kind says it was made with AI (default off). Posting directly, TikTok labels it "Creator
  labeled as AI-generated", and the owner sees it on the posting screen as "Made with AI". An inbox draft can't carry
  the label: check fails with a fix that clears it, and the owner turns it on in TikTok.
- `tiktok_video`: one MP4 or MOV of up to 64 MB (it's sent to TikTok in one piece) and a `title` of up to 2,200
  characters. Check reads the video's length and compares it with what TikTok allows the account. `cover_ms` picks
  the frame TikTok uses as the cover, in milliseconds from the start (default: the first frame). TikTok takes it only
  when posting directly; check refuses a cover past the end of the video, or on an inbox draft.
- `post_mode`: `"direct"` publishes to the account. `"inbox"` sends it to the account's TikTok inbox as a draft, which
  the creator finishes and publishes in the TikTok app.
- To ask for a decision only, request approval with `"mode": "approve"`. The owner sees the video or photos and approves
  or sends it back; there is no posting screen, no job, and nothing reaches TikTok.

## The posting screen and the approval

TikTok requires the creator to make some choices for every post, with nothing chosen for them. On Flaghand these are
the owner's, made when approving, and sent as `tiktok` with each approval item:

```json
{ "draft_id": "drf_…", "rev": 3, "mode": "post_now",
  "tiktok": { "privacy_level": "SELF_ONLY", "allow_comments": false, "disclose": null } }
```

- `privacy_level`: who can view it, one of TikTok's options for the account right now (check lists them in
  `owner_choices.privacy_level_options`). It has no default.
- `allow_comments`, and for videos `allow_duet` and `allow_stitch`: off unless turned on, and refused when the account's
  TikTok settings turn them off. Photo posts have no Duet or Stitch.
- `disclose`: commercial content. `your_brand` is labelled "Promotional content", `branded_content` "Paid partnership",
  and branded content can't be Only me: with Only me chosen Branded content is greyed, and with Branded content ticked
  Only me is.
- The consent statement ("By posting, you agree to TikTok's Music Usage Confirmation", plus the Branded Content Policy
  when branded content is on) is part of the snapshot. Approving with the passkey is the consent.

The snapshot an approval signs holds all of it: the account, TikTok's creator info as just fetched, the choices, the
consent statement, each photo's SHA-256 and the exact request. Changing any of them changes the hash, and the approval
no longer covers it. Right before posting, Flaghand asks TikTok for the creator info again; if anything changed
since the approval, nothing is sent and the owner approves again.

Photo conversions are versioned. A new converter makes a new copy for fresh checks and approvals; an existing job keeps
its approved copy and SHA-256, even after an upgrade. It never substitutes a newly converted photo. If the approved
copy is missing or changed, nothing is sent. An approval prepared before an upgrade that changes the photo must be
prepared again before the passkey tap.

Drafts sent to the inbox take no choices here: the creator makes them in TikTok.

## Until TikTok audits the app

`audit_posture` says where the TikTok app stands, and it fails closed:

- `unaudited` (now): posting directly works only for a private TikTok account, with `privacy_level` `SELF_ONLY` (Only
  me). Check offers sending it to the inbox instead.
- `unknown`: no posting directly.
- `audited`: any option TikTok gives for the account.

Sending drafts to the inbox works in every case.

## After approving

The job asks TikTok once. TikTok downloads each photo from an unlisted address only TikTok is given; videos are
uploaded straight to TikTok. The job is then `processing`, and Flaghand follows TikTok's status until TikTok says:

- `PUBLISH_COMPLETE`: the job is `posted`, with the post's id and link (`result.ids`, `result.urls`).
- `SEND_TO_USER_INBOX`: the job is `inbox`. It's in the creator's TikTok inbox, not published yet.
- `FAILED`: the job is `failed`, with TikTok's reason.

The photo addresses come down as soon as TikTok reports one of those. A post can take a few minutes to appear on
TikTok. `POST …/jobs/{id}/status` checks again at any time; it only reads.

Nothing is ever sent to TikTok twice:

- The job records that it's asking before it asks, and keeps TikTok's publish id the moment TikTok gives it.
- If TikTok's answer can't be read (a timeout, an error on TikTok's side, no publish id), the post may or may not be on
  TikTok. The job fails as unconfirmed (`result.tiktok.unconfirmed`) and isn't sent again; approving the same content
  again is refused. Check the account on TikTok and its inbox. To send it anyway, change the draft and approve it again.
- If TikTok refuses it outright, nothing was created at TikTok, and approving it again sends a new attempt.

## Metrics

TikTok's posting permissions don't include analytics, so a person or an agent records them:
`POST …/jobs/{id}/metrics` with any of `views`, `likes`, `comments`, `shares`, `saves`, `average_watch_time_sec`,
`completion_rate`, `is_winning_post` and `notes`. `GET …/jobs/{id}/metrics` returns every record and the rates of the
latest one. For a posted X job it also has `x`: X's own numbers from the account's stored hourly reads (`GET
…/job-results` lists them for every posted X job; [agents](https://app.flaghand.com/docs/agents.md) "How the posts did").

## Disconnecting

`DELETE …/connections/{id}` (owner) revokes Flaghand's access at TikTok and deletes the stored tokens at once. It's
refused while a post still needs the login. Posts already on TikTok stay there. Its photo addresses come down at once.
TikTok may still publish a post it already took: unless the same account reconnects first, that job ends `failed` at
its next status check, and the same content can't be approved again. The owner can also remove the app in TikTok's
settings.
