# 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. There's no scheduling on TikTok: TikTok checks the account's settings at the moment it posts, and so does
Flaghand.

## Routes

| Route | Who | Does |
|---|---|---|
| `POST /workspaces/{workspace}/connections/tiktok/start` | `connections` scope | Starts connecting. The answer's `url` is 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](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. Access tokens renew on their own for a year.

## 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](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).
- `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.
- `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.

## 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.
- 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](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. The owner can also remove the app in
TikTok's settings.
