# Kunpilot API Everything an agent needs to drive [Kunpilot](https://bskycopilot.com) on a user's behalf: their post analytics, AI post ideas grounded in their real history, drafts, and scheduling. Base URL: `https://bskycopilot.com/api/public/v1` ## The one rule that matters **You never name a handle.** There is no `handle` parameter on any endpoint; if you send one it is ignored. Who you are is decided by the key. A key belongs to a *person*, and a person connects several accounts across platforms — Bluesky and Threads, more than one of either. `GET /me/` lists every one of them with an `id`. Those ids are the only destinations any write will accept, so you can address any account the key owns and none that it does not. Say nothing about destinations and a post goes to the user's default group, which is what every key issued before this existed already did. ## Authentication Authorization: Bearer bskc__ Bearer only. There is no query-parameter form, deliberately: it would put the key in access logs and `Referer` headers. Users create keys at . A key is shown once, at creation, and is unrecoverable afterwards. Each key is either **read** or **read and write**. API access is part of the paid plan. ## Free vs. paid: read this before generating anything Two of these endpoints spend the user's own Anthropic or OpenAI credit. Two return the *exact prompt* those endpoints would send, with the user's real posts already interpolated, and cost nothing. **You are an LLM. Prefer the prompt.** Fetch it, run it yourself, and write the results back with `POST /drafts/`. That is free for the user, needs no AI key configured, and works with a read-only key. Only call `POST /ai/suggestions/` or `POST /ai/rewrite/` when the user explicitly asks Kunpilot to do the generating. | Endpoint | Cost | | --- | --- | | `GET /ai/prompts/suggestions/` | free, no AI key needed | | `POST /ai/prompts/rewrite/` | free, no AI key needed | | `POST /ai/suggestions/` | one call on the user's own provider key | | `POST /ai/rewrite/` | one call on the user's own provider key | ## Response envelope Success: {"status": "success", "message": "...", "data": {...}, "warnings": [...]} **Read `warnings`.** It is present whenever a request was honoured differently from how it was made — a clamped parameter, a sample smaller than the account, a time window that fell back. A response can be `"status": "success"` and still not be what you asked for; this array is how you tell. {"code": "parameter_clamped", "message": "limit was adjusted from 200 to 100.", "parameter": "limit", "requested": 200, "applied": 100} Codes: `parameter_clamped`, `sample_truncated`, `window_fallback`, `range_exceeds_store`, `drafts_not_found`, `thread_fragment`. Error: {"status": "error", "error": "", "message": "..."} The `error` codes are stable and safe to branch on. | Code | HTTP | What to do | | --- | --- | --- | | `unauthorized` | 401 | The key is missing, malformed, revoked or expired. Do not retry; tell the user. | | `api_access_required` | 403 | The account is not on the paid plan. The body carries `upgrade_url`. | | `insufficient_scope` | 403 | The key is read-only. Tell the user to create a read-and-write key. | | `feature_required` | 403 | The plan lacks a feature this route needs (e.g. scheduling). | | `no_ai_key` | 403 | No Anthropic/OpenAI key configured. The body carries `prompt_url` — use it and run the prompt yourself. | | `confirmation_required` | 400 | Publishing needs `{"confirm": true}`. Ask the user first. | | `bluesky_not_connected` | 409 | No Bluesky credentials stored. The user must sign in on the web app. | | `scheduled_in_past` | 409 | Pick a future time. | | `rate_limited` | 429 | Back off. The body carries `rate_limit` (`bucket`, `limit`, `remaining`, `window_seconds`) and `retry_after`; `Retry-After` and `X-RateLimit-Bucket` say the same on the headers. | | `bad_request` | 400 | Fix the request; `message` says what is wrong. | | `idempotency_key_reused` | 409 | You sent an `Idempotency-Key` you already used with a different body. Use a new key. | | `forbidden` | 403 | The resource belongs to another account. | | `thread_limit_reached` | 400 | The thread already holds 25 drafts. | | `not_found` | 404 | No such route or resource. | ## Know what you are looking at Every analytics response carries a `sample` block describing what the answer was computed from: "sample": {"n": 200, "total": 799, "truncated": true, "date_range": ["2025-01-04T...", "2025-03-30T..."], "last_synced_at": "...", "last_post_at": "..."} - `n` — posts actually analysed. `total` — posts the account really has. - **`truncated: true` means the conclusions came from part of the account.** Say so when you report them. `total: null` means we have never seen a profile count; that is not the same as complete. - `date_range` tells you how old the sample is. An account whose newest post is nine months old will still return a "best time to post". Counts are named unambiguously: `stored_post_count` (what we hold) and `total_post_count` (what the account has). `post_count` still exists and equals `stored_post_count`. Conclusions carry `confidence`: `none`, `low`, `medium`, `high`. **Do not report a `none` or `low` finding as fact.** `none` means the ranking was arbitrary — for example every post tied on engagement, so "best performing post" is whichever one the sort happened to put last. ## Storage limit We store at most **200 posts per account**, and page at most 10 times per sync. That is a deliberate cap, not a bug: the anonymous homepage demo analyses arbitrary handles, and without it the table would grow without bound. An account with more than 200 posts will always report `truncated: true`. To reach further back, use `GET /posts/live/`, which pages Bluesky directly and stores nothing. ## Rate limits Three per-key buckets, counted over a sliding one-hour window: general (600), AI (60) and write (120). Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Window`; a 429 also carries `Retry-After`. Honour it rather than retrying blindly. ## Retrying a write safely `POST /drafts/`, `POST /scheduled/` and `POST /autopilot/queue/` accept an `Idempotency-Key` header — any unique string you choose. Send one whenever you might retry: Idempotency-Key: draft-launch-thread-2026-08-22 A repeat with the same key returns the original response instead of acting again, and carries `Idempotent-Replay: true`. The same key with a *different* body is `409 idempotency_key_reused` — that means the key was reused by mistake, and answering with the old response would hide it. Without the header these routes stay at-least-once, so a network timeout is genuinely ambiguous. Records are kept for 48 hours. ## Endpoints ### GET /me/ Call this first, once per key. It tells you which account you are working on. {"status": "success", "data": { "handle": "alice.bsky.social", "display_name": "Alice", "accounts": [ {"id": 12, "platform": "bluesky", "handle": "alice.bsky.social", "status": "active", "can_post": true, "is_default": true}, {"id": 19, "platform": "threads", "handle": "alice.threads", "status": "active", "can_post": true, "is_default": true} ], "default_group": {"id": 3, "name": "All accounts", "account_ids": [12, 19]}, "groups": [{"id": 4, "name": "Business", "account_ids": [19]}], "timezone": "Europe/Lisbon", "last_post_at": "2025-03-30T09:12:00+00:00", "last_synced_at": "2026-08-22T20:00:00+00:00", "stored_post_count": 200, "total_post_count": 799, "scope": "write", "key": {"name": "Claude Desktop", "prefix": "a1b2c3d4", "last_used_at": "..."}, "features": {"scheduling": true, "api_access": true}, "ai_key": {"configured": true, "provider": "anthropic", "is_valid": true}, "rate_limits": {"default": {"limit": 600, "remaining": 598, "window_seconds": 3600}}, "notices": [] }} `accounts` is every account this key can post to. `can_post` is false for one that has been disconnected — it is still listed, because "reconnect it" and "it never existed" are different things to tell a user. `handle` at the top level still names the default account, unchanged, so older integrations keep working. `default_group.account_ids` is the answer to "where does this land if I say nothing". State it before scheduling, rather than letting the user find out after. `timezone` is not decoration. It is how a naive `scheduled_at` is interpreted, so read it before scheduling anything. `ai_key.configured` tells you whether the paid AI routes will work at all. If it is `false`, use the prompt routes. **Read `notices`.** Usually empty. When it is not, the account has something waiting on the user that they have not asked you about and would not know to mention: {"code": "scheduled_posts_missed", "message": "3 scheduled posts were never published, because they could not be sent within 60 minutes of the scheduled time...", "count": 3, "oldest_scheduled_at": "2026-08-21T09:00:00+00:00", "action": "List them with GET /scheduled/?status=missed, then reschedule..."} Raise it with the user before carrying on with what they asked for. Scheduling new posts on top of a pile of unpublished ones, without saying so, is the failure this exists to prevent. `notices` is **not** the same thing as `warnings`. A warning is about the call you just made; a notice is about the account. Codes: `scheduled_posts_missed`. ### Analytics — all free, no AI call - `GET /analytics/summary/` — best and worst performing posts, totals, and when the history was last refreshed. - `GET /analytics/best-time-to-post/` — ranked posting slots. `?weeks=1` how far back to look. `?limit=10` how many slots. `?detail=full` returns the whole grid instead of the ranking. (The parameter is `detail`, not `format` — `format` is reserved for content negotiation.) - `GET /analytics/posts/` — the stored post history. `?limit=` (max 100) `?offset=` `?order=posted_at|engagement|likes` `?since=` `?until=` (ISO 8601). Filters the **stored** set; asking for a range older than we hold returns a `range_exceeds_store` warning pointing at `/posts/live/`. - `GET /analytics/insights/` — the stored profile insight blob, or `{}`. Never generates; if empty, use the suggestions prompt. - `GET /analytics/growth/` — follower/following/post/like growth over time. `?interval=daily|weekly|monthly`. Computed from snapshots, so it is empty until at least two exist — `POST /analytics/sync/` records one. - `POST /analytics/sync/` — refresh the history from Bluesky. **write scope.** Body takes two independent options: - `force: true` — ignore the 15-minute freshness cache. - `depth: "recent" | "full"` — how far back to page. `recent` (the default) prefers the last week; `full` pages back to the 200-post storage limit. These are different questions. `force` alone re-runs the *same* shallow walk, so on an account that posts less than weekly you want `depth: "full"`. The response reports the `depth` it ran at and how many posts it reached. - `GET /posts/live/` — **write scope.** One page of the author feed straight from Bluesky, *not stored*. `?limit=` (max 100) `?cursor=`. Returns `{posts, next_cursor, has_more, stored: false}`. This is how you read past the 200-post storage limit; follow `next_cursor` until it is null. ### AI `GET /ai/prompts/suggestions/` — **free.** Returns `{system, prompt, combined, handle, post_count, requested_count, sample}`. `?topic=...` to steer it, `?count=` (1–20, default 5) for how many suggestions to ask for. The generate route takes the same `count`, so the two produce the same prompt — you should never need to edit the returned text. Run `prompt` with `system`, or just `combined` if you only have one input box. It asks for a JSON object with `post_suggestions` plus seven analysis keys with a fixed inner shape: `sentiment_analysis` {positive, negative, neutral}, `length_analysis` {min, max, optimal} **in characters**, `vocabulary` {high_performing, low_performing}, `tone_style` {casual, professional}, and `topics_genres` / `content_types` / `tips` as lists. Values outside that shape are dropped on ingest, so follow the example exactly. `POST /ai/prompts/suggestions/` — **free.** Same as the GET, with `{topic, count, context}` in the body. Use this rather than the GET whenever you have `context` to send: it can run to paragraphs, and a query string ends up in access logs. `POST /ai/prompts/rewrite/` — **free.** Body `{"text": "...", "instructions": "...", "context": "..."}`. Same three fields back. **`context` is how you stop it inventing things.** Both suggestion routes and both rewrite routes take an optional `context` string (up to 4000 characters; longer is clamped with a warning). Without it the model has only the topic and the account's own post history to work from, and when neither describes what the user actually wants to talk about it fills the gap — with a product name, a date, an outcome — and states it as fact. Put the real facts in `context` and they are what it draws on. If the user has told you what shipped, send it. `POST /ai/suggestions/` — **write scope, spends the user's credit.** Body `{"topic": "...", "count": 10, "context": "..."}` (all optional; count is 1–20). Returns `{handle, posts_analyzed, suggestions, insights}`. `GET /ai/suggestions/` — read. Lists suggestions already stored. `DELETE /ai/suggestions/{id}/` — write scope. `POST /ai/rewrite/` — **write scope, spends the user's credit.** Body `{"text": "...", "instructions": "...", "context": "..."}`. Returns `{text, original}`. ### Drafts - `GET /drafts/` — read. - `POST /drafts/` — write. Body: {"posts": [{"content": "first"}, {"content": "second"}], "tags": ["launch"]} One entry is a single post; several make a thread, wired in order. Returns `{root_id, ids, posts}` with 201. - `GET|PATCH|DELETE /drafts/{id}/` — PATCH accepts `content` and `tags`. **PATCH cannot re-parent a draft**: it will not move one into or out of a thread. Use `POST /drafts/{id}/reply/` for that. DELETE on a thread root removes the whole thread; on a middle draft it removes just that one and closes the gap. The response carries `deleted`. - `POST /drafts/{id}/reply/` — write. Body `{"content": "..."}` or `{"posts": [...]}` (same entry shapes as `POST /drafts/`). Returns `{root_id, ids, posts, thread}` with 201, and honours `Idempotency-Key`. **It appends to the end of the thread the target belongs to**, not directly after the draft you name — a Bluesky thread is a line. Reply to any member and the new posts land last. Pass `"after": ` to insert mid-thread instead; `order` stays contiguous either way. This is the only way to change thread structure after creation. Do not delete and re-POST to build a thread out of existing drafts: that discards the ids, tags, images and `created_at`, and breaks anything holding those ids. Errors: `thread_limit_reached` (400, carrying `existing`, `requested` and `limit`) when the thread is already at 25; `bad_request` for empty content, content over 300 characters, or an `after` outside the thread; `forbidden` (403) for a draft on another account. - `POST /drafts/{id}/schedule/` — write. Body `{"scheduled_at": "..."}`. - `POST /drafts/{id}/duplicate/` — write. Copies the draft and its tags. - `POST /drafts/{id}/publish/` — write. **Irreversible: it posts to Bluesky and deletes the draft.** Requires `{"confirm": true}`. Ask the user before sending this. Ever. The response carries `uri` and `cid` — keep the `uri` if you may want to reply to what you just posted. - `GET /drafts/export/?ids=1,2,3&output=csv|json` — read, free. Hands back the selected drafts as a file's worth of text: `{format, filename, content_type, count, columns, content}`. Write `content` to `filename` for the user. No `ids` means every draft. **The parameter is `output`, not `format`** — that name is taken by content negotiation and `?format=csv` will 404. Columns: `id, content, order, root_id, parent_id, tags, reply_to_uri, created_at, updated_at, image_urls, image_alt_texts`. In CSV the last three and `tags` are `|`-joined. - `GET /drafts/prompts/schedule/?ids=1,2,3` — read, **free.** Returns `{system, prompt, combined, drafts_included, config, timezone, slots}`. The prompt asks for a posting schedule over those drafts, grounded in the account's saved cadence and its own best-performing slots. **You are an LLM: run it yourself.** It comes back as `{"schedule": [{"draft_id", "scheduled_at", "reason"}], "posts_per_day", "notes"}`. Show the user the proposed times, get their agreement, and only then apply it with one `POST /drafts/{id}/schedule/` per entry. Never schedule a batch without asking first. ### Scheduled posts - `GET /scheduled/` — read. `?status=pending|posted|failed|missed|partial`. An unrecognised value is a 400, not an empty list. **`missed` means never sent.** The post was due, but could not be published within the grace window of the time it was scheduled for — almost always a service outage on our side. It is deliberately not published late: a post that lands hours or days after its moment is worse for the account than one that never lands. It waits on the attention page for the user to reschedule it, post it as-is, or discard it. Treat it as their decision, not yours. - `POST /scheduled/` — write. Body: {"posts": [{"content": "..."}], "scheduled_at": "2026-09-01T14:30:00Z"} Prefer an explicit offset or `Z`. A naive datetime is read in the account's own timezone — the one `GET /me/` gave you. Must be in the future. A thread shares one timestamp. **Saying where it goes.** Add `account_ids` *or* `group` — not both: {"posts": [...], "scheduled_at": "...", "account_ids": [12, 19]} {"posts": [...], "scheduled_at": "...", "group": "Business"} Both are accepted on `POST /drafts/{id}/schedule/` too, on identical terms. Every post in a thread goes to the same places. Omit both and the default group decides, exactly as before. Naming destinations **confirms** them: the post keeps that set for good, and will not pick up an account the user connects tomorrow. `targets_confirmed` on the response says whether that has happened. An id you do not own fails the whole request with `unknown_account`; a disconnected one fails with `account_revoked`. Neither is filtered out and reported as success — you will never get a 201 for a post going somewhere other than where you asked. The response carries `targets`, which is where it is actually going: "targets": [{"account_id": 19, "platform": "threads", "handle": "alice.threads", "status": "pending", "error_message": ""}] Read it back to the user. After publication each target carries its own status, so a post that reached Bluesky and failed on Threads says so per destination rather than collapsing to one verdict — that is what the `partial` post status means. - `GET|PATCH|DELETE /scheduled/{id}/` — PATCH accepts `content` and `scheduled_at`; changing the time on any member moves the whole thread. DELETE on a root deletes the whole thread. **Replying to a post that already exists.** A `posts` array makes a thread that shares one timestamp. To answer something *later* — "announce at 09:00, reply with the link at 13:00" — send one post and one of these alongside `scheduled_at`, on `POST /scheduled/` or `POST /drafts/`: - `reply_to_uri` — any post on Bluesky. An `at://` URI or a bsky.app permalink; yours or somebody else's, scheduled by us or not. Checked when you send it, so a bad URI is a 400 now rather than a post that quietly never goes out. - `reply_to_scheduled_id` — another of *your* scheduled posts, which may not have published yet. It resolves at publish time. Give one, not both. The reply must be scheduled after its target, and both `GET /scheduled/` and the create response report `uri` (once published), `reply_to_uri` and `reply_to_scheduled_id`. **Take the `uri` off a posted item to reply to it later.** If the target has not published when the reply comes due, the reply waits and retries rather than going out rootless; after 24 hours it fails and says why. Autopilot never moves a reply-linked post. Scheduling routes also need the account's `scheduling` feature; check `features` from `GET /me/`. ### Autopilot Hand it a batch and it spaces the posts out for you, rather than picking times yourself. Also needs the `scheduling` feature. - `GET /autopilot/config/` — read. `{posts_per_day, start_hour, end_hour}`. - `PATCH /autopilot/config/` — write. Same fields. `posts_per_day` 1–5, hours 0–23. - `POST /autopilot/queue/` — write. Body: {"posts": ["one", "two", "three"], "posts_per_day": 2, "start_hour": 9, "end_hour": 18} The three config fields are optional and fall back to the saved config. Returns `{queued, last_scheduled_date, config}` with 201. Send the whole batch in one call — it lays the schedule out once. ## Not available Do not attempt these; they do not exist and retrying will not help. - Image upload. - Publishing arbitrary text without first creating a draft. - Follower/network analysis. - Template posts. - Reading or changing the user's Anthropic/OpenAI key. - Creating or revoking API keys. That is deliberate — a key cannot mint another key. Users manage keys at . ## A worked session # 1. Which account is this? curl -H "Authorization: Bearer $KEY" \ https://bskycopilot.com/api/public/v1/me/ # 2. Make sure the history is current. curl -X POST -H "Authorization: Bearer $KEY" \ https://bskycopilot.com/api/public/v1/analytics/sync/ # 3. When does this account do best? curl -H "Authorization: Bearer $KEY" \ "https://bskycopilot.com/api/public/v1/analytics/best-time-to-post/?limit=3" # 4. Get the prompt -- free -- and run it yourself. curl -H "Authorization: Bearer $KEY" \ "https://bskycopilot.com/api/public/v1/ai/prompts/suggestions/?topic=shipping" # 5. Write the results back as a draft. curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"posts":[{"content":"..."}],"tags":["ideas"]}' \ https://bskycopilot.com/api/public/v1/drafts/ # 6. Schedule it for the best slot, in the timezone /me/ reported. curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"scheduled_at":"2026-09-01T14:00:00Z"}' \ https://bskycopilot.com/api/public/v1/drafts/123/schedule/