---
title: "Call posts.save_variants"
method: POST
path: "/api/v1/tools/posts.save_variants"
tags: ["posts"]
---

# Call posts.save_variants

`POST /api/v1/tools/posts.save_variants`

## Request

- Base URL: `https://app.goosybear.ai`
- URL: `https://app.goosybear.ai/api/v1/tools/posts.save_variants`
- Auth: HTTP bearer

Save the version written for each destination of a post — 'save these captions for Instagram and LinkedIn', 'here's the LinkedIn version of this post'. It stores the words you send; it writes none itself. A destination must already be on the piece, each version must carry its words, and a destination that already published refuses, because its copy is the record of what went out. It saves the draft only; nothing is published. Not for the piece's own copy — that is `posts.api_fill_step`.

Every result names the workspace it ran in. Say which workspace the answer is about. When the account has more than one workspace and none is selected, this tool refuses with `workspace_ambiguous` and lists the choices — offer them, never pick one.

## Headers

- `x-request-id` string
- `Idempotency-Key` string, required

## Request body

- object
  - `piece_id` string, uuid, required: The piece's id, as posts.create_pieces, content.create_request, content.status or schedule.read return it.
  - `variants` object[], required: Every destination's version, in ONE call. Each one must carry the words — a destination with only its name on it is refused, because there is nothing to put on the piece.
    - `platform` string, required: The destination this version is for. Must already be elected.
    - `caption` string, nullable: This destination's version of the copy — what actually posts there.
    - `description` string, nullable: The longer body, for destinations that have one (a YouTube description, a Google Business post). Omit where the destination only has a caption.
    - `publish_at` string, nullable: This destination's OWN time, ISO 8601 UTC — a per-platform offset from the piece's anchor. Omit unless the brief asked for one; the piece's own time covers every destination that has none.
    - `aspect_ratio` string, nullable: Override the managed default aspect for this destination. Omit unless the operator asked — the default is the managed configuration and is nearly always right.
    - `hashtags` string[], nullable: The tags going out with THIS destination's version. Write them WITHOUT the leading # — the surface adds it, so a stored one doubles it. One word each, no spaces.
    - `alt_text` string, nullable: What the creative SHOWS, for a reader who cannot see it — the subject, what they are doing, the setting. Describe the image, never repeat the caption.
    - `first_comment` string, nullable: The comment posted immediately behind the piece, where the destination has the convention. Omit on destinations that do not.
    - `location_label` string, nullable: The place this post is tagged at, as a human label ("Northwind Trading Co. · Portland, OR"). Never invent one the operator has not given you.
    - `tagged_handles` string[], nullable: The accounts tagged in this destination's version, WITHOUT the leading @. Only accounts the operator named — a tagged account is a claim about someone else.
    - `story` object, nullable: Only on a stories piece: every frame of this destination's story, in order, plus when it expires, the highlight it is saved to and who may reply. Including this REPLACES the whole rail.
      - `frames` object[], required: EVERY frame of this story, in order — the first entry is the frame a viewer sees first. This REPLACES the whole rail: any frame you do not list is removed.
        - `text_sticker` string, nullable: The white pill laid over this frame's picture. A short line, not a caption — omit it where the picture speaks for itself.
        - `link_sticker_url` string, uri, nullable: Where this frame's link sticker sends a viewer. Only a destination the operator named.
        - `link_sticker_label` string, nullable: The words ON the link pill ("See the menu"). Ignored without link_sticker_url — a label with no destination is a pill that goes nowhere.
        - `music_label` string, nullable: The track as the platform NAMES it ("Golden Hour · JVKE"). Only a track the operator picked — never one you chose for them.
        - `alt_text` string, nullable: What THIS frame's picture shows, for a reader who cannot see it. Per frame — three frames are three different pictures.
        - `poll` object, nullable: The poll sticker on this frame. A question with two to four options; anything less is a question, not a poll.
          - `question` string, required
          - `options` string[], required
        - `mention_handles` string[], nullable: Accounts @-mentioned ON this frame, WITHOUT the leading @ — the surface adds it. Only accounts the operator named.
        - `duration_seconds` integer, nullable: How long this frame sits on screen. Omit for the platforms' own 5-second still.
      - `expires_at` string, nullable: When the story stops being visible, ISO 8601 UTC. Normally 24 hours after it posts; omit unless the operator said otherwise.
      - `highlight_name` string, nullable: The highlight the story is saved into after it expires. Only one the operator named.
      - `replies_mode` 'everyone' | 'followers' | 'off', nullable: WHO MAY REPLY to the story. Omit to leave the account's own default — never guess it.
  - `idempotency_key` string: Deprecated on REST: send the `Idempotency-Key` header instead. Still accepted until 2026-12-31; if both are sent they must match.
  - `workspace` string: Which workspace to run in — its slug. Omit to use your default. With more than one reachable workspace and no default, the call is refused and the choices are listed.

## Response `200`

The call was admitted and dispatched. `ok` says whether the tool succeeded — a refusal the tool itself produced is still a 200, exactly as it is a successful JSON-RPC result over MCP.

- union
  - object: The tool ran and answered.
    - `ok` true, required
    - `piece_id` string, required
    - `saved` string[], required
    - `refused` object[], required
      - `platform` string, required
      - `code` string, required
      - `message` string, required
    - `replayed` boolean, required: True when this is the first result of an earlier call with the same idempotency_key, returned without running anything again.
    - `workspace` string, required: The slug of the workspace this call ran in.
    - `working_in` object, required: Which workspace this call ran in, and how that was decided. Present on every workspace-scoped result.
      - `label` string, required: The chip words — e.g. "working in: Acme Main".
      - `note` string, required: Why this workspace — e.g. "your default", "this call only".
      - `workspace` string, required: The resolved workspace's slug.
      - `source` 'call-override' | 'key-pin' | 'stored-default' | 'sole-reachable', required: Which rung of the R7 precedence resolved the workspace.
  - ToolRefusal: A call that was admitted and dispatched, and did not succeed.
    - `ok` false, required
    - `code` string, required: The typed reason — e.g. `workspace_ambiguous`, `invalid_arguments`, or the tool's own failure code.
    - `message` string, required: A sentence a person can act on.
    - `workspaces` string[]: On a workspace refusal: the slugs this key may pass as `workspace`.
    - `issues` object[]: On `invalid_arguments`: which fields failed, and why.
      - `path` string, required
      - `message` string, required
    - `path` string: On an authoring refusal: where in your call's input it points, e.g. `steps.<key>.params.<field>`.
    - `fix` string: On an authoring refusal: the change to your call that would pass.
    - `instruction` string: On an authoring refusal: what to do next with it.

## Other responses

- `400`: The request could not be used: the body is present but not a JSON object (`tenant_api.malformed_body`); it is a top-level array carrying more messages than this endpoint accepts (`tenant_mcp.batch_too_large`); or the `Idempotency-Key` header is malformed (`tenant_api.idempotency_key_invalid`), disagrees with the body's `idempotency_key` (`tenant_api.idempotency_key_conflict`), or was sent to an action that cannot replay a retry (`tenant_api.idempotency_unsupported`). Nothing ran.
- `401`: No bearer credential, or one that did not resolve. Deliberately ONE undifferentiated answer for absent / unknown / revoked / expired, so the endpoint is never an oracle over the key space.
- `403`: The credential is good but the surface is not open to it — the platform or account API switch is off, or the holder lacks the `api.access` capability. The `code` says which.
- `404`: No such tool. The body names no tool and is identical for every unrecognised name.
- `429`: Over this key's per-minute cap. Carries `Retry-After` and `error.retry_after_seconds`.

---

[API](https://skmtc.dev/goosybear/apis/goosy-bear-api.md) · [All operations](https://skmtc.dev/goosybear/apis/goosy-bear-api/llms.txt) · [OpenAPI document](https://skmtc.dev/goosybear/apis/goosy-bear-api/revisions/0e033701d3f7?raw)
