---
title: "render_project"
description: "Re-render a saved project — the route that needs nothing attached to the conversation."
canonical: "https://shotops.dev/docs/mcp/reference/render_project"
last_updated: "2026-09-07"
shotops_mcp_version: "0.9.6"
result_contract_version: "2.0.0"
---
# render_project

Re-render a saved project — the route that needs nothing attached to the conversation.

## What it does

- Writes
- Account required

The account route. By default it renders the screenshots the project already holds, so "render my ShotOps project" is one call with no screenshots argument: no upload, no shell, and the image bytes never enter the chat. That makes it the route that works everywhere, ChatGPT on a phone included. The project supplies the structure — frame order, per-locale caption words, styling — so the result reproduces the designer’s exact strip.

You can ask: “Render the German Tidebook panels from the saved project.”

## Input

- `project`stringoptionalat least 1 character Which ShotOps project to target: its id (as returned by read_look/save_project), OR its NAME as the user says it — matched exactly, ignoring case and surrounding spaces. Omit = your most recently edited project. A name that matches none or several is refused with the account's projects listed, never resolved by guessing.
- `screenshots`arrayoptionalat most 60 items OPTIONAL. OMIT it (or pass []) to render the screenshots the project ALREADY holds — the files the user or designer put in it in the web app — with no upload and no file transfer of any kind. Supply this FLAT list only to OVERRIDE them with your own raw app screenshots: each an inline base64 PNG, { "ref" }, { "url" }, or (local stdio only) { "path" }, and each with a `name` (its original filename). Object entries may also carry `variant: { family?, locale? }`; that coordinate overrides the same stored cell, while an entry without `variant` is the base cell and remains the fallback for every render (the legacy behaviour). Entries are matched to the saved project's shots BY FILENAME (name === the shot's frameName) AND then resolved for each rendered device family + locale, so order does NOT matter and you never pre-sort. A supplied cell wins over the stored one at that coordinate. A shot with neither is reported as missing (it does not error the render). Prefer ref/url for real screenshots so the bytes never transit this conversation. each item any The published schema does not constrain this element, on purpose — a malformed entry comes back as a sentence instead of a stacked union error. What the server re-validates against is below.
- `locale`stringoptional App Store locale, e.g. "de-DE" (default en-US). Labels the render, selects which { "locales": … } screenshot variants render, and, for emit_bundle, selects the fastlane screenshots folder. Does not select caption text — pass the copy for this locale yourself via `style.captions[].text` (see the README's `captions.<locale>.json` convention).
- `outputs`arrayoptionalat least 1 itemat most 2 items The target DEVICES this project is for — iphone-6-9 (1290×2796) and/or iphone-6-5 (1242×2688), in the order they should be offered. On save_project this is saved ON the project (the same Output list the web app shows) and the first entry becomes the previewed size unless `panelPresetId` says otherwise; on render_project it OVERRIDES the saved set for this call. Omit it and the project's saved outputs are used unchanged. each item string one of`iphone-6-9``iphone-6-5`
- `preview`booleanoptional Render at ~25% resolution for a fast styling preview at a quarter the render cost — small enough to always come back inline. Not for final delivery: re-render without `preview` (or use emit_bundle directly) once the look is right.
- `output`stringoptional How to return rendered bytes: "inline" (base64), "urls" (uploaded, short-lived signed download URLs — use for real/full-resolution renders so bytes never transit this conversation), or omit for auto (inline under ~200KB total, urls above). one of`inline``urls`
- `waivers`arrayoptional Approvals for readiness findings this run would otherwise be blocked on. Pass back the exact `contract.readiness.findings[]` id and digest from a `preview: true` run, or from the refusal you just received. Each waiver clears ONE finding; fatal findings are never waivable. each item object - `findingId`stringrequired The `contract.readiness.findings[].id` this waiver authorizes. Exactly one. - `findingDigest`stringrequired That finding’s `digest`. A digest from an earlier run no longer matches and is rejected as stale. - `policyVersion`stringrequired `contract.readiness.policyVersion` as it stood when the finding was raised. - `waivedBy`stringrequired The account that approved it. Must be the account making this call. - `waivedAt`stringrequired ISO 8601. - `reason`stringoptional Free text, for the receipt. It authorizes nothing on its own.
- `idempotencyKey`stringoptionalat least 1 characterat most 200 characters Stable retry key for paid Hosted production. Reuse it only for the identical request; a new key deliberately requests a new render. Omit it for deterministic fingerprint retry safety.

## What the server actually accepts

The published schema above deliberately accepts a wider shape for `screenshots`, so a malformed entry comes back as a sentence rather than a stack of union errors. The server re-validates against these shapes and refuses anything else.

- `screenshots`arrayoptionalat most 60 items OPTIONAL. OMIT it (or pass []) to render the screenshots the project ALREADY holds — the files the user or designer put in it in the web app — with no upload and no file transfer of any kind. Supply this FLAT list only to OVERRIDE them with your own raw app screenshots: each an inline base64 PNG, { "ref" }, { "url" }, or (local stdio only) { "path" }, and each with a `name` (its original filename). Object entries may also carry `variant: { family?, locale? }`; that coordinate overrides the same stored cell, while an entry without `variant` is the base cell and remains the fallback for every render (the legacy behaviour). Entries are matched to the saved project's shots BY FILENAME (name === the shot's frameName) AND then resolved for each rendered device family + locale, so order does NOT matter and you never pre-sort. A supplied cell wins over the stored one at that coordinate. A shot with neither is reported as missing (it does not error the render). Prefer ref/url for real screenshots so the bytes never transit this conversation. each item one of 4 shapes 1`string` Inline base64-encoded PNG (no "data:" prefix). Capped at 3MB of image data — a real full-resolution screenshot is 3-4MB and will NOT fit. Use request_screenshot_upload and pass { ref } instead; inline is for thumbnails and tests. 2`ref` A ref returned by request_screenshot_upload — resolved server-side, never re-sent inline. - `ref`stringrequiredat least 1 character - `name`stringoptionalat most 200 characters The screenshot's original filename (e.g. "03_statistics.png"). Pass it so that, if this render is saved as a project, the user's own screenshots folder re-loads exactly this file by name. Optional if the ref already carries a name (from request_screenshot_upload({ names })); omit entirely and the record falls back to a positional name (screen-N.png). - `variant`objectoptional Which screenshot cell this image belongs to. Empty {} is the project's base device family + base locale; a missing cell inherits through ShotOps' pixel fallback rules. - `family`stringoptionalat least 1 characterat most 80 characters Device screenshot family (for example "android"). Omit for the project's base family; the two currently advertised iPhone outputs share that base family. - `locale`stringoptionalat least 1 characterat most 80 characters App Store locale for these pixels. Omit for the project's base locale. 3`url` An https URL to a PNG or JPEG — fetched server-side, converted to PNG when needed (no redirects, ~20MB cap). - `url`stringrequiredat least 1 character - `name`stringoptionalat most 200 characters The screenshot's original filename (e.g. "03_statistics.png"). Pass it so that, if this render is saved as a project, the user's own screenshots folder re-loads exactly this file by name. Optional if the ref already carries a name (from request_screenshot_upload({ names })); omit entirely and the record falls back to a positional name (screen-N.png). - `variant`objectoptional Which screenshot cell this image belongs to. Empty {} is the project's base device family + base locale; a missing cell inherits through ShotOps' pixel fallback rules. - `family`stringoptionalat least 1 characterat most 80 characters Device screenshot family (for example "android"). Omit for the project's base family; the two currently advertised iPhone outputs share that base family. - `locale`stringoptionalat least 1 characterat most 80 characters App Store locale for these pixels. Omit for the project's base locale. 4`path` A local filesystem path to a PNG, read straight off disk — ONLY available over the local stdio server (npx shotops-mcp); the hosted server rejects this entry shape. - `path`stringrequiredat least 1 character - `name`stringoptionalat most 200 characters The screenshot's original filename (e.g. "03_statistics.png"). Pass it so that, if this render is saved as a project, the user's own screenshots folder re-loads exactly this file by name. Optional if the ref already carries a name (from request_screenshot_upload({ names })); omit entirely and the record falls back to a positional name (screen-N.png). - `variant`objectoptional Which screenshot cell this image belongs to. Empty {} is the project's base device family + base locale; a missing cell inherits through ShotOps' pixel fallback rules. - `family`stringoptionalat least 1 characterat most 80 characters Device screenshot family (for example "android"). Omit for the project's base family; the two currently advertised iPhone outputs share that base family. - `locale`stringoptionalat least 1 characterat most 80 characters App Store locale for these pixels. Omit for the project's base locale.

## Result

- `ok`booleanrequired
- `count`numberoptional
- `panelPresetId`stringoptional
- `panelWidth`numberoptional
- `panelHeight`numberoptional
- `renderMs`numberoptional
- `output`stringoptional one of`inline``urls`
- `locale`stringoptional
- `nextStep`objectoptional
  - `action`stringrequiredalways "offer_save_project" An offer to put to the user. Never a command, and never consent already given.
  - `tool`stringrequiredalways "save_project" The door that would persist this work, if they ask for it.
  - `projectCreated`booleanrequiredalways false No project was created or updated by this call — the same fact as `contract.effects.projectMutation`.
  - `consent`stringrequiredalways "required" Saving needs the user’s own preserve/reuse/keep-editing intent. A client that cannot ask returns this offer unresolved rather than saving.
  - `reason`stringrequired
- `credits`objectoptional Credits this call spent from the account wallet, the balance left afterwards, and when that wallet is next topped up (ISO 8601; absent when no further refill is scheduled). Renders cost per panel; reads, saves, uploads and deletes are free, as is bundling panels that were already rendered.
  - `debited`numberrequired
  - `remaining`numberrequired
  - `refillAt`stringoptional
- `note`stringoptional Free-text advisories about THIS call, joined into one string. Written when the server did something you did not literally ask for or could not have known: a look/version fallback, an output-mode downgrade, a device this server cannot render, a `style.shotLook` a `look` overrode, or placement set by hand where a `style.layout` template would have produced the same composition. Never an error — the call succeeded. Read it, and act on it next call.
- `layout`objectoptional What is wrong with this render’s COMPOSITION, measured on the panels it just returned. ABSENT means there is nothing to report — a clean strip, or one whose captions could not be measured. Advisory: the panels are already rendered and nothing here changed a pixel.
  - `findings`arrayrequired each item object - `kind`stringrequired What overlaps what. Captions must never overlap each other or a device; devices MAY overlap, and are only reported here when the pair does NOT read as composed (no real difference in scale, camera angle or roll). one of`caption-caption``caption-device``device-device` - `frames`arrayrequired 0-based indexes into the returned `panels`. For a caption finding the FIRST entry is the frame whose caption is the one to change; a second appears only when the other party sits in a different frame. each item number - `depthPct`numberrequired How deep the overlap runs, in whole percent of panel height — or, for `device-device`, the percentage of the smaller device the other one covers. - `anchored`booleanrequired true ⟹ a caption was placed by hand (an explicit `anchor`), so nothing may rewrite it. - `clearAtScale`numberoptional The verified factor that clears this frame’s auto-placed captions — 0.82 means 82% of their current size. Absent when no size above the legibility floor clears it, or when the offending caption is a hand placement.
  - `correctedLook`anyoptional The look this render used, with every caption shrunk to the largest size verified to clear the overlap. Pass it back as `look` to re-render. Absent when no repair is deterministic — a device-device overlap is a taste call (give the pair a real difference in scale, angle or roll) and a hand-anchored caption is a human placement. `note` says which.
- `contract`objectoptional The versioned ShotOps result contract (#662): what ran, where, what it changed, what it cost, what it produced and — on a refusal — a typed failure over a closed code catalog. Structured content is authoritative; the prose beside it is a rendering of this block.
  - `contractVersion`stringrequired The version of THIS envelope, REPORTED. Compare it against the version you were written for. There is no version negotiation: no tool accepts a requested version, so this is never a refusal — it moves only when a field changes meaning, and additive fields never move it.
  - `status`stringrequired one of`succeeded``partial``failed``refused``accepted``queued``running``cancel_requested``cancelled`
  - `terminal`booleanrequired false ⟹ this operation is still running and will be reported again (#666).
  - `operation`objectrequired
    - `id`stringrequired Identity for THIS call, unique per invocation. Quote it in a bug report.
    - `tool`stringrequired The registered tool name that produced this result.
    - `kind`stringrequired one of`render``bundle``read``mutate``upload``delete``status`
    - `durable`objectoptional #666 domain handle for addressable long-running work. Absent from synchronous calls.
      - `operationId`stringrequired
      - `pollWith`stringoptional
  - `execution`objectrequired
    - `location`stringrequired Where the work RAN. `local` is the caller’s own machine over stdio. one of`local``hosted`
    - `inputModes`arrayrequired Every way this door accepts screenshots and assets. each item string one of`inline_base64``stored_ref``remote_url``local_path``project_stored`
    - `deliveryModes`arrayrequired Every way this door can hand a result back. each item string one of`inline_base64``signed_url``local_path``share_link`
  - `effects`objectrequired What this call DID to the world, independent of what it returned. Every flag is stated on every result, false included: an absent flag would be indistinguishable from an effect nobody thought to declare.
    - `networkFetch`booleanrequired This call fetched bytes from a host neither ShotOps nor the caller controls.
    - `upload`booleanrequired Caller bytes were uploaded into ShotOps storage.
    - `retainedStorage`booleanrequired Something survives this call in ShotOps storage.
    - `projectMutation`booleanrequired A saved project or its look history changed.
    - `publication`booleanrequired Something became reachable outside the account — a share link.
    - `deletion`booleanrequired Something was permanently removed.
  - `cost`objectrequired
    - `unit`stringrequiredalways "credit" Public cloud credits — the same unit every ShotOps surface quotes.
    - `model`stringrequired How this connection pays. `unmetered` is local stdio, which renders on the caller’s own machine; `anonymous_allowance` is the unsigned hosted taste. one of`metered``anonymous_allowance``unmetered`
    - `estimated`numberoptional What the call was expected to cost, before it ran.
    - `reserved`numberoptional Held against the wallet for the duration of the call (#666).
    - `released`numberoptional Given back — an unused reservation or a refund after a post-charge failure (#666).
    - `settled`numberoptional Actually taken. Absent when nothing was charged.
    - `balanceAfter`numberoptional The wallet’s public cloud credit balance once this call settled.
    - `refillAt`stringoptional ISO 8601. When the wallet is next topped up; absent when none is scheduled.
  - `progress`objectoptional Persisted monotonic durable-operation progress. Never inferred from transient render metadata. Relay `panelsCompleted`/`panelsTotal` and the estimate to the waiting user; the item counters are internal bookkeeping and do not match what they asked for.
    - `completed`integerrequiredmin 0 Internal scheduling items done — panels PLUS the bundle and result items. Do not quote this to a person.
    - `total`integerrequiredmin 0 Internal scheduling items in total. Larger than the panel count.
    - `panelsCompleted`integeroptionalmin 0 Panels finished, in the unit the caller asked in. THIS is the number to report.
    - `panelsTotal`integeroptionalmin 0 Panels this operation will produce — the count the user asked for.
    - `attempt`integerrequiredmin 0
    - `heartbeatAt`string or nulloptional
    - `estimatedRemainingSeconds`numberoptionalmin 0 Rough seconds of rendering left, from this operation’s own measured pace once a panel has landed. An estimate, not a deadline — say “about”. Absent when nothing is left to render.
  - `resolvedInput`objectoptional #661 — what the server actually resolved the request to, before any pixel was produced.
    - `snapshotId`stringoptional `psi_` + the first 32 hex of the fingerprint. Quote it in a bug report.
    - `fingerprint`stringoptional sha256 over every resolved fact. Equal fingerprints ⟹ equal production input.
    - `sources`arrayoptional One entry per source cell, resolved or not. `slot` is the cell the bytes came FROM — a shot id and coordinate on a saved project, an ordered slot label on a direct render. Never a filename, a ref or a URL. `origin` is absent exactly when `status` is not `resolved`: there is nothing the cell came from. each item object - `slot`stringrequired - `status`stringrequired one of`resolved``missing``ambiguous` - `origin`stringoptional one of`inline_base64``stored_ref``remote_url``local_path``project_stored`
    - `defaultsApplied`arrayoptional What the server chose because the caller said nothing. each item string
    - `overrides`arrayoptional What the caller said that changed the outcome. each item string
  - `readiness`objectoptional #665 — the readiness verdict and any waiver receipt.
    - `state`stringoptional `blocked` ⟹ nothing was delivered. `ready_with_findings` ⟹ delivered, and here is what to know. one of`ready``ready_with_findings``waived``blocked`
    - `policyVersion`stringoptional The readiness policy this verdict was computed under. A waiver granted under another one is rejected.
    - `findings`arrayoptional Every finding, with `code`, `waivable` and `digest`. A waiver names one id AND its digest; there is no wildcard and no code-level waiver. each item object - `id`stringrequired `rf_` + 24 hex. Quote it in a waiver. - `severity`stringrequired one of`info``warn``block` - `message`stringrequired - `target`objectrequired The exact shot, panel, locale, output or frames this finding is about. - `shotId`stringoptional - `panelIndex`numberoptional - `locale`stringoptional - `output`stringoptional - `captionLayer`numberoptional - `frames`arrayoptional each item number - `evidence`objectrequired Bounded redacted facts used to derive the finding and its digest.
    - `waivers`arrayoptional The waivers this call ACCEPTED. Pass these objects back verbatim to reuse them; a rebuilt one is rejected. each item object - `findingId`stringrequired - `findingDigest`stringrequired - `policyVersion`stringrequired - `waivedBy`stringrequired - `waivedAt`stringrequired - `code`stringrequired one of`source_identity_ambiguous``output_unsupported``output_omitted``look_exact_unavailable``no_panels``panel_invalid_png``panel_dimensions_invalid``renderer_failed``input_changed_during_run``panel_source_empty``locale_source_fallback``device_source_fallback``caption_inherited``caption_locale_fallback``look_source_fallback``caption_device_collision``caption_caption_collision``device_device_collision``caption_legibility_unresolved``scope_filtered``locale_not_live_on_apple``look_hold_inactive``caption_text_empty` - `reason`stringoptional
  - `artifacts`arrayoptional each item object - `id`stringrequired The opaque asset id from the custody registry (#663) — or, for `kind: "share"`, the link’s own token. Never a storage path, and never a signed URL. - `kind`stringrequired one of`panel``bundle``screenshot``share``project` - `delivery`objectrequired - `mode`stringrequired one of`inline_base64``signed_url``local_path``share_link` - `url`stringoptional THE ONE FIELD a signed URL may appear in. Nothing else in this envelope carries one. - `expiresAt`stringoptional ISO 8601, when the grant above stops working. - `bytes`numberoptional - `retainedUntil`stringoptional ISO 8601, when ShotOps stops keeping the artifact itself.
  - `failure`objectoptional Present exactly when `status` is `failed` or `refused`.
    - `code`stringrequired The closed failure code. Branch on this, never on the sentence. one of`invalid_input``unsupported_input``project_not_found``project_ambiguous``no_projects``version_not_found``resolution_incomplete``readiness_required``readiness_unmet``asset_not_found``asset_in_use``authentication_required``plan_required``trial_choice_required``quota_exhausted``anonymous_limit_reached``capability_denied``billing_unavailable``render_failed``storage_failed``persistence_failed``upstream_unavailable``payload_too_large``rate_limited``operation_not_found``idempotency_conflict``operation_unavailable``operation_not_ready``contract_version_unsupported``internal_error`
    - `phase`stringrequired How far the call got. `input` and `authorization` guarantee nothing was rendered, stored, written or charged. one of`input``authorization``resolution``reservation``execution``delivery``persistence`
    - `retryable`booleanrequired true ⟹ the identical call may succeed later with nothing changed.
    - `nextAction`stringrequired The one move that resolves this, machine-readable. one of`none``fix_input``choose_project``upload_assets``reduce_scope``sign_in``choose_plan``upgrade_plan``retry``wait_and_retry``poll_operation``upgrade_client``contact_support`
    - `details`objectoptional Bounded, redacted, code-specific facts (the offending field name, the candidate project ids, the required plan). Never a credential, a signed URL or screenshot bytes.
- `panels`arrayoptional each item one of 3 shapes 1`string` 2`ref` - `ref`stringrequired - `url`stringrequiredformat uri 3`position` - `position`integerrequiredmin 1 - `contentIndex`integerrequiredmin 1 - `mimeType`stringrequiredalways "image/png" - `delivery`stringrequired one of`image``resource_link` - `ref`stringoptional
- `project`objectoptional
  - `id`stringrequired
  - `name`stringrequired
- `openUrl`stringoptionalformat uri
- `shots`arrayoptional each item object - `frameName`stringrequired - `matched`booleanrequired - `requestedVariant`objectoptional Which screenshot cell this image belongs to. Empty {} is the project's base device family + base locale; a missing cell inherits through ShotOps' pixel fallback rules. - `family`stringoptionalat least 1 characterat most 80 characters Device screenshot family (for example "android"). Omit for the project's base family; the two currently advertised iPhone outputs share that base family. - `locale`stringoptionalat least 1 characterat most 80 characters App Store locale for these pixels. Omit for the project's base locale. - `resolvedAt`one of 2 shapesoptional Which screenshot cell this image belongs to. Empty {} is the project's base device family + base locale; a missing cell inherits through ShotOps' pixel fallback rules. 1`object` Which screenshot cell this image belongs to. Empty {} is the project's base device family + base locale; a missing cell inherits through ShotOps' pixel fallback rules. - `family`stringoptionalat least 1 characterat most 80 characters Device screenshot family (for example "android"). Omit for the project's base family; the two currently advertised iPhone outputs share that base family. - `locale`stringoptionalat least 1 characterat most 80 characters App Store locale for these pixels. Omit for the project's base locale. 2`null` - `inherited`arrayoptional each item string one of`family``locale` - `source`stringoptional one of`supplied``project`
- `outputs`arrayoptional each item object - `deviceId`stringrequired - `label`stringrequired - `panelPresetId`stringrequired - `panelWidth`numberrequired - `panelHeight`numberrequired - `count`numberrequired - `panels`arrayrequired each item one of 3 shapes 1`string` 2`ref` - `ref`stringrequired - `url`stringrequiredformat uri 3`position` - `position`integerrequiredmin 1 - `contentIndex`integerrequiredmin 1 - `mimeType`stringrequiredalways "image/png" - `delivery`stringrequired one of`image``resource_link` - `ref`stringoptional
- `operationId`stringoptional
- `status`stringoptional one of`accepted``queued``running``cancel_requested``succeeded``failed``cancelled`
- `progress`objectoptional
  - `completed`numberrequired
  - `total`numberrequired
  - `panelsCompleted`numberoptional Panels finished — the number to report to the user.
  - `panelsTotal`numberoptional Panels this operation will produce.
  - `attempt`numberrequired
  - `heartbeatAt`string or nulloptional
  - `estimatedRemainingSeconds`numberoptional About how many seconds of rendering are left. An estimate — say “about”.
- `failure`anyoptional

## Example

That request maps to this call:

```
{
  "name": "render_project",
  "arguments": {
    "project": "Tidebook 2.4 launch",
    "locale": "de-DE",
    "preview": true
  }
}
```

One PNG per panel at every device size the project targets, plus a per-shot report saying which shots matched a screenshot and which are missing.

## Access and cost

- Needs an account. Not available to an anonymous hosted caller.
- Preview is available to any signed-in account. A full-resolution render is production output and needs an active Pro trial or Pro.
- Hosted pricing is the same as render_strip: 1 cloud credit per preview panel, 4 per full-resolution panel. Local rendering is free.
- A project holds its own screenshots when its owner set its reach to "Your account" in the web app. read_project reports exactly that.

## When it refuses

- **Shots come back reported as missing.**
  That is a report, not an error — the project has a frame with no stored or supplied screenshot. Supply that file, or accept the strip without it.

- **You supplied screenshots and they landed on the wrong frames.**
  Supplied files are matched to the project’s shots by filename, so the names have to be the originals. Order is irrelevant; the name is everything.

- **The reply says production output is not available.**
  Nothing rendered, nothing charged. Take the step account_status names, then retry — or render a preview in the meantime.

## Where this fits

- [Re-rendering after a refine](https://shotops.dev/docs/mcp/workflows/saved-project)
- [One render per locale](https://shotops.dev/docs/mcp/workflows/localization)

## What the agent is told

The title and description the server publishes on `tools/list` — this is the copy a model chooses between, reproduced verbatim.

Render a saved project

Re-make the store screenshots for a strip the user already has in ShotOps — the route that needs NOTHING attached to this conversation. By default it renders the screenshots the project ALREADY holds, so "render my ShotOps project" is one call with NO `screenshots` argument: no upload, no PUT, no shell, and the image bytes never enter the chat. That makes it the route that works everywhere, ChatGPT on mobile included. A project holds them whenever its owner set its reach to "Your account" in ShotOps, and read_project reports exactly that as `screenshots.available` — check there rather than assuming the files have to be re-supplied. Supply `screenshots` only to OVERRIDE them with your own raw files: a FLAT list, each with its original filename as `name`, matched to the project's shots BY filename — so ORDER does not matter, you never pre-sort, and a supplied file wins over the stored one. The project supplies the structure — frame order, per-locale caption words (pick one with `locale`), styling — so the result reproduces the designer's exact strip, at every device size the project targets (`outputs` overrides that for this call). Shots with neither a stored nor a supplied screenshot are reported as missing, not an error. Returns per-panel PNGs (`inline` or `urls`). No store credential.
