> **Shapeless documentation index**
> Fetch https://shapelessai.com/llms.txt to discover every page before exploring further.
> Every page also answers Markdown: append `.md` to its path, or send `Accept: text/markdown`.

# Posts

Create a post, schedule it, drop it in the queue, attach media, add a first comment, and read every refusal the rail can answer with.

*Verified 2026-09-14. Source: https://shapelessai.com/docs/posts*

One route puts a post on a real account: `POST /api/posts`. It takes the post, checks it against
the platform's rules before it accepts it, and either publishes it inline or puts it on the
schedule.

## The body

```jsonc
{
  "connectionId": "<id>",          // required - which connected account
  "postText": "...",               // required - the post body
  "mediaKeys": ["..."],            // optional - media this account owns
  "documentTitle": "...",          // the post's title - YouTube requires one, LinkedIn PDFs use it
  "scheduledAt": "2026-09-20T09:00:00Z",  // optional - omitted or past = publish now
  "queue": true,                   // optional - instead of scheduledAt: the next free slot
  "settings": { },                 // optional - per-platform, see below
  "firstComment": "..."            // optional - posted as the first comment/reply
}
```

Needs the `publish` scope. `connectionId` comes from `GET /api/connections`.

## The three timings

**Now.** Omit `scheduledAt`, or send one in the past. The route publishes inline and answers with
the live URL, so you can print it.

```bash
curl -s https://shapelessai.com/api/posts \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"connectionId":"c_123","postText":"Shipped."}'
# {"id":"p_456","status":"published","publishedLocation":"https://www.linkedin.com/feed/update/..."}
```

```bash
shapeless posts create --to c_123 --text "Shipped."
```

**At a time.** Send `scheduledAt` as an ISO 8601 instant. Use an offset or `Z`; a bare local
datetime is ambiguous and we will read it as UTC.

```bash
curl -s https://shapelessai.com/api/posts \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \
  -d '{"connectionId":"c_123","postText":"Tuesday thought.","scheduledAt":"2026-09-22T07:00:00Z"}'
# {"id":"p_457","status":"scheduled","scheduledAt":"2026-09-22T07:00:00.000Z"}
```

```bash
shapeless posts create --to c_123 --text "Tuesday thought." --at 2026-09-22T07:00:00Z
```

**In the queue.** Send `queue: true` instead of `scheduledAt` and the post takes the next free
slot on that connection's posting times. Nothing collides: a slot another post already holds is
skipped. Sending both is a `400`; a connection whose every slot is taken for the next two months
is a `409`.

```bash
shapeless posts create --to c_123 --text "Next one out." --queue
```

Read and edit the slots per connection:

```bash
curl -s https://shapelessai.com/api/connections/c_123/queue -H "Authorization: Bearer $SHAPELESS_API_KEY"
# {"slots":["09:00","13:00","17:00"],"timezone":"UTC","next":"2026-09-15T09:00:00.000Z"}

curl -s -X PATCH https://shapelessai.com/api/connections/c_123/queue \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \
  -d '{"slots":["08:30","16:00"],"timezone":"Europe/Berlin"}'
```

`GET` needs `read`, `PATCH` needs `write`, and both answer the same shape. An account nobody has
configured starts on `["09:00", "13:00", "17:00"]` UTC, so the queue works before anyone touches
it. Slots are 1 to 12 `HH:MM` times, resolved through the IANA zone on each day, so they keep
meaning across daylight saving. `next` is `null` when every slot is taken for two months.

## The answer

```jsonc
{
  "id": "p_456",
  "status": "scheduled" | "published" | "failed" | "handoff",
  "scheduledAt": "2026-09-22T07:00:00.000Z",
  "publishedLocation": "https://...",   // present when it went out
  "error": "..."                        // present when it did not
}
```

A row read back with `GET /api/posts/[id]` also carries `firstComment`, `firstCommentUrl` (the
comment's own link once it landed) and `firstCommentError` (why it did not). The post going out
and its first comment landing are two events, so they are two fields.

`handoff` means the platform has no sanctioned API path for this post, so it is waiting for you
in the studio with everything prepared. It is not a failure and not a retry.

Poll one row with `GET /api/posts/[id]`, or the queue with `GET /api/posts?status=scheduled`.

## Media

`mediaKeys` are keys this account already owns - what an earlier job rendered, a brand asset under
`workspace-assets/`, or something you uploaded. A key belonging to another account is a `400`
naming it, never a post that publishes with no media.

```bash
shapeless assets upload ./carousel.pdf     # prints the media key
shapeless posts create --to c_linkedin --text "Ten lessons." \
  --media workspace-assets/<account>/carousel.pdf --title "Ten lessons from a year of shipping"
```

Kind comes from the extension, the same rule the engine uses: `.pdf` is a document,
`.mp4/.mov/.webm/.m4v` a video, everything else an image. `documentTitle` names a LinkedIn PDF
carousel. Counts and formats per platform are on [Platforms](/docs/platforms); breaking one is a
`422` before the row is ever created, never a post that dies at publish time.

## First comment

`firstComment` posts a second message under the first, from the same account, as soon as the post
lands. It is how a link gets shared without the link sitting in the body.

| Platform | Shape | Limit |
| --- | --- | --- |
| `linkedin`, `linkedin_page` | a comment | 1,250 characters |
| `x` | a reply in the thread | 280 characters |
| `bluesky` | a reply in the thread | 300 characters |

Any other platform is a `422` at creation - never a post that publishes and then quietly drops the
comment. `PATCH /api/posts/[id]` accepts `firstComment` too; `null` clears it. Whether it landed
is on the row afterwards as `firstCommentUrl` or `firstCommentError`.

```bash
shapeless posts create --to c_linkedin --text "We wrote up what we learned." \
  --first-comment "Full write-up: https://shapelessai.com/notes"
```

## Per-platform settings

`settings` is a free-form object passed through to the platform adapter. `GET /api/platforms`
carries a JSON Schema per platform, so an agent can fill it without hard-coding.

**TikTok** requires an explicit choice before anything may queue - TikTok's own content-sharing
rules, and we refuse rather than guess:

```jsonc
{
  "privacyLevel": "PUBLIC_TO_EVERYONE",  // or MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY
  "disableComment": false,
  "disableDuet": false,
  "disableStitch": false,
  "commercialContent": false,            // the disclosure toggle
  "brandOrganic": false,                 // "your brand" - promotional content
  "brandContent": false,                 // a paid partnership
  "isAigc": true                         // AI-generated; our posts default this on
}
```

**YouTube**: `{"privacyStatus": "public"}` (or `unlisted`, `private`). The video's title is not a
setting - it is `documentTitle`, and YouTube requires one. `titleRequired` in `GET /api/platforms`
is the field that says so.

**Reddit**: `{"subreddit": "startups", "title": "...", "url": "..."}`. One post goes to exactly
one community: whatever arrives is sanitized down to a single subreddit name, so a comma-separated
list can never fan out. `url` set makes it a link post; without an explicit `title` a text post
takes its first line.

Everything else takes no settings today, and `settingsSchema` says so with an empty object schema
rather than an absent field.

## Editing and cancelling

```bash
curl -s -X PATCH https://shapelessai.com/api/posts/p_457 \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \
  -d '{"postText":"Tuesday thought, shorter.","scheduledAt":"2026-09-22T08:00:00Z"}'

curl -s -X DELETE https://shapelessai.com/api/posts/p_457 -H "Authorization: Bearer $SHAPELESS_API_KEY"
```

`PATCH` needs `write` and works while the post is still scheduled. It also accepts `connectionId`
to move the post to another account on the **same** platform; a different platform is a `409`,
because the rules it was checked against no longer hold.

## Errors

Every refusal is a status and a message that names the rule. None of them are worth retrying
unchanged.

| Status | Code / shape | What happened |
| --- | --- | --- |
| `400` | `{error}` | Bad body: missing `connectionId` or `postText`, oversized text, a media key this account does not own, or `scheduledAt` and `queue` together. |
| `401` | `{error}` | Unknown or revoked key. |
| `403` | `{error}` | The key is missing the `publish` scope. |
| `402` | `{error, code: "free_daily_cap", limit: 10, day, resetsAt}` | A Free account already has 10 posts going out on that UTC day. |
| `409` | `{error}` | `queue: true` with every slot taken for two months, or a `PATCH` moving the post to a different platform. |
| `422` | `{error}` | A platform rule: too many images, images and a video together, a missing video, a first comment on a platform that has none, TikTok without its choices. The message names the rule. |
| `429` | `{error}` | Over the rate limit. See [Authentication](/docs/auth#rate-limit). |
| `503` | `{error}` | The service has no database. |

Worked examples:

```jsonc
// 402
{"code":"free_daily_cap",
 "error":"The Free plan posts 10 a day and 2026-09-22 (UTC) is full. Pick another day, or upgrade for unlimited posts.",
 "limit":10,"day":"2026-09-22","resetsAt":"2026-09-23T00:00:00.000Z"}

// 422
{"error":"Takes at most 4 images (this post has 7)."}
{"error":"Needs a video before it can publish."}
{"error":"Takes images OR a video in one post, never both - drop one kind."}
{"error":"instagram has no first comment: it is supported on LinkedIn, X and Bluesky."}
```

### The Free day

The cap counts **the UTC day a post goes out on**, not the day you created it. So a week planned
in advance is five a day, not five in total, and the refusal names the day that is full rather
than telling you to come back later. `day` is that date; `resetsAt` is its UTC midnight end.

Everything that puts a post on the rail is counted the same way - your own `POST /api/posts` and
approving a proposal both land a post on a day - so an agent cannot route around the cap by
proposing first. Paid plans have no cap.

The `402` is the one to build for. It is not an error in your code: it is the plan. Catch the
code, move the post to a day with room, or tell the human the account is on Free.

## The other half of the queue

The agent team also *proposes* posts. Those arrive as `status: "proposed"` and never go out on
their own:

```bash
shapeless posts list --status proposed
shapeless posts approve <id>      # proposed -> scheduled   [publish]
shapeless posts dismiss <id>
shapeless posts publish <id>      # out, now                [publish]
```

Approving counts against the Free day the post lands on, exactly as creating one does. See
[Jobs](/docs/jobs) for where proposals come from.
