# Drafts and media

## Media

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

```sh
curl -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 "Authorization: Bearer $TOKEN" -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`). Add `width`, `height` (pixels) and, for a video,
`duration` (seconds) to the query so previews lay it out exactly; use the file's real dimensions, without a `px` suffix.
They are not detected automatically, and `0` is refused: leave out what you don't know. `PATCH …/media/{id}` sets them
later. 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.

## 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?, 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 streams only the HTML head, 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. An unavailable,
refused or invalid image returns a text-only card and a warning. A failed page fetch returns `upstream_failed`; a
non-HTML page returns `unsupported_media_type`.

Only public HTTP(S) pages can be fetched: embedded credentials, private/local addresses, other URL schemes and
LinkedIn pages (which cannot be scraped under its API terms) are refused with `invalid_request`. 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.

## 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: …" }
    ]
  }
}
```

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`, 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.
- `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, 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.
- `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](slideshows)), `cover_index`, `auto_add_music` and `post_mode` (`"direct"` or
  `"inbox"`). `tiktok_video`: `title`, one video in `media`, and `post_mode`. See [TikTok](tiktok).

### 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.

### X longer posts

A single `x_post` 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, and existing
thread validation and publishing are unchanged; threads are never split or merged automatically. Ordinary posts and
threads add no subscription reads.

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. 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` 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, slideshow text and planning labels are searched. 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`; 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.
No platform request is made. 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 `callOperation`. 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: they are never posted and are not approval
content, just like the desk title. 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's filter row shows the labels on its loaded posts as chips, alongside 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
`callOperation` with those operation IDs). 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.

## 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](agents#migrate-from-another-scheduler).

## X Communities

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 `callOperation`, 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 current official schema describes
this field as "Share an exclusive (super-follower) tweet with all followers", **not explicitly as Community
crossposting**. Do not promise a follower audience based on this flag. 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-25: [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://docs.x.com/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.

## Check before asking

`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:

```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); every LinkedIn tag is linked to a company page by its exact name (LinkedIn can't tag people through its API);
LinkedIn video format, file size and length; X and LinkedIn polls against each platform's rules. TikTok drafts have their own checks: see [TikTok](tiktok).

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](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. Supply the real `width` and `height` for the native 552 px LinkedIn preview, just 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.
