---
title: "Screenshots and assets"
description: "The input forms each door accepts, the size limits, how long an asset lives, and the difference between an asset a project references and a temporary one you can safely delete."
canonical: "https://shotops.dev/docs/mcp/concepts/screenshots-and-assets"
last_updated: "2026-09-07"
shotops_mcp_version: "0.9.6"
result_contract_version: "2.0.0"
---
# Screenshots and assets

The input forms each door accepts, the size limits, how long an asset lives, and the difference between an asset a project references and a temporary one you can safely delete.

Every image you hand ShotOps takes one of a small number of forms, lands in one of a small number of places, and lives for a knowable length of time. This page is what the workflow articles assume.

## How a screenshot gets in

A `screenshots` entry names exactly one source. Two sources in one entry is refused — split them.

| Form | Works on | What it means |
| --- | --- | --- |
| { "ref": "…" } | Hosted, and token-backed local | A screenshot already uploaded under your account. The form to prefer. |
| { "url": "https://…" } | Hosted | The server fetches a PNG or JPEG itself and converts JPEG to PNG. |
| { "file": { … } } | Hosted | A PNG/JPEG ChatGPT attachment descriptor. In ChatGPT it arrives as a top-level file parameter rather than an array entry. JPEG is converted to PNG. |
| { "path": "/abs/01_today.png" } | Local stdio only | Read straight off your disk. Nothing is uploaded. |
| An inline base64 string | Both | Small images only. See the cap below. |
| { "locales": { … } } | Both | Per-locale variants of one screenshot; each variant is any of the above. |

The split is not arbitrary and it is not a gap waiting to be closed. `{ url }` and `{ file }` need account storage to import into, which the local server does not have. `{ path }` needs a filesystem the caller is allowed to name, and on a multi-tenant host that is a local-file-inclusion hole. Each refusal names the door that is open on your tier.

Pre-rendered `panels` for [`emit_bundle`](https://shotops.dev/docs/mcp/reference/emit_bundle) split exactly the same way: `{ ref }` on hosted, `{ path }` on local.

[`import_screenshot`](https://shotops.dev/docs/mcp/reference/import_screenshot) turns any of the hosted forms into refs, all of them in one call, one result per entry in the order you sent them; a failed entry reports its own error without cancelling the others. [`request_screenshot_upload`](https://shotops.dev/docs/mcp/reference/request_screenshot_upload) is the answer when the bytes exist only on the user's own machine: it mints signed slots you `PUT` to directly.

> Note Minting a slot moves no bytes. A ref whose `PUT` never happened resolves to nothing, and says so — it does not silently render an empty panel.

## Size limits

| Limit | Value | What happens past it |
| --- | --- | --- |
| Inline base64, decoded | 3 MB | A refusal naming request_screenshot_upload. Far past it, the host drops the request with a bare HTTP 413 and no message at all. |
| A fetched { url } | About 20 MB, PNG or JPEG, no redirects | The entry fails with its own error; the rest of the batch still imports. |
| An anonymous hosted request | 1 MB of wire bytes | A refusal telling you to import by URL and render by ref. |

A real full-resolution app screenshot is 3–4 MB, which is to say it does not fit inline. Treat inline as a thing for thumbnails and tests.

Every raw PNG is palette-optimized inside ShotOps before rendering — dimensions and transparency are preserved, and an already-small PNG is left byte-for-byte alone. Fetched JPEGs are converted to palette PNGs at the same boundary without changing their dimensions. That happens after the input arrives, so it does not raise the inline cap.

## Where assets live, and for how long

Uploaded screenshots, rendered panels and bundles live in private, account-scoped storage. Completed hosted artifacts have an opaque identity, reported as `contract.artifacts[].id`; it is not a storage path or a signed URL. Treat every returned ref or artifact id as an opaque handle, and use the delivery URL only to download. That storage holds screenshots, renders and bundles and nothing else — no Apple credential, no store credential, ever.

The practical rule is simple: keep source screenshots in a saved Project, download finished output promptly, and never treat a delivery URL as permanent.

| Thing | Lifetime |
| --- | --- |
| An account-scoped upload, private panel or bundle | Read contract.artifacts[].retainedUntil when it is present. Without that field, do not invent an expiry. |
| A screenshot referenced by a saved project | Protected from automatic expiry while the reference exists. Explicit deletion is refused until the dependency is gone. |
| A signed download URL from output: "urls" | One hour. It is a delivery grant, not the asset's identity or retention promise. |
| Anything an anonymous connection uploads | One hour, with the expiry carried in the object's own path — the ref stops resolving on time whether or not the sweeper has reached the bytes. |
| A pending-claim link from an unsigned local save_project | Seven days. Sign in through it within that window to become the project's owner. |
| A share link from emit_bundle({ share: true }) | Fourteen days. After that the link is dead and downloads nothing; the row stays visible to its owner for another seven days so it can still be revoked deliberately, then it is reaped. |

For an individual output, `contract.artifacts[].retainedUntil` is the authoritative deadline when one applies. Do not treat access or a download as an extension of that deadline; save or retain the source through the product when it must outlive a temporary run.

Local rendering uploads nothing at all, so on that door there is usually nothing with a lifetime to think about. The two deliberate exceptions are an unsigned `save_project`, which stages the raw PNGs privately so a claimed project opens whole, and a token-backed one, which uploads them as account refs.

## Referenced or temporary

The distinction that decides whether deleting something is safe:

- Referenced — a saved Project, production operation, share or preview points at it. The dependency keeps automatic retention alive and causes explicit deletion to be refused.
- Temporary — nothing points at it. The panels and zips one run produced, an upload superseded by a newer capture, a preview rendered while iterating.

The record never stores screenshot bytes. What it stores is the pointer, which is why a project saved from account-scoped refs reopens with its screens already in it, and a project saved from inline base64 or a `{ url }` opens empty and asks the user to re-load their own files. `read_project` reports the state in `screenshots.available`, `count`, `total`, `names` and `missing`.

## Deleting assets

[`delete_assets`](https://shotops.dev/docs/mcp/reference/delete_assets) permanently removes the opaque asset ids you name. It only reaches objects under your own account, and it validates the entire batch before deleting anything, so one foreign, malformed or still-referenced asset refuses the call instead of leaving a half-deleted set. Deleting the same completed set twice converges harmlessly.

> Dependencies are protected, deletion is still irreversible A live Project, operation, share or preview dependency refuses deletion. Do not use that check as permission to guess: offer cleanup after delivery, act only on explicit user intent, and name the temporary output assets this run created. Once an unreferenced asset is accepted, deletion cannot be undone.

Because it is irreversible, deletion is something to offer after delivery, never something to do automatically at the end of a run, and never something to do on assets you did not create.

## Output modes

Two independent properties, and conflating them is how a preview ends up in a store listing.

Where the bytes go is the `output` argument: `"inline"` (base64 in the response), `"urls"` (uploaded under your prefix, returned as a ref plus a one-hour signed download URL), or omitted for automatic — inline under roughly 200 KB total, URLs above. Use `"urls"` for anything full-resolution so the bytes never transit your context.

Two connections override that outright rather than honouring it. The local server has no object storage to upload to, so it always answers inline. An anonymous hosted connection is forced inline too, because that tier produces nothing durable by design.

What the pixels are is the `preview` argument. `preview: true` renders at about a quarter resolution: fast, cheap, small enough to come back inline, and the right thing to iterate on. Dropping it renders store-ready panels, which is the half of the product that needs an active Pro trial or Pro.

A preview artifact is never shippable, at any transport, on any plan, on either door. It is a picture of a decision, not a deliverable.
