# Bluesky Copilot API Everything an agent needs to drive [Bluesky Copilot](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 **An API key belongs to exactly one Bluesky account, and you never name a handle.** The account is decided by the key, not by anything you send. There is no `handle` parameter on any endpoint; if you send one it is ignored, and you will get the key owner's data back. If the user runs several Bluesky accounts, they hold one key per account. Call `GET /me/` with each key to learn which handle it drives. ## 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 Bluesky Copilot 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": {...}} 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. `Retry-After` says how long. | | `bad_request` | 400 | Fix the request; `message` says what is wrong. | | `not_found` | 404 | No such route or resource. | ## 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. ## 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", "timezone": "Europe/Lisbon", "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}} }} `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. ### 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`. - `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 `{"force": true}` to bypass the 15-minute freshness window. ### AI `GET /ai/prompts/suggestions/` — **free.** Returns `{system, prompt, combined, handle, post_count}`. `?topic=...` to steer it. 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. `POST /ai/prompts/rewrite/` — **free.** Body `{"text": "...", "instructions": "..."}`. Same three fields back. `POST /ai/suggestions/` — **write scope, spends the user's credit.** Body `{"topic": "..."}` (optional). 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": "..."}`. 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`. - `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. ### Scheduled posts - `GET /scheduled/` — read. `?status=pending|posted|failed`. - `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. - `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. 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/