Skip to main content

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, 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 (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.
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; 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.