# Bluesky image post API

Publish a single-image Bluesky post through an API or MCP server: the 1 MB image limit, alt text, the 300-grapheme caption, example requests, and errors with fixes.

**Beta:** you can connect a Bluesky account and post to it through the API in early access today. It hasn't been proven with a real production post yet. The `@turboyap/sdk` and `@turboyap/mcp` packages call the same API. [Test mode](https://turboyap.com/docs/test-mode) runs the same requests against a simulated Bluesky account.

## What a Bluesky image post is

A Bluesky image post is one image under up to 300 characters of text. Bluesky's API uploads the image as a blob (`com.atproto.repo.uploadBlob`), then creates the post with an `app.bsky.embed.images` embed. In Turboyap it's `type: "image"` with one media id.

Turboyap compresses the image under 1 MB, sends its aspect ratio so Bluesky can lay it out, sets its alt text from `alt_text`, and publishes.

## Limits

What Bluesky accepts for this post type. Turboyap's planner checks every one of these before a post is scheduled (the same data as [platform rules](https://turboyap.com/docs/platform-rules)), and each value links to the Bluesky documentation it comes from.

| Limit | Value | Source |
|---|---|---|
| Images per post | 1 | [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts) |
| Formats | JPEG, PNG, WebP, GIF (Turboyap converts anything else to JPEG) | [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts) |
| File size | 1 MB (1,000,000 bytes) per image | [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts) |
| Dimensions | Fits 2000×2000 (Turboyap resizes to fit) | [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts) |
| Aspect ratio | Any | [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts) |
| Caption | 300 characters, counted as graphemes (sent as `text`) | [Bluesky: post lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json) |
| Caption size | 3,000 bytes of UTF-8 | [Bluesky: post lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json) |
| Link card | No: `link` is ignored on Bluesky image posts, with a warning | [Bluesky: posts guide](https://docs.bsky.app/docs/advanced-guides/posts) |
| Rate limit | 5,000 write points per hour and 35,000 per day per account; a post costs 3 | [Bluesky: rate limits](https://docs.bsky.app/docs/advanced-guides/rate-limits) |
| Rate limit | createSession: 30 per 5 minutes and 300 per day per account | [Bluesky: rate limits](https://docs.bsky.app/docs/advanced-guides/rate-limits) |

Compare with other platforms in the [social media API limits tracker](https://turboyap.com/tools/social-api-limits#bluesky).

## Connect a Bluesky account

1. In Bluesky, open **Settings**, then **Privacy and security**, then **App passwords**, and add one named Turboyap.
2. Connect with your handle (for example `yourname.bsky.social`) and that app password. Never use your main password: an app password can be revoked on its own, any time.
3. Only if your account isn't hosted by Bluesky, also give your hosting provider's `https://` URL.

Turboyap checks the app password with Bluesky before it saves the account, and stores it encrypted so it can renew the session. A human with a live key can connect directly with `POST /v1/accounts` and `{ "platform": "bluesky", "handle": "yourname.bsky.social", "app_password": "xxxx-xxxx-xxxx-xxxx" }`.

An agent never handles the account's credentials: it calls the `create_connect_link` MCP tool (or `POST /v1/connect_links` with the platform) and sends the 7-day link to the person who owns the account, whose page walks them through these steps. New accounts start on autonomy level 1, so agent posts wait for approval.

## Example requests

All three do the same thing: upload the media, preview the post with a dry run, then schedule it. The idempotency key makes retries safe: send the same request again with the same key and you get the original post back, not a second one.

**Early access:** `api.turboyap.com` is open to early-access users only, and `@turboyap/sdk` and `@turboyap/mcp` aren't on npm yet. These examples show the API as built.

### curl

```bash
# 1. Upload each image. The body is the raw file; Content-Type is its type.
curl https://api.turboyap.com/v1/media \
  -H "Authorization: Bearer $TURBOYAP_API_KEY" \
  -H "Content-Type: image/png" \
  -H "X-Filename: slide-1.png" \
  --data-binary @slide-1.png
# → { "media": { "id": "med_…", "width": 1080, "height": 1920, … }, "deduplicated": false }

# 2. Preview. Remove "dry_run" to create and schedule the post.
curl https://api.turboyap.com/v1/posts \
  -H "Authorization: Bearer $TURBOYAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "media_ids": ["med_…"],
    "caption": "Read the label. #ingredients",
    "targets": [{ "account_id": "acct_bluesky…" }],
    "schedule_at": "2026-10-20T18:00:00-04:00",
    "idempotency_key": "bluesky-image-post-2026-10-20",
    "dry_run": true
  }'
```

### TypeScript with @turboyap/sdk

```ts
import { readFile } from "node:fs/promises";
import { TurboyapClient } from "@turboyap/sdk";

const ty = new TurboyapClient({ apiKey: process.env.TURBOYAP_API_KEY! });
const account = (await ty.listAccounts()).find((a) => a.platform === "bluesky")!;

const media_ids: string[] = [];
for (const file of ["image.png"]) {
  const { media } = await ty.uploadMedia(await readFile(file), { filename: file, contentType: "image/png" });
  media_ids.push(media.id);
}

const post = {
  type: "image" as const,
  media_ids,
  caption: "Read the label. #ingredients",
  targets: [{ account_id: account.id }],
  schedule_at: "2026-10-20T18:00:00-04:00",
  idempotency_key: "bluesky-image-post-2026-10-20",
};

// Preview first: every problem comes back with a fix.
const preview = await ty.createPost({ ...post, dry_run: true });
if (!preview.ok) throw new Error(preview.errors.map((e) => `${e.code}: ${e.fix}`).join("\n"));

const { post: created } = await ty.createPost(post);
console.log(created.targets[0]?.status, created.targets[0]?.next_step);
```

### Claude Code (MCP)

Add the Turboyap MCP server once, with an agent key from your workspace:

```bash
claude mcp add turboyap -e TURBOYAP_API_KEY=ty_agent_… -- npx -y @turboyap/mcp
```

Then ask:

> Post ~/content/label-check.png to our Bluesky tomorrow at 8am ET with the caption "Read the label. #ingredients" and alt text that describes the label. Dry-run first.

The agent calls `list_accounts`, `get_platform_rules`, `upload_media`, `create_post` with `dry_run: true`, applies any fixes, calls `create_post` again to schedule, and reports each account's `next_step` from `get_post`. The same server works in Cursor; see the [MCP quickstart](https://turboyap.com/docs/mcp-quickstart).

## How publishing works

When the scheduled time arrives, Turboyap uploads each image to Bluesky as a blob, then creates the post with `com.atproto.repo.createRecord`. Publishing is immediate: the target moves `scheduled` → `publishing` → `published`, and `platform_url` links to the post on bsky.app. If a response is lost, Turboyap looks for the post on Bluesky before it tries again.

The 300-character limit counts graphemes: an emoji with a skin tone counts as one.

Links, @mentions and #hashtags in the text become rich text. A mention that doesn't match a Bluesky account stays plain text.

Pass `link` on a text post for a link card. Bluesky can't show a link card and images on the same post.

Images are compressed under 1 MB: Bluesky allows 2 MB now, but self-hosted servers on older versions still enforce 1 MB. Alt text comes from `alt_text`.

Video isn't supported yet: Turboyap's media pipeline takes images only.

Each post's record key is derived from the post, so a retry after a lost response can't create a second post.

## Common errors and fixes

Every error has a `code`, the `field` that caused it, and a `fix` an agent can apply without asking you. A request with several problems returns all of them in `issues`; a dry run returns them without creating anything.

| Code | When | Fix |
|---|---|---|
| `too_many_items` | More than one media id on an image post. | Send one media id, or set `fit.overflow` to `"truncate"` to post only the first. For several images, use `type: "slideshow"`. |
| `caption_too_long` | Caption over 300 characters (counted as graphemes). | Shorten it, or pass `targets[i].caption` with a version under 300 characters. |
| `caption_too_long` | Caption over 3,000 bytes of UTF-8 (emoji and non-Latin scripts take several bytes each). | Shorten it, or use fewer emoji. |
| `invalid_request` | `link` on Bluesky image posts. It's a warning, not an error: the post goes out without the card. | Put the URL in the caption instead, or post it as a text post. |
| `media_format_unsupported` | A media id points to a video, or the upload isn't an image Turboyap can read. | Upload PNG, JPEG or WebP images. Video posts are coming soon. |
| `schedule_in_past` | `schedule_at` is more than a minute in the past. | Use `"now"` or a future time with a timezone offset. |
| `account_unavailable` | The Bluesky account needs to be reconnected. | Reconnect the account, then retry. |
| `unsupported_platform` | A target is on a platform Turboyap doesn't support yet. | Remove that target. Supported platforms: tiktok, instagram, bluesky (beta), telegram (beta), discord (beta). |
| `insufficient_scope` | The key lacks a scope the request needs, such as `publish` for `schedule_at: "now"` (agent keys don't have it by default). | Schedule a future time instead of `"now"`, omit `schedule_at` to save a draft, or use a key with the missing scope. `GET /v1/keys/me` lists the key's scopes. |
| `account_not_allowed` | The key is limited to some accounts, and a target isn't one of them. | Remove that target, or use a key whose `account_ids` include the account. |
| `forbidden_scope` | An agent key tries to approve or reject a post. | A human approves agent posts. To skip review for an account, a human sets its autonomy level to 3. |
| `unknown_outcome` | Turboyap stopped partway through publishing (for example, a restart) and can't confirm whether the post went out. It doesn't publish it again. | Check the account on the platform. If the post isn't there, create it again. |

Errors from Bluesky itself appear on the target's `error` with Bluesky's own code. Turboyap retries rate limits and temporary failures with backoff, and marks the account for reconnection when Bluesky stops accepting the app password.

## Approval and audit

- **Level 1, review everything (default):** a post an agent schedules starts as `pending_approval` and waits until a human approves it with a human key (`POST /v1/posts/:id/approve`). Agent keys can't approve.
- **Level 3, full autopilot:** agent posts schedule directly. Every action is still logged. Level 2, guardrails, is coming soon.
- **Posts created by humans** schedule directly at any level.
- **Edits re-open review:** if an agent changes the caption or time of an approved post on a level-1 account, it goes back to `pending_approval`.
- **Dry runs show the outcome:** each target's `status_on_create` tells the agent whether the post will wait for approval.
- **Audit log:** every create, approval, edit, publish and failure is recorded with whether a human or an agent did it, the key id, and the MCP client name. The kill switch (`POST /v1/workspace/pause`) stops every publish to Bluesky and every other platform at once.

Read more in [the autonomy dial](https://turboyap.com/docs/autonomy-dial).

## FAQ

### What image size does the Bluesky API accept?

Turboyap keeps every Bluesky image under 1 MB (1,000,000 bytes) and within 2000×2000 pixels, converting and resizing it for you. Bluesky's lexicon allows 2 MB now, but self-hosted servers on older versions still enforce 1 MB, so Turboyap uses the lower limit. JPEG, PNG, WebP and GIF are accepted as-is.

### How do I add alt text to a Bluesky image through the API?

Pass `alt_text` keyed by media id, for example `{"med_…": "A pint of ice cream next to its ingredient label"}`, up to 1,000 characters each. Turboyap sends it as the image's alt text on Bluesky.

### Can a Bluesky post have images and a link card?

No. A Bluesky post shows either images or a link card, not both. On image posts Turboyap ignores `link` with a warning; put the URL in the text instead, where it becomes a clickable link.

### Can I post video to Bluesky through Turboyap?

Not yet. Turboyap's media pipeline takes images only for now.

## Related

- [Bluesky text post API](https://turboyap.com/api/bluesky/text-post) (beta)
- [Bluesky multi-image post API](https://turboyap.com/api/bluesky/multi-image-post) (beta)
- [TikTok photo post API](https://turboyap.com/api/tiktok/photo-post)
- [Instagram image post API](https://turboyap.com/api/instagram/image-post)
- [Telegram photo post API](https://turboyap.com/api/telegram/photo) (beta)
- [Discord image message API](https://turboyap.com/api/discord/image-message) (beta)
- [Post one slideshow to TikTok and Instagram](https://turboyap.com/docs/slideshows)
- [REST API reference](https://turboyap.com/docs/api)
- [Platform rules](https://turboyap.com/docs/platform-rules)
- [Social media API limits tracker](https://turboyap.com/tools/social-api-limits)
