Api Drafts And Scheduling

Drafts and scheduling

These routes change a real Bluesky account, so they all need a read and write key. Scheduling additionally needs the scheduling feature on your plan — check features in GET /me/.

One JSON shape covers both a single post and a thread:

{"posts": [{"content": "first"}, {"content": "second"}], "tags": ["launch"]}

One entry is a single post. Several make a thread, wired in order, with the first as the root.

Image upload is not in this version.

Drafts

Route Method Scope
/drafts/ GET read
/drafts/ POST write
/drafts/{id}/ GET read
/drafts/{id}/ PATCH, DELETE write
/drafts/{id}/reply/ POST write
/drafts/{id}/schedule/ POST write
/drafts/{id}/publish/ POST write
/drafts/export/ GET read
/drafts/prompts/schedule/ GET read

POST /drafts/ returns 201 with {"root_id": 88, "ids": [88, 89], "posts": [...]}.

PATCH accepts content and tags. Tags are given as plain names and created if they do not exist.

Another account's draft returns 404, not 403 — the same behaviour as the rest of the app. The one exception is POST /drafts/{id}/reply/, which answers 403 forbidden; see below.

Growing a thread after the fact

POST /drafts/{id}/reply/ appends replies to a draft that already exists:

{"content": "and the follow-up"}

or, for several at once, the same entry shapes POST /drafts/ accepts:

{"posts": [{"content": "second"}, "third"]}

Returns 201 with {"root_id", "ids", "posts", "thread"} and honours Idempotency-Key.

This exists because thread structure could previously only be set at creation time. Drafts get written one at a time, as ideas land, and the decision to chain two of them almost always arrives after both exist. The workaround was to delete and re-POST the whole thread, which threw away the draft ids, their tags, their images and created_at — and broke anything holding those ids.

It appends to the end of the thread, not directly after the draft you name. A Bluesky thread is a line, so replying to the third post of five puts the new post sixth. Any member of the thread is an equally valid target. Pass "after": <draft_id> to insert at a point instead; everything after it shifts down and order stays contiguous.

A standalone draft becomes the root of a new thread. The root keeps root_id: null and parent_id: null — it does not point at itself — and replies inherit the thread's tags, so a tag-filtered export still holds the whole thread.

PATCH /drafts/{id}/ still cannot re-parent a draft. It accepts content and tags, and nothing else; this route is the only way to change thread structure after creation.

Errors: thread_limit_reached (400, carrying existing, requested and limit) once the thread holds 25; bad_request for empty content, content over 300 characters, or an after outside the thread; forbidden (403) for a draft on another account.

Deleting part of a thread

Deleting a thread root removes the whole thread. Deleting a draft from the middle removes only that one and closes the gap — what followed is re-linked onto what preceded, and order is made contiguous again. The response carries deleted with the count.

This was a bug worth naming: parent and root are both cascading self-references, so deleting the second of three drafts silently deleted the third as well, and the API answered with a clean success. Nothing indicated data had been lost.

Publishing

POST /drafts/{id}/publish/ posts to Bluesky immediately and deletes the draft. It is the only irreversible route here, so it requires:

{"confirm": true}

That is a deliberate speed bump. It makes accidental publication a two-step decision for an agent rather than a one-step one, and it gives you an obvious place to put a human in the loop.

If the account has no Bluesky credentials stored, you get 409 bluesky_not_connected rather than a server error — sign in on the web app first.

The response carries the published post's uri and cid. Keep the uri if there is any chance you will want to reply to what you just posted: the draft is deleted, and that URI is the only handle left on it.

Exporting

GET /drafts/export/?ids=1,2,3&output=csv|json hands the drafts back as text, inside the usual envelope:

{"status": "success", "data": {
  "format": "csv", "filename": "drafts.csv",
  "content_type": "text/csv; charset=utf-8", "count": 12,
  "columns": ["id", "content", "..."],
  "content": "id,content,order,..."
}}

Write content to filename. Omit ids for every draft.

The parameter is output, not format. ?format=csv returns 404: Django REST Framework reserves format as a query parameter for its own content negotiation, so the request never reaches the route.

Columns are id, content, order, root_id, parent_id, tags, reply_to_uri, created_at, updated_at, image_urls, image_alt_texts. In CSV, the multi-valued ones are joined with |. The export is flat — one row per draft, replies included — with root_id and parent_id carrying the thread structure.

It comes back in the envelope rather than as a file download so that warnings survives: drafts_not_found when ids were skipped, parameter_clamped past 500, and thread_fragment when the chosen ids cover part of a thread but not all of it — listing the affected root_ids and the missing draft ids per thread. The id set is never silently expanded. An export that comes back short and looks complete is worse than an error, because it leaves with the user.

In the web app the same thing is a checkbox on each draft card and an Export selected button, with a copy-to-clipboard option alongside the download.

Asking for a schedule

GET /drafts/prompts/schedule/?ids=1,2,3 is free and needs no AI key. It returns system / prompt / combined like the other prompt routes, plus the config, timezone and slots it was built from.

The prompt asks for a posting schedule over those drafts, grounded in the account's saved autopilot cadence and its own best-performing time slots — each carrying a confidence, with instructions to ignore the ones that are none or low. It asks for:

{"schedule": [{"draft_id": 88, "scheduled_at": "2026-09-01T09:30:00+01:00",
               "reason": "..."}],
 "posts_per_day": 2, "notes": "..."}

Run it in your own agent, show the proposed times to the user and get their agreement, then apply it with one POST /drafts/{id}/schedule/ per entry.

That last step matters: /drafts/{id}/schedule/ takes the exact time you proposed, where POST /autopilot/queue/ picks its own random hour inside the configured window and re-lays out everything already pending.

In the web app this is the Copy prompt to schedule button on the drafts page — paste the prompt into any chat, get a plan back, apply it yourself.

Scheduled posts

Route Method Scope
/scheduled/ GET read
/scheduled/ POST write
/scheduled/{id}/ GET read
/scheduled/{id}/ PATCH, DELETE write
{"posts": [{"content": "..."}], "scheduled_at": "2026-09-01T14:30:00Z"}

GET /scheduled/ accepts ?status=pending|posted|failed|missed. Anything else is a 400 (invalid_parameter) rather than an empty list, so a typo cannot look like an empty schedule.

missed — posts we chose not to publish late

A post is missed when it was due but could not be published within PUBLISH_GRACE_MINUTES (an hour by default) of the time it was scheduled for. Almost always this means a service outage on our side.

It is not published afterwards, and that is deliberate. Without a window, an outage ends with every post from every hour we were down landing at once — a "good morning" post in the afternoon, a launch countdown after the launch, thirty posts in ninety seconds from an account that then looks compromised. A post that misses its moment is usually worse than one that never goes out.

Missed posts appear on the attention page in the app, and over the API under ?status=missed. The user can reschedule one, post it as it is, or discard it. Nothing happens to it automatically.

A scheduled_posts_missed notice on GET /me/ reports the count, so an agent learns about a backlog without having to ask.

Getting the time right

This is the easiest thing to get quietly wrong.

  • With an offset or a trailing Z, the time means exactly what it says.
  • Without one, it is read in the account's own timezone — the one GET /me/ reports. A naive 14:00 for a Lisbon account is 14:00 in Lisbon, not UTC.
  • The time must be in the future, or you get 409 scheduled_in_past.

Send an explicit offset whenever you can. It removes the ambiguity entirely.

Threads

Every post in a thread shares one scheduled time. Changing scheduled_at on any member moves the whole thread, and deleting the root deletes all of it.

Replying to a post that already exists

Because a thread shares one timestamp, a posts array cannot express "announce now, follow up in three hours" — and it cannot attach anything to a post that was not created in the same call.

Both POST /scheduled/ and POST /drafts/ take one of two optional fields alongside scheduled_at:

{"posts": [{"content": "and here is the link"}],
 "scheduled_at": "2026-09-01T13:00:00Z",
 "reply_to_uri": "at://did:plc:.../app.bsky.feed.post/abc"}
  • reply_to_uri — any post on Bluesky, as an at:// URI or a bsky.app permalink. Yours or someone else's, scheduled here or typed into the app.
  • reply_to_scheduled_id — another of your own scheduled posts, which may not have published yet.

One or the other, never both. The reply must come strictly after its target, and PATCH refuses a move that would reorder the two in either direction. Autopilot leaves reply-linked posts alone entirely, because it chooses a random hour inside your window and would happily put the follow-up first.

reply_to_uri is checked against Bluesky when you send it, so a URI that does not exist is a 400 immediately rather than a post that quietly never goes out. Get a URI from the uri field on any posted item in GET /scheduled/, or from the POST /drafts/{id}/publish/ response; it is null until the post has actually gone out.

If the target has not published by the time the reply falls due, the reply stays pending and retries on later runs. It stops waiting once it is itself past the grace window, at which point it is marked missed like anything else that went unpublished for too long. It is never published without a root — a follow-up with no parent is not something you can take back.