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.
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
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.
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.
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.
Read and edit the slots per connection:
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
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.
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; 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.
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:
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
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. |
503 | {error} | The service has no database. |
Worked examples:
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:
Approving counts against the Free day the post lands on, exactly as creating one does. See Jobs for where proposals come from.
