# Drafts and media

## Media

Upload a photo, video or PDF document as the raw request body with its `Content-Type`:

sh / Bash (workspace id in `WS`, token from secret storage):
```sh
printf 'Authorization: Bearer %s\n' "$FLAGHAND_TOKEN" | curl --header '@-' -X POST "https://app.flaghand.com/api/v1/workspaces/$WS/media?alt=A%20chart%20of%20weekly%20views&filename=chart.png&width=800&height=600" -H "Content-Type: image/png" --data-binary '@chart.png'
```
PowerShell (`$env:WS`, `$env:FLAGHAND_TOKEN`):
```powershell
"Authorization: Bearer $env:FLAGHAND_TOKEN" | curl.exe --header '@-' -X POST "https://app.flaghand.com/api/v1/workspaces/$env:WS/media?alt=A%20chart%20of%20weekly%20views&filename=chart.png&width=800&height=600" -H "Content-Type: image/png" --data-binary '@chart.png'
```

Accepted: JPEG, PNG, WebP and GIF photos; MP4 and MOV videos; PDF documents (`application/pdf`), up to 50 MB. The file's first bytes must say it's the
type you sent it as, or it's refused (`unsupported_media_type`). Its width, height (pixels) and, for a video, duration
(seconds) are read from the file and saved, so the previews and the approval sheet lay it out exactly as it is. A
`width`, `height` or `duration` in the query (digits, no `px`; `0` is refused) is used only where the file doesn't give
it, such as an animated WebP or an unusual video. `PATCH …/media/{id}` with any of them reads the file again.
The answer is a media item (`med_…`) with its type, kind (`image`, `video` or `document`), size and default alt text.
PDFs are parsed to verify they are readable and unencrypted, with 1 to 300 pages; their `pages` and `sha256` come from
the file, not editable metadata. `GET …/media/{id}/content` returns the file.

During the no-card free week (`starting`), uploads and stored copies share 250 MB per workspace (250 × 1024 × 1024
bytes). Read `getBilling.media_storage` for `used_bytes` and `limit_bytes`; null means no storage limit. A new
upload, explicit slide render or PDF export that would exceed it is refused with HTTP 402 `media_storage_full`,
not the per-file HTTP 413. Its single line explains the usage and billing step; relay it unchanged. The error also
includes `used_bytes`, `limit_bytes` and `next_step`. A known Content-Length is checked before storing, and the real
streamed size is checked too; refused uploaded bytes are removed. An accepted card trial or paid plan lifts the limit.
Necessary conversions of media already stored are never refused. See [what counts](https://app.flaghand.com/docs/billing.md#the-free-start-and-first-approval).

### Delete a file

`DELETE /workspaces/{workspace}/media/{id}` removes one file you uploaded, its platform renditions and its media row. It needs
`media` scope, works in every billing state, and returns `{"ok":true}`. The CLI is `flaghand media delete med_… --json`;
MCP is `deleteMedia`, or discover it with `getOperation` and run it with `callOperation`. The desk's media library
also has a Delete file action.

A file still used by a draft (including its Reset original or a slideshow's slides), a pending job or an active
platform download is refused with `conflict`. The error names the referencing items and includes their ids (draft
titles need `read` scope). Files generated from a slideshow (its slide images and PDF) are refused with `conflict` too:
several drafts can share one, so they stay until the workspace is deleted. Detach the file from those
items, cancel pending jobs and let active downloads finish
before deleting; archiving a draft does not detach its media. A posted, locked draft still retains its file and blocks
deletion. Posted jobs and their approval snapshots are never
rewritten. JSON and ZIP exports keep those records and list the deleted file with `deleted_at` and `file: null`,
without including its bytes. Deleting here does not remove a post or a file already sent to a platform.

A completed second delete returns `not_found`. If storage deletion is interrupted, retry the same id to finish removing
its bytes; the file cannot be attached again while cleanup is pending. Files otherwise stay until explicitly deleted or
the whole workspace is deleted. The daily backup's existing 28-day restore window is unchanged.

## Media from a web page or file URL

To post a page's photo, or a file that's already online, import it instead of downloading and uploading it. First
list what can be imported; nothing is saved:

```sh
flaghand media import --url https://example.com/blog/launch --json
```

The API is `POST /api/v1/workspaces/{workspace}/media/import` with `{"url":"https://example.com/blog/launch"}`; MCP
is `importMedia`. It needs the `media` scope. The answer is `{source, candidates, media, warnings}`. Each candidate has
an `index` (from 1), `kind`, `type`, `url`, `preview_url`, `width`, `height`, `size` (bytes, null when the server
doesn't say) and `alt`. A page's candidates are its Open Graph images, then its Twitter images, then the photos in its
`<article>` or `<main>` (the whole body's when it has neither). Only JPEG, PNG, WebP and GIF photos at least 200 px on
each side are listed, so icons, spacers and tracking pixels are left out; the same image is listed once, with the
largest size a `srcset` offers. A URL that is itself a photo, MP4, MOV or PDF is its one candidate, whatever its size.

Look at the candidates (`preview_url`) and send the ones to save as `pick`, in the order you want them:

```json
{"url":"https://example.com/blog/launch","pick":[2,1]}
```

Each becomes an ordinary upload (`med_…`), with the same checks, 50 MB limit and storage as one, the page's alt text
for that image, and `source_url` set to the URL you imported from. If one can't be saved, none are. A page read again
may list its images differently, so pick from the answer you just read, or import a candidate's own `url`.

To attach it while drafting, put `media_from` on the body (or on a thread's post) instead of `media`. It takes the
URL, or `{"url":…,"pick":[…]}`; without `pick`, a page gives its first candidate, a file itself and an X post all
its media.
`POST …/drafts`, `PATCH …/drafts/{id}` and `POST …/posts` import it in the same call and attach it after any `media`
already there, alt text included (an X post's alt text stays on the media item, which posting reads):

```json
{"kind":"linkedin_post","body":{"text":"We shipped it.","media_from":{"url":"https://example.com/blog/launch","pick":[2]}}}
```

The saved draft holds the media ids, never `media_from` or the URL. Imports for a draft that is then refused are
deleted again. `media_from` is not taken with `options`: import first, then use the ids.

Fetching follows link cards' rules: public HTTP(S) only, no credentials, cookies or scripts, and every redirect and
image URL checked again. X, LinkedIn, YouTube, TikTok, Instagram, Facebook and Threads pages and files are never
fetched as pages; their images on a page are left out with a warning.

Use only images you have the rights to post: your own, your company's, or ones the owner lets you use. A page's images belong to whoever made them, and a news article's photos usually belong to the outlet or a photo agency even when the story is about you. To show a page without the rights to its image, use a link card, which links back to it.

### Media from your own X posts

A post's URL (`https://x.com/<handle>/status/<id>`) by one of the X accounts connected to this workspace is read
through X's API with that account's own login. Its candidates are the post's own photos, videos (the best MP4) and
GIFs, each with its alt text, and `source.kind` is `x_post`; `media_from` without `pick` takes all of them:

```json
{"kind":"x_post","connection_id":"con_X","body":{"text":"In case you missed it.","media_from":"https://x.com/yourhandle/status/1850000000000000001"}}
```

Only your connected accounts' own posts: another account's post and a repost are refused, and a quote gives only its
own media, never the quoted post's. To show someone else's post, link or quote it. Each import is one post read: while
credits are on it spends `x_media_import` credits (1 for the post; a post already read today is free). Media saved
from an X post is deleted, even from the drafts that use it, when X says the post was deleted, protected, suspended or
withheld, or within 24 hours of a removal request. Then a scheduled post that uses it fails as not posted, an
approval not yet published is voided (its alt text reads as removed), Check names the removed id in drafts, and a
keyed answer that named it replays as a `409 conflict`. A keyed import of an X post replays its media but not its
candidates. Media your post reused from someone else's post is still theirs.

## LinkedIn link cards from a URL

LinkedIn's posting API does not fetch a preview. Resolve it once before creating or editing the draft:

```sh
flaghand link-cards create --page-url https://example.com/article --json
```

The API is `POST /api/v1/workspaces/{workspace}/link-cards` with `{"page_url":"https://example.com/article"}`;
MCP is `createLinkCard` (or discover it with `getOperation`). It needs the `media` scope. `page_url` is the page to
fetch, not the CLI's `--url` API origin. The answer is `{card, media?, candidates, warnings}`. Copy `card` unchanged
into a `linkedin_post` body's `card`, instead of photo/video `media`:

```json
{"text":"Read our article.","card":{"url":"https://example.com/article","title":"An article","description":"What it covers.","image_media_id":"med_…"}}
```

The server reads Open Graph tags first, then Twitter tags, then the normal page title and description. Entities are
decoded; relative images use the final redirected page and its base URL. Without tags, the title falls back to the URL
and the card has no image. A thumbnail becomes a normal workspace-owned media item, with the same file validation and
retention as an upload; it is never a remote image URL in the draft. A failed page fetch returns `upstream_failed`; a
non-HTML page returns `unsupported_media_type`.

The card's image is the page's Open Graph image. `candidates` lists the page's photos as
[importing media](https://app.flaghand.com/docs/drafts-and-media#media-from-a-web-page-or-file-url) lists them. Look at the image you got: when it's a plain
background, a logo or a stock banner rather than something worth showing, call again with `image_url` set to a
better candidate's `url` (or any public JPEG, PNG, WebP or GIF), or set `card.image_media_id` to a file you uploaded.
An unavailable or invalid Open Graph image returns a text-only card and a warning; an `image_url` that can't be saved
is an error.

Only public HTTP(S) pages can be fetched: embedded credentials, private/local addresses and other URL schemes are
refused with `invalid_request`, and so are X, LinkedIn, YouTube, TikTok, Instagram, Facebook and Threads pages and
files, which are read only through those platforms' own APIs. For one of those, supply the card by hand. Redirects and
image URLs follow the same rules. No scripts run and no session cookies or API tokens are sent to publishers.

Pasting an HTTP(S) link into an empty-card LinkedIn composer calls this same route. The result is saved with the
draft and shown in its preview; removing that pasted link removes its automatic card. Existing supplied cards are
kept. Attached photos/videos take precedence. The approval snapshot freezes the saved card and its stored image,
and neither approval nor posting fetches page metadata again. This call never creates a draft, requests approval,
schedules or posts anything on its own.

## Automatic UTM link tags

Link tracking is **off by default**, for existing and new workspaces. Enable it in **Settings → Account → Posting**, or
with `PATCH /workspaces/{workspace}/link-tracking` and `{"enabled":true}` (`drafts` scope or an owner session).
`GET …/link-tracking` reads the setting with `read` scope. In the CLI:

```sh
flaghand workspaces get-link-tracking --json
flaghand workspaces update-link-tracking --enabled true --json
```

MCP exposes `getLinkTracking` and `updateLinkTracking`; discover them with `getOperation` and call them through the
read/write dispatchers, or use their operation names directly. Set `enabled` to `false` to turn it off.

When checking or preparing approval, Flaghand adds `utm_source=x` or `utm_source=linkedin`, `utm_medium=social`
and `utm_campaign` to each text link (every part of an X thread) and the LinkedIn card's URL. The campaign is the
**first label** in the draft's `labels` array, or the draft id if it has none. That label becomes public in the URL
(URL-encoded), so use a campaign name safe to share. Any URL that
already has a query parameter beginning `utm_` stays **exactly as written**, even if it has only one UTM tag.
Other query parameters and fragments are kept. Ambiguous text-link boundaries stay as authored: tagging never
merges adjacent links or absorbs surrounding text. LinkedIn `@[Name]` mentions, reply targets, quote targets, media
and TikTok captions are not tagged.

The check's `snapshot.content` and `snapshot.payload` contain the actual URLs to review and send; the original draft
body and its revision are not rewritten. The owner reviews the tagged links before signing the exact approval
snapshot. X still counts each recognized link as 23 characters; LinkedIn checks the tagged text against its own limit.
A setting or campaign change during the passkey step requires a fresh snapshot. Previously approved or scheduled
payloads do not change when this switch or a label changes. When continuing a partly live X thread, its live parts
keep their original exact URLs; new parts use the current tracking setting and campaign. Editing a live part still
requires undoing that edit or the owner's explicit whole-thread repost choice.

## Drafts

`POST /workspaces/{workspace}/drafts` with a `kind`, a `body` shaped by that kind, and usually a `connection_id` (the
account it posts as, from `GET …/connections`) and a `post_at`:

```json
{
  "kind": "x_thread",
  "connection_id": "con_…",
  "post_at": "2026-11-02T16:30:00.000Z",
  "body": {
    "posts": [
      { "text": "We shipped weekly charts today.", "media": [{ "media_id": "med_…", "alt": "Weekly views, up 40%" }] },
      { "text": "How it works: …" }
    ]
  }
}
```

For an X reply suggestion, use `kind: "x_post"`, `body.text`, the exact chosen `body.reply_to_url`, and
`suggestion: {"kind":"reply"}`. Check automatically assigns `body.by_hand: "all"` if it cannot prove API eligibility,
including with target reads off, and returns its current `rev`. Re-read that draft and use mode `approve` for by-hand
replies. The reason is `suggestion.reply_by_hand_reason`, restated by each Check with drafts permission (null once
nothing forces it by hand);
text and target are not changed. Target display data,
when enabled, is `reply_target` outside the signed snapshot, read again first when it's past its 24-hour window;
`reply_target_unavailable: true` means the read failed or X no longer has the post (no longer available on X). After exact owner passkey approval, GET and LIST return
`reply_on_x_url` for the person to open, one reply at a time in a separate tab/window/X app. The desk's session-only
`opened-on-x` action records `suggestion.opened_on_x`, a tap, not proof of posting. Record the live reply separately
with `posted-by-hand`. No token approves or opens the composer.

Kinds and their bodies (the spec has every field):

- `x_post`: `text`, up to 4 `media`, optional `poll`, `reply_settings`, `quote_url`, `reply_to_url`, `community`,
  `share_with_followers`, a [`plug`](https://app.flaghand.com/docs/drafts-and-media#x-plugs), and `by_hand: "all"`. A post with `quote_url` needs `by_hand: "all"`:
  since April 20, 2026 X's API quotes posts only on its Enterprise plan, so the owner quotes it in X. A `reply_to_url`
  must be the account's own post or one that mentions or quotes it, since X's API refuses other replies: check offers
  replying by hand or posting it as a new post.
- `x_thread`: 2 to 25 `posts` (each `text` and `media`; only `posts[0]` may have `poll` and `reply_settings`), optional
  `quote_url` and `reply_to_url` for post 1, a [`plug`](https://app.flaghand.com/docs/drafts-and-media#x-plugs), and `by_hand`: `"first"` (the owner posts post 1 in X;
  the rest are posted under it once it's live) or `"all"`. A thread with `quote_url` needs one of them, since X's API
  no longer quotes posts; so does a `reply_to_url` to a post that doesn't mention or quote the account.
- `linkedin_post`: `text` with `@[Exact Page Name]` tags, up to 20 photo `media` **or one MP4 video**,
  a PDF `document: { media_id, title }` instead of media, a link `card` or a `poll` instead of either;
  `tags` (each tag's `name`, `kind` and page `urn`), and `by_hand: "all"`.
- `tiktok_photo`: `title`, `description`, 1 to 35 photo `media` (posted as they are) or a `slideshow` (slides with text,
  rendered by Flaghand: see [slideshows](https://app.flaghand.com/docs/slideshows)), `cover_index`, `auto_add_music` and `post_mode` (`"direct"` or
  `"inbox"`). `tiktok_video`: `title`, one video in `media`, and `post_mode`. See [TikTok](https://app.flaghand.com/docs/tiktok).

Both X kinds accept optional `repost_after_hours` (a positive number, e.g. `24`): repost the post, or a thread's
opening, after that many hours live. It is shown and signed with the original publishing plan; omit it for none.
See [Repost later on X](https://app.flaghand.com/docs/approvals#repost-later-on-x) for cancellation and job outcomes. Not available with `by_hand: "all"`.

### X polls and who can reply

```json
{
  "text": "What should we build next?",
  "poll": { "options": ["Better charts", "More exports"], "duration_minutes": 1440 },
  "reply_settings": "following"
}
```

An X poll has 2 to 4 choices in display order, each 1 to 25 Unicode characters, and lasts 5 to 10,080 whole minutes
(7 days). A poll can't have media or quote another post. A thread may carry a poll only on `posts[0]`.
These are [X's create-post rules](https://docs.x.com/x-api/posts/create-post), not workspace limits.

`reply_settings` is `"following"` (people the account follows), `"mentionedUsers"`, `"verified"`, or `"subscribers"`.
Leave it out for everyone; `"everyone"` isn't an input value. Set it on the single post or on `posts[0]`, never the
thread root or a later reply. With no field, existing drafts keep their ordinary everyone-can-reply behavior.

Both fields are frozen into the approval's content and posting payload. Editing a choice, its order, the duration,
or who can reply changes the snapshot hash and voids an earlier approval. The preview shows an unvoted poll, not
fabricated results. After posting, the live check compares the poll's choices, order and duration plus the opening
post's reply setting. It ignores changing vote counts and voting status, and reports missing poll or reply-setting
data as an incomplete match rather than silently passing it. A hand-first thread also checks these approved fields
on the opening post before it releases any replies, whether that post is found automatically or its link is pasted.
The desk lists the exact poll duration before approval. For hand posting, its instructions list the approved choices,
duration and X reply choice to set in X; X's compose link carries only the text.
API, CLI `--body`, and MCP `body` use this same JSON.

### LinkedIn polls

```json
{
  "text": "Help us pick the next feature.",
  "poll": { "question": "What should we build next?", "options": ["Better charts", "More exports"], "duration": "THREE_DAYS" }
}
```

A LinkedIn poll has a question of 1 to 140 characters, 2 to 4 options of 1 to 30 characters each, in display order,
and a `duration` of `ONE_DAY`, `THREE_DAYS`, `SEVEN_DAYS` or `FOURTEEN_DAYS`. Lengths are counted as LinkedIn's
composer counts them, in UTF-16 units, so an emoji counts as 2. A poll is the post's whole attachment: no photos, video
or link card beside it (links can stay in the text). The text may be empty, as with a photo post. The question and
options are plain text: `@[Page]` tags and hashtags work only in `text`. Voting is single choice, and LinkedIn shows the
author who voted for what. These are the rules of LinkedIn's
[Poll API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/poll-post-api?view=li-lms-2026-10),
not workspace limits.

The poll is frozen into the approval's content and posting payload, and goes to LinkedIn as the Posts API's `poll`
content exactly as approved. Editing the question, an option, the options' order or the duration changes the snapshot
hash and voids an earlier approval. LinkedIn can't change a poll once it's posted. It uses the existing
`w_member_social` permission; no new scope or reconnect is needed. Like every LinkedIn post, a poll isn't read back
after posting: reading a member's posts needs LinkedIn's restricted `r_member_social`, which Flaghand's app doesn't
have, and LinkedIn's pages are never read outside its API.

The desk, the approval sheet and in-chat previews draw the poll as LinkedIn's feed shows it to someone who hasn't
voted, right after posting: the question, the options in order, `0 votes` and the whole duration left (`1d`, `3d`,
`1w` or `2w`), never made-up results. To post it yourself, the instructions list the question, options and duration
to set in LinkedIn.

A media reference without `alt` gets the library's alt text copied in, so a draft alone says what is posted; media
from an X post keeps its alt text on the media item instead.
LinkedIn photos and link-card thumbnails must be JPG, GIF or PNG, as required by its
[Images API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api?view=li-lms-2026-10).
WebP can stay in the shared library for other platforms, but a LinkedIn check asks you to convert and upload it in a
supported format before approval.

### X longer posts

A single `x_post`, or each post in an `x_thread`, can use up to 25,000 **weighted** characters when its connection's authenticated X user reports
`subscription_type` as `Basic`, `Premium` or `PremiumPlus`. Other accounts (including an unknown or missing subscription)
use 280. A blue badge or an editable preview's `premium` field is not proof. The connection API exposes the last
`subscription_type`; sign-in, identity verification and profile refresh read it alongside the profile they already fetch.
Checking a longer post re-reads it, as does the scheduler immediately before sending, so a lapsed subscription fails the
check with a `shorten-text` fix (`max_weighted: 280`). Shorten the text, change the draft to an `x_thread`, or restore the
subscription and check again. A failed subscription lookup never permits a longer post; it asks to reconnect X only
when the login itself no longer works or belongs to another account, not when X is busy or down (check again later).

X's counting is shared by the desk and server: a URL uses 23, CJK characters and emoji sequences use 2, and combining
characters are normalized. Draft text is not cut to make it fit. Thread counters remain at 280 per part, but check applies
the account's confirmed limit to every part and names any part that is too long. Threads are never split or merged
automatically. Short posts and threads whose parts all fit within 280 add no subscription reads; a longer part refreshes
eligibility once per check and again before the scheduler sends the remaining parts.

The timeline preview uses X's existing 280-weighted-character, last-whitespace cut and **Show more**; opening it shows
the complete text. The passkey approval always signs the full text, and the live check requests `note_tweet` and compares
its full text, not the shortened timeline `text`.

Sources: [X API changelog](https://docs.x.com/changelog) (August 9 and 20, 2024: long-form Write, raised from 4k to 25k),
[authenticated user fields](https://docs.x.com/x-api/users/get-my-user), and
[X Premium tiers](https://help.x.com/en/using-x/x-premium) (longer posts are included in Basic too).


Every change is `PATCH …/drafts/{id}` with the `rev` you read. A stale `rev` is refused with `rev_conflict`, so two
writers can't overwrite each other. `body` replaces the complete body as before. For a partial edit, send `body_patch`:
`{"rev":3,"body_patch":{"media_add":[{"media_id":"med_…"}]}}` appends an uploaded image without resending text or
existing attachments. `body_patch.media_from` imports and appends in the same call (`media` scope required).
Omitted fields stay; null removes optional fields; arrays/nested objects replace, except `posts` is one positional
patch per current thread part. [Partial edits and examples](https://app.flaghand.com/docs/media-and-threads#add-media-to-an-existing-draft-in-one-call).
The PATCH answer includes `approval_effect`: whether it invalidated a request or voided approval, why, and the
`next_call` to check and request the intact original plan again. Media changes require the owner to review the changed
snapshot just like text changes. `createPost` also takes `body_patch` on an existing `draft_id`, so the edit, check and
whole-plan request can be one call. A field sent unchanged changes nothing. An X post becomes a thread (or back) by
sending `kind` with the body in its new shape. `meta` holds notes for whoever reviews it (`notes`, `warnings`, a
timing note `when`, the owner's private `note`); it's merged key by key, never posted and never part of an approval.
Changing the body, the kind, the account or the time:

- voids the draft's approval (it's listed with `void_reason: "edited"`) and puts it back to `draft`;
- is refused with `draft_locked` while the draft has a job that's waiting, scheduled, posting or posted. Cancel the job
  first (`POST …/jobs/{id}/cancel`).

Titles, `labels`, `agent_note` and `archived` change without touching the approval. The draft's top-level `title` is only a desk label;
`body.document.title` is posted on LinkedIn, so editing it **does** void the approval.

### Search before drafting

`GET …/drafts?q=weekly%20charts` and `GET …/jobs?state=posted&q=weekly%20charts` find existing work before you write
it again. `q` is a literal, case-insensitive substring, including Unicode letters: every thread part's text, titles,
TikTok captions/descriptions, LinkedIn poll questions, slideshow text, planning labels and the platform name (`x`,
`linkedin` or `tiktok`) are searched, just like the desk. It is not a regular expression, SQL
pattern or full-text query: `%`, `_`, quotes and backslashes are literal. Surrounding whitespace is trimmed; blank
or omitted `q` leaves the list unchanged. A phrase must occur within one field, not across two thread parts.

Search stays inside the workspace, combines with the existing filters, keeps newest-first ordering and runs before
the existing result limit (drafts: 100 by default, `limit` to change it; jobs: 200). Drafts exclude archives unless
`archived=true`, which adds them; searching jobs also excludes jobs whose draft is archived. Jobs search their saved payload as well
as their current draft text, title and labels, so an old attempt remains findable after a canceled draft is edited.
Matching makes no platform request; returning a draft can still refresh a stale reply target through the existing
reply-target feature and its credit checks. A full result page is not proof there are no older matches.

```sh
flaghand drafts list --q "weekly charts" --json
flaghand jobs list --state posted --q "weekly charts" --json
flaghand drafts list --q "launch" --label "Week 1" --json
```

MCP: `listDrafts` with `{"q":"weekly charts"}` and `listJobs` with `{"state":"posted","q":"weekly charts"}`;
these same arguments work through `callReadOperation`. Discover the full schemas with `getOperation`.
The desk's existing **Find a post** field searches its loaded drafts and posted items by the same text matching,
including later thread parts and labels, alongside its status/label filters. Archived matches stay behind the
explicit Archived toggle. The desk loads up to 999 recent drafts; use the API's filters for older work.

### Planning labels

Set top-level `labels` on any draft, including an item in `POST …/posts`, to group a campaign or a week:

```json
{ "kind": "x_post", "labels": ["Launch", "Week 1"], "body": { "text": "We shipped it." } }
```

Labels are plain strings for planning, not hashtags or LinkedIn mentions. With [link tracking](https://app.flaghand.com/docs/drafts-and-media#automatic-utm-link-tags) on
(off by default), the first label becomes public in `utm_campaign` on checked and approved X/LinkedIn links. Otherwise
labels are not posted or included in approval content. New and existing unlabeled drafts return `labels: []`. Duplicates are kept once;
otherwise strings are preserved exactly, including case. `PATCH …/drafts/{id}` with the current `rev` and `labels`
replaces the whole list; `[]` clears it. As with other draft edits, a live job locks the draft.

`GET …/drafts?label=Launch` lists only drafts with that exact label (case-sensitive). It combines with `status` and
`archived`, before `limit`. JSON exports keep the labels and Markdown exports include them in front matter.
The desk shows the labels on its loaded posts as one line of words under its status filters.

The generated CLI and MCP tools use those same fields:

```sh
flaghand drafts create --kind x_post --body '{"text":"We shipped it."}' --labels '["Launch","Week 1"]'
flaghand drafts update drf_… --rev 1 --labels '["Launch"]'
flaghand drafts list --label Launch
```

In MCP, call `createDraft` or `updateDraft` with `labels: ["Launch"]`, and `listDrafts` with `label: "Launch"` (or use
`callWriteOperation` for `createDraft`/`updateDraft`, or `callReadOperation` for `listDrafts`). The batch importer accepts `labels` too; Typefully and Buffer migration maps
their tag names to these labels, without putting them in the post's text.

### The agent's note

Set top-level `agent_note` on a draft, including an item in `POST …/posts`, to tell the owner why this post, where it
comes from and what you're unsure of:

```json
{ "kind": "x_post", "agent_note": "From the v2.3 release notes. Not sure the charts are on mobile yet.", "body": { "text": "We shipped it." } }
```

It's one short plain-text note, shown as one paragraph above the post's preview on the approval page, in the desk and
in the in-chat preview: line breaks and tabs fold into spaces, and characters that reorder or hide text are dropped. A
long note scrolls in its own box. It is never posted and never part of the post's content or the request sent to X, LinkedIn or TikTok. A
check's snapshot carries it as `agent_note`, so the approval's snapshot, and its hash, record the note the owner saw.
Changing it with `PATCH …/drafts/{id}` (the current `rev`, then `agent_note`) doesn't void an approval: that approval
keeps the note it froze (the approval's `agent_note`, which the desk shows while it stands), and the next one freezes
the new note. A blank or null note removes it. JSON exports keep
each draft's note and each approval's snapshot with the note it froze.

```sh
flaghand drafts create --kind x_post --body '{"text":"We shipped it."}' --agent-note "From the v2.3 release notes."
flaghand drafts update drf_… --rev 1 --agent-note "Checked: the charts are on mobile too."
```

In MCP, `createPost`, `createDraft` and `updateDraft` take `agent_note`. The plan's request `note` stays separate: who
posts which part, when, and what the owner does by hand.

## Weekly posting slots

Each connection can have a weekly plan. In Settings, edit its zone and day/time rows, then save. Agents use
`GET` or `PUT …/connections/{id}/slots` (`read` to read, `drafts` to replace):

```json
{
  "zone": "America/New_York",
  "slots": [{ "day": 1, "time": "09:00" }, { "day": 3, "time": "14:30" }]
}
```

`day` runs from Sunday `0` to Saturday `6`. `time` is a local `HH:MM` in that IANA zone, independent of the workspace's
display zone. Before a plan is set the answer is `{"zone":"UTC","slots":[]}`; an empty array clears it. Duplicate
rows are folded together. In a DST week the wall time stays the same, not the UTC offset. A nonexistent spring-forward
time is skipped; a repeated autumn time is used once, at its first occurrence. Optional `if_empty: true` on a PUT writes
only if the plan is still empty, atomically; otherwise it answers `conflict` without overwriting. Importers use this
when filling an empty plan.

`GET …/connections/{id}/slots/next?count=5` previews the next open exact UTC times as
`{"zone":"…","slots":["…"],"next":null,"remaining":0}`.
It defaults to five slots strictly after now plus the existing two-minute scheduling lead time. `after` accepts an
RFC 3339 instant with its offset. Each response contains up to 1000 times. When `next` is non-null, continue with
`after=next` and `count=remaining`; nothing is lost and any requested count is reachable across pages. Node CLI and
stdio MCP collect those pages automatically. Remote MCP returns one page per invocation and explicitly tells the
agent to call again with `after: next` and `count: remaining` until `remaining` is zero, so the hosted Worker never
builds an arbitrarily large result in one invocation. A preview reserves nothing; no plan returns an empty array. Non-archived drafts with
an exact `post_at` and waiting, scheduled, held, posting, processing or finished-post jobs at that time occupy a slot on
that connection only, including an unpinned draft that resolves to this platform's sole active account. Archive an unlocked draft or change its time to release its proposed slot; a live job still
occupies its own exact time, even when an approval overrode the draft's time.

A **new** draft in `POST …/drafts` or `POST …/posts` can send `"post_at":"next_slot"` with its `connection_id`.
Creation atomically claims the next open slot and returns an ordinary exact `post_at`. Two drafts in one plan, or
concurrent creators, take different times. The connection needs a nonempty plan; nothing is guessed. An import-key
retry returns the original draft and time. A posts retry should use the same `Idempotency-Key`, or send its saved
`draft_id` and `rev` without `next_slot`. Existing drafts change time only with an exact timestamp (or `null`).

This only proposes times. It never approves, schedules, creates a job or posts. A posts call still checks every draft
and asks the owner to approve the exact snapshots. Changing slots later never moves a saved draft or its approval;
missed times are not silently rolled into the next slot. The usual platform and scheduling checks still apply,
including TikTok's post-now-only rule: `next_slot` is refused for TikTok rather than asking to publish a queued draft immediately.

The generated CLI commands are `flaghand connections get-slots`, `set-slots` and `get-next-slots`; use `--id`, `--zone`,
and `--slots` (a JSON array). MCP exposes `getConnectionSlots`, `setConnectionSlots` and `getNextConnectionSlots` from
the same spec. Buffer imports can carry an explicit weekly plan; see [migrating drafts](https://app.flaghand.com/docs/agents#migrate-from-another-scheduler).

## X Communities

X [announced Communities' retirement](https://x.com/nikitabier/status/2047041338106159484) on April 22, 2026 for May 6
([migration was later extended to May 30](https://techcrunch.com/2026/04/23/x-is-shutting-down-communities-because-of-low-usage-and-lots-of-spam/)),
but Communities were [still running on October 25, 2026](https://x.com/i/status/2114417889600307503) and API version
2.172 still documents `community_id`; checked October 26, 2026, with no new shutdown date found.

An `x_post` can name a Community with `body.community`: its numeric ID (a string) or its
`https://x.com/i/communities/<id>` URL. The first check looks up its name through X's API and caches it in this
workspace, at X's published $0.005 Community Read price. You can also look it up first with
`POST …/lookups/x-community` (`{ "community": "<id or URL>", "connection_id": "con_…" }`; the connection is optional).
The generated CLI operation is `flaghand mentions look-up-x-community --community '<id or URL>'`; MCP exposes `lookUpXCommunity`
through `callWriteOperation` (the lookup caches its result), or directly in its full toolset. No lookup or check posts anything.

Check and the passkey snapshot hold the resolved `{ id, name, url }` in `content.community` and
`payload.parts[0].community`. The owner sees that Community in the preview. Posting sends only the frozen ID as
X's `community_id`, and live read-back checks it. The account must have permission to post there; the lookup is not
a membership check. A refused lookup blocks approval instead of falling back to an ordinary post. Editing the
destination voids the approval like any other body change. Threads do not accept a Community destination.

X's documented Community lookup needs `list.read`, in addition to `tweet.read` and `users.read`. New X connections
request it. An existing connection needs the owner's reconnect only before its first uncached Community lookup;
ordinary posting and already-cached Community names keep working without it. Agents cannot connect the account.

`share_with_followers` is an optional boolean, forwarded unchanged to X and frozen in the approval; leave it out
(or set it to null) to use X's default. No true/false default is selected here. X's
[July 28, 2025 changelog](https://docs.x.com/changelog) says that `community_id` with `share_with_followers: true`
also broadcasts the Community post to the authenticated user's followers' timelines, matching **Also share with
followers** on x.com. The 2.172 OpenAPI schema still describes this field as "Share an exclusive (super-follower)
tweet with all followers". X returns `community_id` but does not return `share_with_followers`, so the live check
confirms the Community, not follower sharing.

API evidence checked 2026-10-26: [Create Posts](https://docs.x.com/x-api/posts/create-post),
[Community lookup](https://docs.x.com/x-api/communities/get-community-by-id),
[OpenAPI](https://api.x.com/2/openapi.json), and [pricing](https://docs.x.com/x-api/getting-started/pricing).
The create endpoint lists the ordinary `tweet.read`, `tweet.write`, `users.read` scopes and no Community-specific
Enterprise requirement. No real post was made to test access.

## X plugs

A plug is one reply that goes out under a post once it takes off: an `x_post` or `x_thread` can carry
`body.plug` with `text`, up to 4 `media`, a `metric` (`"likes"` or `"views"`) and a `threshold` (a whole number from 1).
When post 1 reaches the threshold, the scheduler posts the plug once, as a reply from the same account under the post
(a thread's last post), within the minute.

```json
{ "text": "Launching today.", "plug": { "text": "The full guide: https://example.com/guide", "metric": "likes", "threshold": 100 } }
```

- It reads the numbers best times already re-reads every hour for that X account, so it needs best times on for the
  account and adds no X reads to watch. Without it, a check that would schedule or post the draft fails with the fixes
  `start-insights` (the owner turns best times on in Insights; its first read backfills the account's posts, about
  $2.50 of X reads once) and `drop-plug`. Agents can't turn best times on. Posting the plug is one reply, read back like
  any post.
- While posting or best times spends credits, the plug checks post 1's numbers itself every hour instead, from the
  credits its approval set aside, so it doesn't need best times on and keeps checking at zero credits
  ([Credits](https://app.flaghand.com/docs/credits.md#posting-on-credits)).
- Best times re-reads a post's numbers for 8 days after it goes live. A post still under its threshold then never
  gets the plug, and neither does one whose account stops learning.
- The plug is checked like any X post (280 weighted characters unless the account can post longer ones, media and alt
  text), shown in the preview under the post and frozen in the approval: `content.plug` and `payload.plug`. Editing it
  voids the approval like any other body change. `plug: null` removes it.
- Not for replies (`reply_to_url`: best times reads only original posts) or `by_hand: "all"` (nothing posts for you
  there). The check says so with a `drop-plug` fix.
- The job's `plug` shows where it is: `state` is `watching` from approval until post 1 reaches the threshold, then
  `due`, `posting` and `posted`, or else `missed`, `failed` or `canceled`. It also has the `metric` and `threshold`,
  the numbers last `seen`, `watch_until`, and once posted its `reply` (`id`, `url`, `posted_at`, `check`). `error` says
  why it was missed or failed.
- It's posted once. If X's answer is unclear, it isn't sent again: check the post's replies on X. If X runs out of
  credits, it waits for them and then posts.
- `POST …/jobs/{id}/cancel` on the posted job stops a plug that hasn't gone out, and its auto-repost if it has one;
  the post stays live. Archiving the draft stops the plug too, and so does taking the post down; once posted, the plug
  is taken down with the post.
- The audit log and webhooks get `job.plug_posted`, `job.plug_missed`, `job.plug_failed` and `job.plug_canceled`.

## Check before asking

For X and LinkedIn, save the draft and run Check before requesting approval. Fix its reach suggestions first, or
explain a deliberate trade-off in `agent_note` or the existing plan note. Do not add another owner-confirmation step.
For a suggestion batch, leave the checked drafts in **Suggested**, not an approval request.

`POST …/drafts/{id}/check` (optionally with `mode` and `post_at`) returns everything that would stop the draft posting
exactly as previewed, the same checks the owner's approval runs. It also returns a non-blocking `reach` checklist for
X and LinkedIn: media first, alt text, length, LinkedIn links and best time. Passing rows are included; TikTok has no
reach list. Failed rows also appear in `suggestions` with `code: "reach"` and `rule` naming the checklist item.
`ok` counts `problems` only, never reach warnings. Threads identify the part; options carry their own checklist and
an aggregate with `option`. See [Reach checklist](https://app.flaghand.com/docs/reach.md) for the fields, fixes and dated evidence.

Example of a blocking problem:

```json
{ "ok": false, "snapshot_hash": "…", "problems": [
  { "text": "X no longer lets apps quote posts, even your own. Quote it yourself in X, or post it without the quote.",
    "fixes": [{ "kind": "hand-all", "patch": { "by_hand": "all" } }, { "kind": "drop-quote", "patch": { "quote_url": null } }] }
] }
```

Each fix's `patch` is the change to the body that applies it. Merge it into the complete body and `PATCH` the draft
with its current `rev`, then check again. Not every problem has a body patch: a missing time is a top-level `post_at`
update; `connect` fixes mean the owner has to reconnect the account in the desk. A successful HTTP check can still have
`ok: false`: check that field, not just HTTP success (or CLI exit 0). Check also returns the exact `snapshot`
an approval would freeze, including the request that would be sent to X or LinkedIn.

The checks include: the account is connected and its login lasts past the post time; the time is at least 2 minutes and
at most 90 days away; no post is empty; media counts, sizes and alt-text lengths fit the platform; an X quote is posted
by hand (X's API no longer quotes posts); an X reply answers the account's own post or one that mentions or quotes it
(read from the post's public data, free); each X post or thread part sent through the API has at most one cashtag
(like `$TICKER`); every LinkedIn tag is linked to a company page by its exact name (tagging people needs a restricted
LinkedIn scope that Flaghand can't get today, so post person tags by hand or use plain names);
LinkedIn video format, file size and length; X and LinkedIn polls against each platform's rules. TikTok drafts have their own checks: see [TikTok](https://app.flaghand.com/docs/tiktok).

X cashtags are counted by X's own text rule, not by counting dollar signs. An offending single post offers `hand-all`;
an offending first thread post offers `hand-first` only if the later parts pass. Or edit the text to leave at most one
cashtag per part (`edit-text`, no patch, `max_cashtags: 1`, with the thread's post number in `posts`), then check
again. A by-hand post isn't subject to this API restriction; `by_hand: "first"` still checks the rest of the thread.

A post that repeats, word for word, one the same account posted through Flaghand (or has scheduled) close to its time
is a problem too, since the platform refuses it: X within 12 hours (X documents no window; refusals have been seen
from 30 minutes to 12 hours) and LinkedIn within 10 minutes (its documented window). Only spacing is ignored, so
changing one word, mark or link is enough. The fix is `edit-text` (no patch: change the text; for a thread, `posts`
lists the repeated post numbers). A post refused as a repeat anyway fails with `error_code` `duplicate_post`.

## LinkedIn documents (PDF carousels)

A LinkedIn document is one swipeable PDF, not a photo carousel. Upload it as `application/pdf`, or export a
[slideshow](https://app.flaghand.com/docs/slideshows) with `POST …/slideshows/{id}/pdf` (CLI: `flaghand slideshows export-pdf drf_… --json`,
MCP: `exportSlideshowPdf`). The export renders the current slides and embeds their exact PNGs losslessly, one page per
slide in order. `4:5` and `1:1` are supported, as are the other slideshow shapes. Exporting never requests approval or
posts anything, and it doesn't connect a TikTok account. The answer's `media.id` is the PDF to attach:

```json
{
  "kind": "linkedin_post",
  "connection_id": "con_…",
  "body": {
    "text": "Our weekly plan.",
    "document": { "media_id": "med_…", "title": "Plan your week" }
  }
}
```

Use one PDF on its own, without photos, video or a link card. Text and tags work as usual; the text may be empty when
there is a document. LinkedIn's Documents API accepts at most 100 MB and 300 pages; uploads here retain the existing
50 MB maximum. An encrypted, unreadable, empty or over-300-page PDF is refused before it can be attached. Uploaded
PDFs are inspected in a private, memory-bounded parser, separate from the publishing desk's renderer. If their compressed
content cannot fit that runtime, the API, CLI, MCP and desk say: “The PDF's compressed content is too large to process;
re-export or flatten it.” This is a processing failure, not an additional file-size or page quota.

The approval freezes the PDF's SHA-256, storage key, byte size, page count and visible title. The desk and approval
sheet preview the actual PDF pages in a paged document card in light and dark; PDF scripts, forms and links are not
executed. Changing the attached PDF or its visible title needs a fresh approval. An exported PDF is an immutable copy:
editing its source slideshow never changes an attached/approved file. Export again and replace `document.media_id` to
use the edits; that voids the approval. In-chat MCP Apps show only the PDF's title and page count; review its actual
pages in the desk or approval sheet. Chat does not fetch or execute the PDF viewer.

Only after the owner's passkey approval does Flaghand initialize a Documents API upload and send the exact approved
PDF bytes. The job stays `processing` until LinkedIn reports that document `AVAILABLE`; the cron follows the same URN
without re-uploading, rechecking the approval and the workspace before publishing its title and URN with the Posts API.
Processing failure fails without a post; cancel works during processing, and the existing two-hour stale-post rule
still applies. It uses the current `w_member_social` grant, with no new scopes or reconnect.

**Posted does not mean read back.** LinkedIn member-post retrieval requires restricted `r_member_social`, which this
app doesn't request or have. We check the document's upload/processing status, not the published post. The result says
`check.available: false`, `error_code: "linkedin_readback_unavailable"`; it never claims the live post matches. This is
the same limitation as other LinkedIn posts. We never scrape LinkedIn pages to substitute for its API.

API rules checked against LinkedIn's [Documents API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/documents-api?view=li-lms-2026-10)
and [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-10)
on 2026-10-25. These are documented permissions; no live media upload or post was used as verification.

## LinkedIn video

A personal-profile video uses the same `linkedin_post` body, with one uploaded video's `media_id` in `media`.
Use MP4: LinkedIn's Videos API requires 75 KB–500 MB and 3 seconds–30 minutes. The shared upload endpoint currently
accepts up to 50 MB, so that remains the upload maximum here. MOV is accepted into the media library for other
platforms, but isn't accepted for LinkedIn API posting. A video must be on its own, without photos, other videos or a
link card; links can stay in the text. LinkedIn has no video alt-text field, so video alt text isn't sent.

Check reads the duration from the file, not the editable `duration` metadata, and freezes it with the file in the
approval snapshot. Its `width` and `height` for the native 552 px LinkedIn preview are read from the file, as for photos.

After the owner's passkey approval, Flaghand initializes the upload, sends LinkedIn's exact byte ranges, and
finalizes with each part's returned ID. While LinkedIn processes it, the job is `processing`: nothing is posted yet.
The cron follows that same uploaded video each minute and posts only after it becomes `AVAILABLE`, checking the
workspace and the signed approval again. Keep or close the page; neither re-uploads the video. The existing two-hour
stale-post rule still applies: if processing lasts past that window, the job fails rather than publishing it late.
A processing failure also ends the job as `failed` without a post. You or an agent with `cancel` can cancel a LinkedIn video while it is still
processing, before publishing begins.

This uses the existing `w_member_social` permission; no new LinkedIn scope or account reconnect is needed. LinkedIn
posts still aren't read back: checking an uploaded video's processing state isn't permission to read profile posts.
API rules checked against LinkedIn's [Videos API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api?view=li-lms-2026-10)
and [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-10)
on 2026-10-25.
