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 naive14:00for 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 anat://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.