> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Media

# Media

The media library holds a project's images: uploads, LinkedIn-synced
assets, and AI-generated pictures. The REST surface covers search,
AI generation, and attaching media to posts.

Full schemas: **OpenAPI reference → Media**.

| Method | Path                       | Scope                        | Notes                                                     |
| ------ | -------------------------- | ---------------------------- | --------------------------------------------------------- |
| GET    | `/v1/media`                | `media:read`                 | Search reachable assets.                                  |
| POST   | `/v1/media`                | `media:write`                | Import your own image as a library asset.                 |
| POST   | `/v1/media/generations`    | `media:write`                | Async AI image generation (job).                          |
| POST   | `/v1/media/carousels`      | `media:write`                | Async AI carousel generation (job — deck + rendered PDF). |
| PATCH  | `/v1/posts/{postId}/media` | `posts:write` + `media:read` | Full-state media write onto a post.                       |

Both scopes are grantable to API keys and OAuth tokens alike — see
[auth.md §1.2](./auth.md#12-scopes).

***

## Searching (`GET /v1/media`)

Returns the project's **reachable** assets — owned + profile-assigned,
`READY` only — newest first, with display URLs, tags, and provenance.
Optional free-text `query` (title/filename/AI caption) and a `source`
filter (`UPLOAD` | `LINKEDIN_SYNC` | `GENERATED`).

`displayUrl` may be `null` for LinkedIn-synced rows and may expire when
it is a backup URL — fetch fresh rather than caching URLs long-term.

## Importing your own image (`POST /v1/media`)

The user's own photo becomes a library asset in one synchronous call —
inline `content_base64` (small files only: ≤ \~3 MB decoded, and an
optional `sha256` of the decoded bytes is verified) or a two-step
[`uploadId`](./uploads.md) for anything larger, exactly one per call.
A request body much over the inline cap is rejected at the platform
edge (\~4.5 MB on the wire) as a bare HTTP 413 with no error envelope —
pre-check the file size rather than retrying inline. The
response carries the `img_…` id that
`PATCH /v1/posts/{postId}/media` takes.

* **`status: "PROCESSING"` does not mean "wait".** The asset is
  attachable the instant it is returned — Cloudflare re-hosting and
  vision tagging run asynchronously, and `GET /v1/media` (which lists
  `READY` rows only) shows it once they finish.
* **Images only.** SVG is refused; a PDF or audio file is refused
  naming the endpoint that takes it (`/v1/knowledge`, `/v1/sources`).
* **Retries are free.** The same bytes, or the same upload handle,
  return the asset already created rather than a second copy — the
  import is idempotent by content, no `Idempotency-Key` needed.
* An `upl_…` handle whose bytes were never PUT is a `422` that says
  so, not a broken asset.

An upload handle is **not** a media key: `PATCH
/v1/posts/{postId}/media` rejects `upl_…` values — import first, then
attach the `img_…` id.

## Deleting

There is no `DELETE /v1/media/{mediaId}` — removal is MCP-only, via
[`delete_media_asset`](./mcp-tools.md#2-write-tools) (`media:destroy`,
two-phase). It is the dashboard's tombstone delete: the asset leaves
the library and every future attachment, posts already carrying it
keep their copy, and a LinkedIn-synced photo is never re-imported.
Re-sending the identical bytes to `POST /v1/media` revives the asset —
under a newly minted storage key, so an object an existing post
already points at is never overwritten. The superseded key is recorded
on the row, so the retention sweep still erases those bytes when the
asset is next deleted and purged. That record is bounded: an asset put
through enough delete/restore cycles to fill it answers
[`409 conflict`](./errors/conflict.md) instead of dropping the keys,
and the repair is to wait out the retention window below.

The kept copy lasts **30 days**. The retention sweeps then erase the
stored file, **every earlier version of it** and the CDN copy, and every
embedded reference returns `404` — saved carousel decks, saved templates
and exported PDFs lose the image, and so does any post still scheduled
at that point. The countdown starts at the delete. Already-published
LinkedIn posts are unaffected: LinkedIn hosts its own copy.

**Every version is erased, not only the current file.** A generated
asset that was re-rendered (a carousel or card edited more than once)
was repointed at a fresh storage key on each render, leaving one stored
object per render. A nightly inventory sweep erases every media-library
object no row references once it is older than the same 30-day window —
it works from the bucket itself, not from anything the writer recorded,
and a superseded render is by definition older than its replacement, so
all of them are gone within 30 days of the delete. The delete may be
presented as total removal of the stored file and its history.

## Generating (`POST /v1/media/generations`)

Kick off an AI image generation. Three kinds:

* `PHOTO` — an original AI image, optionally style-referenced.
* `PHOTO_EDIT` — compose the author's own photo(s) into a desired look.
* `IMAGE_EDIT` — a targeted in-place tweak of an existing generated
  image.

References and subjects are passed as `img_…` media ids only — raw URLs
are never accepted, and every id resolves through the same reachability
rules as `GET /v1/media`. The response is a
[`Job` envelope](./jobs.md); on completion `result.mediaId` carries the
new `img_…` id.

The workspace's saved branding is available material on a `PHOTO` or
`PHOTO_EDIT` run: it is applied when the instruction asks for it (for
example "in our branding"), never forced onto a run that asked for a
different look.

Generation is metered by the [AI budget](./jobs.md#ai-budget): capacity
is reserved atomically at submit, so concurrent calls cannot jointly
exceed the cap — losers receive `402`. `wait_for_completion_ms` (capped
at 25,000 ms) holds the connection for short jobs.

## Generating a carousel (`POST /v1/media/carousels`)

Async carousel generation: the model picks a design, storyboards the
deck, writes every slide in the project's voice, generates the imagery
the design calls for, stamps the project's brand identity, and renders
a PDF. The response is a [`Job` envelope](./jobs.md); on completion the
result carries `mediaId` (the deck as a library asset), `documentKey`
(the rendered PDF — attachable as `kind: "document"`), and `imageSlots`
— see [Jobs § job types](./jobs.md#job-types) for how a mid-job budget
exhaustion degrades rather than fails. Decks run two to three minutes,
so poll rather than sync-waiting.

## Attaching to a post (`PATCH /v1/posts/{postId}/media`)

A **full-state** write: the body describes the post's complete media
state after the call (`kind: "images"` with a list, `"video"`,
`"document"`, or `"none"` to clear). Library images are referenced by
`img_…` id and resolved to durable storage keys server-side. Being
full-state, the write is naturally idempotent — repeat it safely.

A `"document"` takes an optional `title` — the heading LinkedIn renders
over the document in the feed. It wins over the file `name`; leave it
out and the title is derived as before (file name, then the post's
internal title). A `"video"` also takes an optional `title`, rendered
as a label bar under the player — but its default is the opposite:
leave it out and the post publishes with **no** title at all, exactly
like a video composed natively on LinkedIn (nothing is ever derived).
Either title is honoured **only before the post is published**:
LinkedIn does not accept `content` in a partial post update, so a title
sent for an already-published post changes nothing on LinkedIn. Images
take no title; a library image reference may carry an `alt` override
that wins over the asset's own alt text for this post. An asset whose
`alt` is `null` is still not attached without one: the post takes the
description Scripe's vision tagging wrote at import — screen-reader
alt text, published to LinkedIn with the image, and not a search
signal — unless the asset was attached before that tagging finished,
in which case the post carries none and `alt` is yours to set.
`GET /v1/media` reports only the `alt` a person set, never the
generated one.

Attached **images are fitted for you at publish time.** LinkedIn refuses
an image of 36,152,320 pixels or more, or over \~8 MB, and it refuses
asynchronously — so an over-limit photo used to leave a post that
Scripe recorded as published and that never reached the feed. Scripe
now downscales such an image (longest side 4,096 px, re-encoded as
JPEG — PNG where it has real transparency, GIF where it is animated)
and uploads that rendition; the library asset itself is untouched, and
a JPEG, PNG or GIF already inside both limits is uploaded
byte-identically. A format LinkedIn does not accept at all (WebP, say)
is re-encoded even when it is small. Every image is then confirmed
processed by LinkedIn before the post references it, so a `published`
post is one LinkedIn actually accepted.

If an image still cannot be made acceptable — it cannot be decoded or
fitted, or LinkedIn reports it as failed — the **whole publish fails**
rather than going out silently broken: the post keeps its `scheduled`
status and `scheduledAt`, gets no LinkedIn id, and its
`lastPublishError` (surfaced as `delivery.detail`) names the image by
filename and carries the stable marker `LINKEDIN_IMAGE_NOT_ACCEPTED`.
The repair is to attach a different image — the scheduler keeps
retrying for 24 hours, so a swapped image publishes on the next tick.
A failure that is not a verdict on the image (LinkedIn's status
endpoint unreachable, the cron tick running out of time) keeps its own
error and carries no marker.

A `"video"` may also carry a `thumbnailKey` — a stored image key
published as the video's cover. It is **fail-closed**: a thumbnail that
cannot be fetched, that LinkedIn rejects, or for which LinkedIn returns
no thumbnail upload slot fails the **whole publish**, so nothing goes
out with a cover other than the one you named. The post's
`lastPublishError` then names the thumbnail as the cause, carrying the
stable marker `VIDEO_THUMBNAIL_UPLOAD_FAILED`. A connection-level
failure on that upload (expired token, missing permission, rate limit,
LinkedIn 5xx) still fails the publish but keeps its own error — it is a
verdict on the connection, not on the thumbnail.

**A post with a poll refuses media.** LinkedIn's post content is a union,
so a post carries either media or a poll, never both. Attaching media to a
post the dashboard gave a poll answers
[`conflict`](./errors/index.md#conflict) and changes nothing; remove the
poll in the Scripe dashboard first. Clearing media (`kind: "none"`) is
allowed and leaves the poll alone.

***

## Agent access

`search_media`, `create_media_asset`, `generate_image`,
`generate_carousel`, and `attach_media_to_post` are the MCP twins — see
[MCP tools](./mcp-tools.md). Generation streams progress to MCP hosts.

A board idea can carry media too, edited by `attach_media_to_idea`
(`ideas:write` + `media:read`) — MCP-only, with no REST twin; see
[Ideas](./ideas.md).
