Reference

save_project

Keep the strip: save it as an editable ShotOps project with a URL to hand back.

View as Markdown

What it does

  • Writes
  • Destructive
  • Account required

The persistence step, and it happens when the user asks for it. It builds a structure and look record rather than pixels, so it is fast and never hits a render timeout, and it returns a URL the user opens in the web app. Omitting the project id creates a new project; passing one updates that project in place. Reach for it once someone wants the work preserved, shared, reused or kept editable — a request for images or a bundle is not that, and a render already told them nothing was saved and offered this call. Hand it an App Store link, app ID or bundle ID as appListing and the project takes the real app’s name, bundle ID, language list and iPhone device set instead of a typed guess.

You can ask: “Save this Tidebook strip as a new project I can reopen later.”

Annotated destructive because passing an existing project id overwrites that project’s structure. Creating a new one is not destructive; the annotation covers the worst case, as the MCP spec asks.

Input

  • screenshotsarrayrequiredat least 1 itemat most 10 items

    Screenshots, one per panel in order: { "ref" }, { "url" }, { "path" } (local only) or small base64, each with an optional "name" (original filename). Or { "fromProject", "name" }: a screenshot another project in this account holds.

    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.

  • projectstringoptionalat least 1 character

    Project id or exact name to update. Omit to create a new project.

  • projectNamestringoptional

    Name for a new project.

  • styleobjectoptional

    Layout, captions, background, shotLook. Fields: describe_look.

  • lookobjectoptional

    A look from read_look. Default for an update: the project's own.

  • outputsarrayoptionalat least 1 itemat most 2 items

    Devices the project targets: iphone-6-9, iphone-6-5.

    each item

    string

    one ofiphone-6-9iphone-6-5

  • localestringoptional

    Locale of the caption words. Default en-US.

  • appListingstringoptionalat least 1 characterat most 500 characters

    App Store URL, app ID or bundle ID to link; the project takes its name, languages and devices.

What the server actually accepts

The published schema above deliberately accepts a wider shape for screenshots, project, projectName, style, look, outputs, locale, appListing, 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.

  • screenshotsone of 2 shapesrequired

    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. NOTE: save_project does not render and never fetches image bytes. It uses each slot's SHAPE (phones per panel), each entry's name (source filename, for re-load matching) and each { "ref" }, per-locale ones included, which it records on that entry's own frame so the project reopens with that screenshot. A ref that is not an upload under this account refuses the whole save, naming it, and nothing is written. Pass the same entries you rendered with (esp. their names). To reuse a screenshot another project in this account holds, pass { "fromProject": "<id or name>", "name": "<its filename>" } — nothing is uploaded.

    1array

    each item

    one of 6 shapes

    1string

    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.

    2ref

    A ref returned by request_screenshot_upload — resolved server-side, never re-sent inline.

    • refstringrequiredat least 1 character

    • namestringoptionalat 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).

    3url

    An https URL to a PNG or JPEG — fetched server-side, converted to PNG when needed (no redirects, ~20MB cap).

    • urlstringrequiredat least 1 character

    • namestringoptionalat 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).

    4path

    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.

    • pathstringrequiredat least 1 character

    • namestringoptionalat 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).

    5locales

    Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render picks the top-level locale's variant; a 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.

    • localesobjectrequired

    6fromProject

    A screenshot another project in this account holds: { "fromProject", "name" }. Nothing is uploaded.

    • fromProjectstringrequiredat least 1 character

      Id or exact name of a project in this account that holds the screenshot.

    • namestringrequiredat least 1 characterat most 200 characters

      The screenshot's filename in that project, as read_project lists it.

    2array

    each item

    array

    each item

    one of 6 shapes

    1string

    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.

    2ref

    A ref returned by request_screenshot_upload — resolved server-side, never re-sent inline.

    • refstringrequiredat least 1 character

    • namestringoptionalat 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).

    3url

    An https URL to a PNG or JPEG — fetched server-side, converted to PNG when needed (no redirects, ~20MB cap).

    • urlstringrequiredat least 1 character

    • namestringoptionalat 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).

    4path

    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.

    • pathstringrequiredat least 1 character

    • namestringoptionalat 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).

    5locales

    Per-locale variants of ONE screenshot: { "locales": { "en-US": <entry>, "de-DE": <entry> } } (each variant is an inline base64 / ref / url / path entry). render picks the top-level locale's variant; a 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.

    • localesobjectrequired

    6fromProject

    A screenshot another project in this account holds: { "fromProject", "name" }. Nothing is uploaded.

    • fromProjectstringrequiredat least 1 character

      Id or exact name of a project in this account that holds the screenshot.

    • namestringrequiredat least 1 characterat most 200 characters

      The screenshot's filename in that project, as read_project lists it.

  • projectstringoptionalat least 1 character

    Project id or exact name. Omit for the most recently edited project.

  • projectNamestringoptional

    Name for the project, when this call creates a new one. Default "ShotOps render".

  • styleobjectoptional

    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.

    • layoutstringoptional

      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). Leaves the headline full-width and centred. Character: [safe]. Bleeds: [none]. "bleed" — Room for a longer headline, device running off the bottom edge. What the market looks like. Reserves 4 headline lines (~72 characters). Leaves the headline full-width and centred. Character: [conventional]. Bleeds: [bottom]. "top-bleed" — Device running off the TOP edge, headline underneath it. Inverts the usual reading order. Reserves 4 headline lines (~72 characters). Leaves the headline full-width and centred. Character: [expressive]. Bleeds: [top]. "lean-right" — Device pushed off the RIGHT edge, headline in a column down the left. Reads as one continuous swipe. Reserves 2 headline lines (~26 characters). Restyles the headline into a narrower column. Character: [expressive]. Bleeds: [right]. "lean-left" — Device pushed off the LEFT edge, headline in a column down the right. The mirror of Lean right. Reserves 2 headline lines (~26 characters). Restyles the headline into a narrower column. Character: [expressive]. Bleeds: [left]. "swipe" — A third of the device carried into the next panel, headline full width above it. Only honest applied to every frame. Reserves 2 headline lines (~36 characters). Leaves the headline full-width and centred. Character: [expressive]. Bleeds: [right]. "corner-bleed" — Off the bottom AND the right at once, headline in a column down the left. The most opinionated panel here. Reserves 4 headline lines (~51 characters). Restyles the headline into a narrower column. Character: [expressive]. Bleeds: [bottom-right]. PRECEDENCE: the template expands FIRST into shotLook.deviceSize/shotLook.hOffset/shotLook.vOffset/shotLook.angle/shotLook.cameraPos/shotLook.roll and captions[].sizePt/captions[].maxWidth/captions[].reserveLines/captions[].align/captions[].boxAlign/captions[].padX/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. A slot of several phones ([left, right]) is placed as ONE group where the template puts one device. Nothing stores the template id: a saved project and read_look carry only the expanded values.

      one ofstandardbleedtop-bleedlean-rightlean-leftswipecorner-bleed

    • shotLookobjectoptional

      This individual screenshot/device’s styling — the SAME fields as style.shotLook.

      • anglestringoptional

        Camera preset. Default front.

        one offrontleftright

      • cameraPosone of 2 shapesoptional

        Manual camera position override; null/omit = use the angle preset.

        1x

        • xnumberrequired

        • ynumberrequired

        • znumberrequired

        2null

      • rollstring or numberoptional

        -45–45° clock-hand tilt. Default 0.

      • deviceSizestring or numberoptional

        -45–45° clock-hand tilt. Default 0.

      • hOffsetstring or numberoptional

        -45–45° clock-hand tilt. Default 0.

      • vOffsetstring or numberoptional

        -45–45° clock-hand tilt. Default 0.

      • materialstringoptional

        Device body. Default real.

        one ofrealclay

      • colorwaystringoptional

        Body colour (material "real"). Default blue. These are finish SLOTS — each device binds its own real finish. On iPhone: blue = Deep Blue, silver = Silver, orange = Cosmic Orange, ice = Glacier, red = Burgundy, black = Black. A slot a device does not bind falls back to its first finish — "green" is Pixel-only (Jade).

        one oforangeblueicesilverredblackgreencustom

      • customColorstringoptional

        Hex body colour when colorway is "custom".

      • finishstring or numberoptional

        -45–45° clock-hand tilt. Default 0.

      • clearcoatstring or numberoptional

        -45–45° clock-hand tilt. Default 0.

      • clayTonestringoptional

        Clay tone (material "clay"). Default grey.

        one ofgreywhitecharcoalcustom

      • clayCustomstringoptional

        Hex clay colour when clayTone is "custom".

      • flatScreenbooleanoptional

        Render the screen flat (no curvature). Default false.

      • glarebooleanoptional

        Screen glare. Default false.

      • lightingbooleanoptional

        Scene lighting. Default true.

      • reflectionsbooleanoptional

        Body reflections. Default false.

      • clipToFramebooleanoptional

        Clip this complete device composite to its owning frame. Default false (overflow allowed).

      • deviceFramebooleanoptional

        Render the 3D device frame around the screen. Default true. Set false for a flat rounded screenshot card with no device chrome — screenCornerRadius then sets its corner rounding, and roll still rotates the flat card exactly as it tilts a framed device.

      • screenCornerRadiusstring or numberoptional

        -45–45° clock-hand tilt. Default 0.

    • backgroundobjectoptional

      • modestringoptional

        One gradient across the strip, or a flat colour per panel.

        one ofgradientperPanel

      • stopsarrayoptionalat 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

        • colorstringrequired

        • atnumberrequiredmin 0max 1

      • anglenumberoptional

        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.

      • panelColorsarrayoptionalat most 10 items

        Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default.

        each item

        string or null

      • shadowbooleanoptional

        Drop shadow under the phones. Default true.

      • floorReflectionbooleanoptional

        Flipped, faded floor reflection under each phone. Default false.

    • captionsarrayoptionalat 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

      1one of 2 shapes

      1object

      • fontIdstringoptional

        Bundled font. Default inter.

        one ofintermanropepoppinsfrauncesspace-grotesk

      • sizePtnumberoptionalgreater 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.

      • colorstringoptional

        Text colour (hex). Default FFFFFF.

      • alignstringoptional

        Text alignment INSIDE the caption box. It never moves the box — boxAlign does. Default center.

        one ofleftcenterright

      • anchorobjectoptional

        Pins the caption by hand at a normalized 0–1 position on the panel: x is the LEFT edge of its maxWidth box and y the block top, whatever align is. A pinned caption no longer reflows or stacks, and boxAlign/padX/widthMode do not apply to it. Default {x:0.5, y:0.06}, which means auto-placed — prefer boxAlign to move an auto caption sideways.

        • xnumberoptionalmin 0max 1

        • ynumberoptionalmin 0max 1

      • boxAlignstringoptional

        Where the caption BOX sits in its band, like an auto-layout child: "left" keeps its left edge at padX, "right" keeps its right edge at 1 − padX, "center" centres it on the panel. Resizing the box never moves the aligned edge, so a left column narrowed with maxWidth stays put on the left. Default center.

        one ofleftcenterright

      • padXnumberoptionalmin 0max 0.5

        The band's side padding as a 0–0.5 fraction of panel width, on both sides: where a left box starts, where a right box ends, and what a fill box spans between. A centred fixed box ignores it. Default 0.07.

      • widthModestringoptional

        The box width: "fill" spans the band between the paddings (1 − 2 × padX) and ignores maxWidth; "fixed" makes maxWidth the width. Default fixed.

        one offillfixed

      • maxWidthnumberoptionalgreater than 0

        The caption box's FIXED width, the width its text wraps to, as a fraction of the panel, 0.1–1. Default 0.86. Ignored while widthMode is "fill". Outside that range it is clamped to it and the response says so.

      • reserveLinesintegeroptionalmin 1max 8

        The caption box's MINIMUM HEIGHT, in lines: how much 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.

      • bandstringoptional

        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 ofbottom

      • bandInsetnumberoptionalmin 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.

      • lineHeightnumberoptionalmin 0.5max 3

        Line spacing as a multiple of font size, 0.5–3. Default 1.2.

      • bandCenternumberoptionalmin 0max 1

        LEGACY — use boxAlign and padX instead. Still accepted: the centre of the caption box as a 0–1 fraction of panel width, read as the box it describes (a centre left of the middle is a left box padded to that edge). Ignored when boxAlign is also sent.

      • textstringoptionalat most 200 characters

        The headline — per-RENDER input, never stored in a look.

      • subtitlestringoptionalat most 300 characters

        Optional subtitle under the headline — also per-render input.

      2array

      each item

      object

      • fontIdstringoptional

        Bundled font. Default inter.

        one ofintermanropepoppinsfrauncesspace-grotesk

      • sizePtnumberoptionalgreater 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.

      • colorstringoptional

        Text colour (hex). Default FFFFFF.

      • alignstringoptional

        Text alignment INSIDE the caption box. It never moves the box — boxAlign does. Default center.

        one ofleftcenterright

      • anchorobjectoptional

        Pins the caption by hand at a normalized 0–1 position on the panel: x is the LEFT edge of its maxWidth box and y the block top, whatever align is. A pinned caption no longer reflows or stacks, and boxAlign/padX/widthMode do not apply to it. Default {x:0.5, y:0.06}, which means auto-placed — prefer boxAlign to move an auto caption sideways.

        • xnumberoptionalmin 0max 1

        • ynumberoptionalmin 0max 1

      • boxAlignstringoptional

        Where the caption BOX sits in its band, like an auto-layout child: "left" keeps its left edge at padX, "right" keeps its right edge at 1 − padX, "center" centres it on the panel. Resizing the box never moves the aligned edge, so a left column narrowed with maxWidth stays put on the left. Default center.

        one ofleftcenterright

      • padXnumberoptionalmin 0max 0.5

        The band's side padding as a 0–0.5 fraction of panel width, on both sides: where a left box starts, where a right box ends, and what a fill box spans between. A centred fixed box ignores it. Default 0.07.

      • widthModestringoptional

        The box width: "fill" spans the band between the paddings (1 − 2 × padX) and ignores maxWidth; "fixed" makes maxWidth the width. Default fixed.

        one offillfixed

      • maxWidthnumberoptionalgreater than 0

        The caption box's FIXED width, the width its text wraps to, as a fraction of the panel, 0.1–1. Default 0.86. Ignored while widthMode is "fill". Outside that range it is clamped to it and the response says so.

      • reserveLinesintegeroptionalmin 1max 8

        The caption box's MINIMUM HEIGHT, in lines: how much 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.

      • bandstringoptional

        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 ofbottom

      • bandInsetnumberoptionalmin 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.

      • lineHeightnumberoptionalmin 0.5max 3

        Line spacing as a multiple of font size, 0.5–3. Default 1.2.

      • bandCenternumberoptionalmin 0max 1

        LEGACY — use boxAlign and padX instead. Still accepted: the centre of the caption box as a 0–1 fraction of panel width, read as the box it describes (a centre left of the middle is a left box padded to that edge). Ignored when boxAlign is also sent.

      • textstringoptionalat most 200 characters

        The headline — per-RENDER input, never stored in a look.

      • subtitlestringoptionalat most 300 characters

        Optional subtitle under the headline — also per-render input.

      2null

  • lookobjectoptional

    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).

    • schemanumberoptional

      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.

    • panelPresetIdstringoptional

      Canvas size preset, e.g. r69 = 1320×2868. Default r69.

    • bgModestringoptional

      One gradient across the strip, or a flat colour per panel. Default gradient.

      one ofgradientperPanel

    • gradientStopsarrayoptionalat 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

      • colorstringrequired

      • atnumberrequiredmin 0max 1

    • gradientAnglenumberoptional

      Canonical gradient angle in degrees, clockwise from "to top" (CSS convention: 90 = right, 180 = down, the gradientDir:"vertical" equivalent). Paired with gradientStops.

    • gradientFromstringoptional

      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.

    • gradientTostringoptional

      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.

    • gradientDirstringoptional

      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 ofhorizontalvertical

    • panelColorsobjectoptional

      Per-panel flat colours (mode perPanel), keyed by panel id.

    • shadowbooleanoptional

      Drop shadow under the phones. Default true.

    • floorReflectionbooleanoptional

      Flipped, faded floor reflection under each phone. Default false.

    • panelBackgroundsobjectoptional

      Per-frame background overrides keyed by panel id (web-authored; agents rarely set these).

    • captionStylesobjectoptional

      Per-panel caption STYLE arrays keyed by panel id (styling only, no words — for caption text pass style.captions).

    • shotsarrayoptionalat 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

      • panelIdstringoptional

        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.

      • lookobjectoptional

        This individual screenshot/device’s styling — the SAME fields as style.shotLook.

        • anglestringoptional

          Camera preset. Default front.

          one offrontleftright

        • cameraPosone of 2 shapesoptional

          Manual camera position override; null/omit = use the angle preset.

          1x

          • xnumberrequired

          • ynumberrequired

          • znumberrequired

          2null

        • rollstring or numberoptional

          -45–45° clock-hand tilt. Default 0.

        • deviceSizestring or numberoptional

          -45–45° clock-hand tilt. Default 0.

        • hOffsetstring or numberoptional

          -45–45° clock-hand tilt. Default 0.

        • vOffsetstring or numberoptional

          -45–45° clock-hand tilt. Default 0.

        • materialstringoptional

          Device body. Default real.

          one ofrealclay

        • colorwaystringoptional

          Body colour (material "real"). Default blue. These are finish SLOTS — each device binds its own real finish. On iPhone: blue = Deep Blue, silver = Silver, orange = Cosmic Orange, ice = Glacier, red = Burgundy, black = Black. A slot a device does not bind falls back to its first finish — "green" is Pixel-only (Jade).

          one oforangeblueicesilverredblackgreencustom

        • customColorstringoptional

          Hex body colour when colorway is "custom".

        • finishstring or numberoptional

          -45–45° clock-hand tilt. Default 0.

        • clearcoatstring or numberoptional

          -45–45° clock-hand tilt. Default 0.

        • clayTonestringoptional

          Clay tone (material "clay"). Default grey.

          one ofgreywhitecharcoalcustom

        • clayCustomstringoptional

          Hex clay colour when clayTone is "custom".

        • flatScreenbooleanoptional

          Render the screen flat (no curvature). Default false.

        • glarebooleanoptional

          Screen glare. Default false.

        • lightingbooleanoptional

          Scene lighting. Default true.

        • reflectionsbooleanoptional

          Body reflections. Default false.

        • clipToFramebooleanoptional

          Clip this complete device composite to its owning frame. Default false (overflow allowed).

        • deviceFramebooleanoptional

          Render the 3D device frame around the screen. Default true. Set false for a flat rounded screenshot card with no device chrome — screenCornerRadius then sets its corner rounding, and roll still rotates the flat card exactly as it tilts a framed device.

        • screenCornerRadiusstring or numberoptional

          -45–45° clock-hand tilt. Default 0.

  • outputsarrayoptionalat least 1 itemat most 2 items

    The target DEVICES this project is for — iphone-6-9 (1320×2868) 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 it OVERRIDES the saved set for this call. Omit it and the project's saved outputs are used unchanged.

    each item

    string

    one ofiphone-6-9iphone-6-5

  • localestringoptional

    App Store locale, e.g. "de-DE" (default en-US). Labels the render, selects which { "locales": … } screenshot variants render, and, for a 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).

  • appListingstringoptionalat least 1 characterat most 500 characters

    Link this project to its App Store listing: an App Store URL ("https://apps.apple.com/us/app/duolingo/id570060128"), a numeric app ID ("570060128"), or a bundle ID ("com.duolingo.DuolingoMobile"). The server looks the app up and the created project takes its name, bundle ID, languages and iPhone device set. Anything YOU state wins: an explicit projectName, outputs or locale is applied as given and the app is still linked. On an EXISTING project it re-links that project and rewrites nothing else — the language list, the frames and every caption stay exactly as the designer left them. Use the full store URL when the app is listed outside the US storefront; a bare ID is looked up in the US. Needs an account — an unsigned local server refuses it. Not the same field as bundleId, which only pre-fills a fastlane Deliverfile.

Result

  • okbooleanrequired

  • projectIdstringoptional

    Absent on a pending local claim until it is opened.

  • openUrlstringoptional

  • statusstringoptional

  • countnumberoptional

  • outputsarrayoptional

    each item

    string

  • linkedAppobjectoptional

  • notestringoptional

    Advisories: fallbacks, clamps, downgrades. Never an error.

  • messagestringoptional

  • contractobjectoptional

    • contractVersionstringrequired

    • statusstringrequired

    • terminalbooleanrequired

Example

That request maps to this call:

{
  "name": "save_project",
  "arguments": {
    "screenshots": [
      {
        "ref": "uploads/you/tidebook/01-today.png",
        "name": "01-today.png"
      },
      {
        "ref": "uploads/you/tidebook/02-forecast.png",
        "name": "02-forecast.png"
      },
      {
        "ref": "uploads/you/tidebook/03-spots.png",
        "name": "03-spots.png"
      }
    ],
    "projectName": "Tidebook 2.4 launch"
  }
}

A project id and an openUrl to give the user. From an unsigned local server it returns an explicit seven-day pending claim instead — the user owns the project once they open the URL and sign in.

Access and cost

  • Needs an account on the hosted server. On local stdio an unsigned save is allowed and becomes a pending claim.
  • Free, on both doors. Saving spends no cloud credits and renders nothing.
  • An unsigned local save is the one local path that uploads pixels — the raw PNGs go to temporary private claim storage so the user can claim them. Ordinary local rendering and exporting upload nothing.
  • A Free account may own 2 projects. Pro is unlimited.

When it refuses

The reply says the project allowance is used up.
Delete a project in the web app, or upgrade. Nothing was saved.
A pending claim expires unclaimed.
The seven-day window closed and the temporary upload is gone. Save again and open the URL this time.
You passed a project id and it updated something the user did not expect.
Only pass an id when the user explicitly says the work belongs in that project. Omit it and a new project is created instead.
The reply says no App Store app matches the listing reference.
Nothing was saved. Find the app with search_app_listing and pass a result’s app ID, use the full store link for an app listed outside the US, or drop the reference and name the project yourself.

Where this fits

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.

Save as editable project

Save screenshots and styling as an editable ShotOps project the user can open in the web app. Call it only when the user wants to keep, share or keep editing the work. `project` updates that project; omit it to create one.