---
title: "emit_bundle"
description: "The uploadable file: a fastlane deliver-ready zip of the finished screenshots."
canonical: "https://shotops.dev/docs/mcp/reference/emit_bundle"
last_updated: "2026-09-07"
shotops_mcp_version: "0.9.6"
result_contract_version: "2.0.0"
---
# emit_bundle

The uploadable file: a fastlane deliver-ready zip of the finished screenshots.

## What it does

- Writes
- Destructive
- Open world
- Account required

The delivery step. It returns one zip holding the rendered screenshots, a pre-filled Deliverfile that is screenshots-only, and a README. Three ways in, the same zip out: render here from screenshots, re-zip panels a previous render already produced, or name a saved project and it packages what that project holds. You run fastlane yourself — this server never holds an Apple or store credential, never creates an App Store version and never submits anything for review.

You can ask: “Package the rendered Tidebook panels for both locales without rendering again.”

Annotated open-world because it mints a file and, optionally, a public share link. It still never reaches a store.

## Input

- `screenshots`arrayoptionalat least 1 itemat most 10 items Screenshots in App Store display order (1–10). Preferred common form: a FLAT array, where each entry becomes its own App Store panel, e.g. [{ "ref": "first" }, { "ref": "second" }]. Advanced multi-phone form: a NESTED array of slots, where several entries inside one slot are composited into that single panel, e.g. [[{ "ref": "left" }, { "ref": "right" }], [{ "ref": "third" }]]. Each screenshot entry is EITHER an inline base64 PNG string (capped at 3MB of image data — a full-resolution screenshot is 3-4MB and will NOT fit; use a ref for anything real), OR { "ref": "..." } from request_screenshot_upload, OR { "url": "https://..." }, OR (local stdio mode only) { "path": "/abs/or/relative/path.png" } to read a file straight off disk, OR { "locales": { "<locale>": <any of those> } } to vary that screenshot per App Store locale (missing locales fall back to the en-US variant). Prefer ref/url for real screenshots so the bytes never transit this conversation. Required unless `panels` (pre-rendered refs) is given instead. 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.
- `panels`arrayoptionalat least 1 itemat most 10 items Pre-rendered panels, packaged into the bundle WITHOUT rendering again — either { "ref": "…" } from a prior render_strip `output: "urls"` call (hosted), or (local stdio mode) { "path": "/abs/panel.png" } straight off disk so the free local tier can package a two-pass render. Mutually exclusive with `screenshots`; one of the two is required. An entry may also be { "locales": { "<locale>": { "ref" | "path" } } } to vary that panel per locale (see `locales`). 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.
- `panelPresetId`stringoptional App Store screenshot size. Default r69 (6.9″ iPhone, 1290×2796). iPhone sizes only — iPad is not rendered yet. one of`r69``r65``r55`
- `clip`stringoptional An explicit top-level `clip: "strip"` or `clip: "panel"` wins over every shot; `"strip"` forces continuous overflow and `"panel"` clips every device to its own panel. Omit `clip` to honour each shot's `clipToFrame`; when it is missing or false, overflow is allowed. one of`strip``panel`
- `outputs`arrayoptionalat least 1 itemat most 2 items Target device outputs to render into one screenshot bundle. Supported now: iphone-6-9 (1290×2796 / APP_IPHONE_67) and iphone-6-5 (1242×2688 / APP_IPHONE_65). Each is rendered from the same Look and inputs; omit this field to keep the legacy single panelPresetId form unchanged. With a `project` and no list, the project's OWN saved outputs are used. Not supported with pre-rendered `panels` refs. each item string one of`iphone-6-9``iphone-6-5`
- `look`objectoptional A ShotOps "look" — per-DEVICE styling + background, exactly as read_look returns it. Its shots[] give every screenshot/phone its own styling (including multiple phones in one panel); style.shotLook is one shared style for all. COMPOSES with `style`: pass a look for the devices + `style.captions` for the words in ONE render. Validated — unknown keys are rejected (call describe_look for the full field catalog).
  - `schema`numberoptional Look schema version. The server stamps it on every save (save_look); read_look returns it. Any input value is ignored — never set it yourself.
  - `panelPresetId`stringoptional Canvas size preset, e.g. r69 = 1290×2796. Default r69.
  - `bgMode`stringoptional One gradient across the strip, or a flat colour per panel. Default gradient. one of`gradient``perPanel`
  - `gradientStops`arrayoptionalat least 1 item Canonical gradient stops — { color (hex), at (0–1 position) } — wins over gradientFrom/gradientTo/gradientDir when present. Read this back for the full stop list a Studio-authored look may carry; the trio below is only ever a two-stop summary. each item object - `color`stringrequired - `at`numberrequiredmin 0max 1
  - `gradientAngle`numberoptional Canonical gradient angle in degrees, clockwise from "to top" (CSS convention: 90 = right, 180 = down, the gradientDir:"vertical" equivalent). Paired with gradientStops.
  - `gradientFrom`stringoptional Gradient start (hex) — a DERIVED mirror of gradientStops[0], re-derived from the stops on every render and reload. Setting it ALONE changes nothing: set gradientStops to change the backdrop. Default #1b1b2e.
  - `gradientTo`stringoptional Gradient end (hex) — a DERIVED mirror of the last gradientStops entry, re-derived from the stops on every render and reload. Setting it ALONE changes nothing: set gradientStops to change the backdrop. Default #0a0a14.
  - `gradientDir`stringoptional A DERIVED mirror of gradientAngle (nearest axis), re-derived on every render and reload. Setting it ALONE changes nothing: set gradientAngle to turn the gradient. Default vertical. one of`horizontal``vertical`
  - `panelColors`objectoptional Per-panel flat colours (mode perPanel), keyed by panel id.
  - `shadow`booleanoptional Drop shadow under the phones. Default true.
  - `floorReflection`booleanoptional Flipped, faded floor reflection under each phone. Default false.
  - `panelBackgrounds`objectoptional Per-frame background overrides keyed by panel id (web-authored; agents rarely set these).
  - `captionStyles`objectoptional Per-panel caption STYLE arrays keyed by panel id (styling only, no words — for caption text pass style.captions).
  - `shots`arrayoptionalat most 60 items One entry per SCREENSHOT/DEVICE, flattened in panel order. Each entry has its OWN `look`. For screenshots:[[left,right]], pass two shots with the SAME panelId ("panel-1") to give the two devices different colours/materials/angles. This is how a look varies devices; style.shotLook cannot. each item object - `panelId`stringoptional Panel containing this screenshot/device. Panels are panel-1…panel-N in slot order; REPEAT the same panelId for multiple devices in one panel. A saved look’s own ids are remapped by ordinal. - `look`objectoptional This individual screenshot/device’s styling — the SAME fields as style.shotLook. - `angle`stringoptional Camera preset. Default front. one of`front``left``right` - `cameraPos`one of 2 shapesoptional Manual camera position override; null/omit = use the angle preset. 1`x` - `x`numberrequired - `y`numberrequired - `z`numberrequired 2`null` - `roll`string or numberoptional -45–45° clock-hand tilt. Default 0. - `phoneHeight`string or numberoptional -45–45° clock-hand tilt. Default 0. - `hOffset`string or numberoptional -45–45° clock-hand tilt. Default 0. - `vOffset`string or numberoptional -45–45° clock-hand tilt. Default 0. - `material`stringoptional Device body. Default real. one of`real``clay` - `colorway`stringoptional Body colour (material "real"). Default silver. These are finish SLOTS — each device binds its own real finish (silver = iPhone Silver / Pixel Moonstone). "green" is Pixel-only (Jade); other devices fall back to their first finish. one of`orange``blue``silver``green``custom` - `customColor`stringoptional Hex body colour when colorway is "custom". - `finish`string or numberoptional -45–45° clock-hand tilt. Default 0. - `clearcoat`string or numberoptional -45–45° clock-hand tilt. Default 0. - `clayTone`stringoptional Clay tone (material "clay"). Default grey. one of`grey``white``charcoal``custom` - `clayCustom`stringoptional Hex clay colour when clayTone is "custom". - `flatScreen`booleanoptional Render the screen flat (no curvature). Default false. - `glare`booleanoptional Screen glare. Default false. - `lighting`booleanoptional Scene lighting. Default true. - `reflections`booleanoptional Body reflections. Default false. - `clipToFrame`booleanoptional Clip this complete device composite to its owning frame. Default false (overflow allowed).
- `style`objectoptional Structured styling — the discoverable path (call describe_look for the full field catalog + defaults). `layout` is the fastest way in: one named composition that settles the headline region and the device placement together, with your explicit fields still winning over it. COMPOSES with `look`: pass BOTH to get per-device styling from the `look` AND captions from `style.captions` in ONE render (every real App Store strip). When a `look` is also given it supplies the devices + background, so `style.shotLook`/`style.background` are ignored (a note says so) — use `style` for `captions` then. Alone, `style` styles every phone identically + captions. Also composes with `useSavedLook`/`version`.
  - `layout`stringoptional Panel COMPOSITION template — sets the headline’s reserved region and the device’s placement, camera pose, and roll in one choice. "standard" — Headline on top, whole device below it, nothing cropped. The safe default. Reserves 2 headline lines (~36 characters). Character: [safe]. "bleed" — Room for a longer headline, device running off the bottom edge. What the market looks like. Reserves 4 headline lines (~72 characters). Character: [conventional]. "top-bleed" — Device running off the TOP edge, headline underneath it. Inverts the usual reading order. Reserves 4 headline lines (~72 characters). Character: [expressive]. PRECEDENCE: the template expands FIRST into shotLook.phoneHeight/shotLook.hOffset/shotLook.vOffset/shotLook.angle/shotLook.cameraPos/shotLook.roll and captions[].sizePt/captions[].maxWidth/captions[].reserveLines/captions[].band, and THEN any of those fields you pass EXPLICITLY overrides it — so { layout: "bleed", shotLook: { vOffset: "45" } } renders bleed seated at 45, not at the template’s 38. That is how you say "bleed, but a bit lower" without the template silently winning. The reserved region is held at FULL SIZE whether or not the headline fills it, which is what makes every panel in a swiped set land on the same line. Nothing stores the template id: it is discarded at expansion, so a saved project and read_look carry only the expanded values. one of`standard``bleed``top-bleed`
  - `shotLook`objectoptional This individual screenshot/device’s styling — the SAME fields as style.shotLook.
    - `angle`stringoptional Camera preset. Default front. one of`front``left``right`
    - `cameraPos`one of 2 shapesoptional Manual camera position override; null/omit = use the angle preset. 1`x` - `x`numberrequired - `y`numberrequired - `z`numberrequired 2`null`
    - `roll`string or numberoptional -45–45° clock-hand tilt. Default 0.
    - `phoneHeight`string or numberoptional -45–45° clock-hand tilt. Default 0.
    - `hOffset`string or numberoptional -45–45° clock-hand tilt. Default 0.
    - `vOffset`string or numberoptional -45–45° clock-hand tilt. Default 0.
    - `material`stringoptional Device body. Default real. one of`real``clay`
    - `colorway`stringoptional Body colour (material "real"). Default silver. These are finish SLOTS — each device binds its own real finish (silver = iPhone Silver / Pixel Moonstone). "green" is Pixel-only (Jade); other devices fall back to their first finish. one of`orange``blue``silver``green``custom`
    - `customColor`stringoptional Hex body colour when colorway is "custom".
    - `finish`string or numberoptional -45–45° clock-hand tilt. Default 0.
    - `clearcoat`string or numberoptional -45–45° clock-hand tilt. Default 0.
    - `clayTone`stringoptional Clay tone (material "clay"). Default grey. one of`grey``white``charcoal``custom`
    - `clayCustom`stringoptional Hex clay colour when clayTone is "custom".
    - `flatScreen`booleanoptional Render the screen flat (no curvature). Default false.
    - `glare`booleanoptional Screen glare. Default false.
    - `lighting`booleanoptional Scene lighting. Default true.
    - `reflections`booleanoptional Body reflections. Default false.
    - `clipToFrame`booleanoptional Clip this complete device composite to its owning frame. Default false (overflow allowed).
  - `background`objectoptional
    - `mode`stringoptional One gradient across the strip, or a flat colour per panel. one of`gradient``perPanel`
    - `stops`arrayoptionalat least 1 item Gradient colour stops, in order along the gradient line — { color (hex), at (0–1 position; 0 = line start, 1 = line end) }. Two stops is the classic top/bottom gradient, more make a multi-colour sweep, one stop is a solid fill. Default a two-stop navy gradient: [{color:"#1b1b2e",at:0},{color:"#0a0a14",at:1}]. each item object - `color`stringrequired - `at`numberrequiredmin 0max 1
    - `angle`numberoptional Gradient angle in degrees, clockwise from "to top" — CSS `linear-gradient()` convention: 90 = to right, 180 = to bottom (the old "vertical" default, and this field’s own default), 270 = to left. Any value folds into 0–360.
    - `panelColors`arrayoptionalat most 10 items Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default. each item string or null
    - `shadow`booleanoptional Drop shadow under the phones. Default true.
    - `floorReflection`booleanoptional Flipped, faded floor reflection under each phone. Default false.
  - `captions`arrayoptionalat most 10 items One entry PER PANEL in slot order; null = no caption on that panel. An entry is EITHER a single caption object OR an array of caption layers (stacked, in order) on that panel. Alongside a `look`, an entry is a DELTA: every style field you set wins, every field you leave out keeps the look’s styling for that panel and layer — which is what lets you re-render a `layout.correctedLook` without restating your captions. each item one of 2 shapes 1`one of 2 shapes` 1`object` - `fontId`stringoptional Bundled font. Default inter. one of`inter``manrope``poppins``fraunces``space-grotesk` - `sizePt`numberoptionalgreater than 0 Font size in iOS points (preset-independent), 20–120. Default 32. Outside that range it is clamped to it and the response says so. - `color`stringoptional Text colour (hex). Default FFFFFF. - `align`stringoptional Default center. one of`left``center``right` - `anchor`objectoptional Normalized 0–1 position of the caption on the panel. Default {x:0.5, y:0.06}. - `x`numberoptionalmin 0max 1 - `y`numberoptionalmin 0max 1 - `maxWidth`numberoptionalgreater than 0 Wrap width as a fraction of the panel, 0.1–1. Default 0.86. Outside that range it is clamped to it and the response says so. - `reserveLines`integeroptionalmin 1max 8 How many lines of headline room to HOLD, whether or not the text fills it. A shorter headline is centred in the reserved space rather than leaving all the slack beneath it, and the space is held so every panel in the strip lands on the same line. Omit to let the caption be exactly as tall as its text. Set for you by style.layout. - `band`stringoptional Put this caption in the BOTTOM band, growing upward from the lower margin, instead of the default top band. This is how a headline sits UNDER the device (see the top-bleed layout). The caption stays auto-placed — it still reflows and still stacks; use this rather than pinning an anchor, which would freeze it. one of`bottom` - `bandInset`numberoptionalmin 0max 0.5 Inset of the auto caption band from its own panel edge, as a 0–0.5 fraction of panel height. The top band measures down; the bottom band mirrors it upward. Default 0.06. - `lineHeight`numberoptionalmin 0.5max 3 Line spacing as a multiple of font size, 0.5–3. Default 1.2. - `bandCenter`numberoptionalmin 0max 1 Horizontal reference of the auto caption block as a 0–1 fraction of panel width. Text alignment decides which edge sits there. Default 0.5. - `text`stringoptionalat most 200 characters The headline — per-RENDER input, never stored in a look. - `subtitle`stringoptionalat most 300 characters Optional subtitle under the headline — also per-render input. 2`array` each item object - `fontId`stringoptional Bundled font. Default inter. one of`inter``manrope``poppins``fraunces``space-grotesk` - `sizePt`numberoptionalgreater than 0 Font size in iOS points (preset-independent), 20–120. Default 32. Outside that range it is clamped to it and the response says so. - `color`stringoptional Text colour (hex). Default FFFFFF. - `align`stringoptional Default center. one of`left``center``right` - `anchor`objectoptional Normalized 0–1 position of the caption on the panel. Default {x:0.5, y:0.06}. - `x`numberoptionalmin 0max 1 - `y`numberoptionalmin 0max 1 - `maxWidth`numberoptionalgreater than 0 Wrap width as a fraction of the panel, 0.1–1. Default 0.86. Outside that range it is clamped to it and the response says so. - `reserveLines`integeroptionalmin 1max 8 How many lines of headline room to HOLD, whether or not the text fills it. A shorter headline is centred in the reserved space rather than leaving all the slack beneath it, and the space is held so every panel in the strip lands on the same line. Omit to let the caption be exactly as tall as its text. Set for you by style.layout. - `band`stringoptional Put this caption in the BOTTOM band, growing upward from the lower margin, instead of the default top band. This is how a headline sits UNDER the device (see the top-bleed layout). The caption stays auto-placed — it still reflows and still stacks; use this rather than pinning an anchor, which would freeze it. one of`bottom` - `bandInset`numberoptionalmin 0max 0.5 Inset of the auto caption band from its own panel edge, as a 0–0.5 fraction of panel height. The top band measures down; the bottom band mirrors it upward. Default 0.06. - `lineHeight`numberoptionalmin 0.5max 3 Line spacing as a multiple of font size, 0.5–3. Default 1.2. - `bandCenter`numberoptionalmin 0max 1 Horizontal reference of the auto caption block as a 0–1 fraction of panel width. Text alignment decides which edge sits there. Default 0.5. - `text`stringoptionalat most 200 characters The headline — per-RENDER input, never stored in a look. - `subtitle`stringoptionalat most 300 characters Optional subtitle under the headline — also per-render input. 2`null`
- `useSavedLook`booleanoptional If true, style the strip with the project's saved look (ignored when `look`/`style` is given).
- `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.
- `version`integeroptionalmin 1 Render a specific saved look version of the project (implies the saved look). Omit = the project's HELD version if one is held (hold_look), else the latest saved look.
- `bundleId`stringrequired Your app’s reverse-DNS bundle identifier, e.g. com.acme.app. Pre-fills the fastlane Deliverfile ONLY — it is never sent to any store from here.
- `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).
- `locales`arrayoptionalat least 1 itemat most 40 items Emit ONE multi-locale bundle: render (or, with `panels`, just package) each listed App Store locale and lay them out as fastlane/screenshots/<locale>/ folders in a single zip. Screenshot/panel entries may vary per locale via { "locales": … }; a plain entry — or a missing locale variant — falls back to the en-US variant, so a locale with no screens of its own ships the fallback pixels in its own folder (correct for fastlane). With `screenshots` this renders once PER locale (slow — prefer per-locale render_strip calls with output:"urls", then compose here with per-locale `panels`). A single-entry list behaves exactly like `locale`. Caption text is per-RENDER input: in this one-call form the same style.captions apply to every locale, so for localized captions use the render-per-locale-then-compose flow. each item string
- `projectName`stringoptional A name for the bundle (used for the zip filename + any share link). Default "shotops".
- `share`booleanoptional Also create an unguessable, 14-day share link to a landing page (preview + bundle download + fastlane how-to), attributed to your account. Default false.
- `createProject`booleanoptional COMPATIBILITY INPUT: also save this render as an editable ShotOps project (returns projectId + an openUrl). Set it ONLY when the user explicitly asked for both the bundle and a saved project — never infer it from a delivery request, and prefer save_project, which is the normal persistence door. Pass an existing `project` id to update that project instead of creating a new one. Full-res only — not allowed with preview:true. The record embeds no image bytes; a token-backed local save may separately upload private source refs so the project reopens with its screenshots.
- `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`
- `sourceDir`stringoptional Advanced: the on-disk folder these screenshots were read from (only meaningful when they came from local { path } entries on the SAME machine). When set, the saved project remembers this folder as its screen source (kind: "local-path") instead of just filenames. Used by the local stdio bridge — most callers should omit it.
- `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`, `panels`, 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`one of 2 shapesoptional Screenshots in App Store display order (1–10). Preferred common form: a FLAT array, where each entry becomes its own App Store panel, e.g. [{ "ref": "first" }, { "ref": "second" }]. Advanced multi-phone form: a NESTED array of slots, where several entries inside one slot are composited into that single panel, e.g. [[{ "ref": "left" }, { "ref": "right" }], [{ "ref": "third" }]]. Each screenshot entry is EITHER an inline base64 PNG string (capped at 3MB of image data — a full-resolution screenshot is 3-4MB and will NOT fit; use a ref for anything real), OR { "ref": "..." } from request_screenshot_upload, OR { "url": "https://..." }, OR (local stdio mode only) { "path": "/abs/or/relative/path.png" } to read a file straight off disk, OR { "locales": { "<locale>": <any of those> } } to vary that screenshot per App Store locale (missing locales fall back to the en-US variant). Prefer ref/url for real screenshots so the bytes never transit this conversation. Required unless `panels` (pre-rendered refs) is given instead. 1`array` Preferred: one screenshot entry per App Store panel, in display order. each item one of 5 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). 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). 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). 5`locales` Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render_strip picks the top-level `locale`'s variant; emit_bundle with `locales` renders/packages every listed locale. A locale with no variant of its own falls back to the en-US variant (else the first declared), so you can localize only some screenshots. - `locales`objectrequired 2`array` Advanced: one nested slot per panel; put several screenshot entries in a slot for a multi-phone panel. each item array One App Store slot: 1–6 screenshots. Several entries = several phones composited into that one slot. each item one of 5 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). 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). 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). 5`locales` Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render_strip picks the top-level `locale`'s variant; emit_bundle with `locales` renders/packages every listed locale. A locale with no variant of its own falls back to the en-US variant (else the first declared), so you can localize only some screenshots. - `locales`objectrequired
- `panels`arrayoptionalat least 1 itemat most 10 items Pre-rendered panels, packaged into the bundle WITHOUT rendering again — either { "ref": "…" } from a prior render_strip `output: "urls"` call (hosted), or (local stdio mode) { "path": "/abs/panel.png" } straight off disk so the free local tier can package a two-pass render. Mutually exclusive with `screenshots`; one of the two is required. An entry may also be { "locales": { "<locale>": { "ref" | "path" } } } to vary that panel per locale (see `locales`). each item one of 3 shapes 1`ref` - `ref`stringrequiredat least 1 character 2`path` (local stdio mode only) a pre-rendered panel PNG read straight off disk: { "path": "/abs/or/relative/panel-01.png" }. Mirrors a screenshot { path } — rejected on the hosted server. Lets the free local tier PACKAGE a render with no account / no upload refs. - `path`stringrequiredat least 1 character 3`locales` Per-locale variants of ONE pre-rendered panel: { "locales": { "en-US": { "ref": "…" }, "de-DE": { "ref": "…" } } }. Each variant is a { "ref" } or (local) { "path" }. Used with `locales` to compose a multi-locale bundle from per-locale render_strip results without re-rendering; a missing locale falls back to the en-US variant (else the first declared). - `locales`objectrequired

## Result

- `ok`booleanrequired
- `filename`stringoptional
- `bundleId`stringoptional
- `panelCount`numberoptional
- `output`stringoptional one of`inline``urls`
- `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.
- `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.
- `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.
- `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
- `zipUrl`stringoptionalformat uri
- `zipRef`stringoptional

## Example

That request maps to this call:

```
{
  "name": "emit_bundle",
  "arguments": {
    "bundleId": "com.tidebook.app",
    "project": "Tidebook 2.4 launch",
    "locales": [
      "en-US",
      "de-DE"
    ],
    "output": "urls"
  }
}
```

A zip inline or as a download URL, the file list inside it, and — if you asked for one — a share link with an explicit expiry.

## Access and cost

- Needs an account. Not available to an anonymous hosted caller.
- Every form of this call is production output, compose-only included. It needs an active Pro trial or Pro, and the decision is taken before the renderer runs and before any charge.
- Re-zipping panels a previous render already produced does not render again and does not charge again.
- A form that has to render first charges the hosted full-resolution price of 4 cloud credits per panel. Local rendering is free.

## When it refuses

- **The reply says production output is not available.**
  No zip exists yet and nothing was charged. Call account_status, take the step it names, and retry. There is no preview form of this call — a bundle is always store-ready output.

- **A locale folder came back with fallback captions.**
  That locale has no caption text of its own, so en-US was used. Add the caption words for that locale to the project, or pass them for this call.

- **The zip is too large to return inline.**
  Ask for a URL instead. The bytes are identical; only the transport changes.

## Where this fits

- [Emitting the bundle at the end of a first strip](https://shotops.dev/docs/mcp/workflows/first-strip)
- [One bundle carrying every locale folder](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.

Export screenshot bundle

Deliver the finished store screenshots as one file the user can actually upload: a `fastlane deliver`-ready zip holding the screenshots, a pre-filled Deliverfile that is screenshots-only and never submits, and a README. Three ways in, same zip out — render the strip here from `screenshots`; re-zip pre-rendered `panels` refs from a prior render_strip `output: "urls"` call (no re-render, near-instant); or name a saved `project` alone and it packages the screenshots that project already holds, with nothing attached to this conversation. `locales: [...]` emits ONE localized bundle with a fastlane/screenshots/<locale>/ folder per language, so every language uploads in a single fastlane run — pair it with per-locale { "locales": … } screenshot/panel entries, or with a `project`, which supplies each locale's own caption words. Returns the zip `inline` or as a `url` for large payloads, and can mint a 14-day share link to a landing page. Bundling is not saving: `createProject` is a compatibility input kept for older callers, so set it only when the user explicitly asked for BOTH a bundle and a saved project — never infer it from a delivery request, and use save_project for an ordinary save. YOU upload the zip with your own fastlane: this server never touches an Apple or store credential, never creates an App Store version, and never submits for review.
