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.
Both scopes are grantable to API keys and OAuth tokens alike — see
auth.md §1.2.
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 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, andGET /v1/media(which listsREADYrows 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-Keyneeded. - An
upl_…handle whose bytes were never PUT is a422that says so, not a broken asset.
PATCH /v1/posts/{postId}/media rejects upl_… values — import first, then
attach the img_… id.
Deleting
There is noDELETE /v1/media/{mediaId} — removal is MCP-only, via
delete_media_asset (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 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.
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; 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: 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; 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 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 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. 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.