turboyap

Bluesky multi-image post API

Publish Bluesky posts with 2 to 4 images through an API or MCP server: image limits, alt text, 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 runs the same requests against a simulated Bluesky account.

What a Bluesky multi-image post is

A Bluesky multi-image post carries 2 to 4 images under the text. Bluesky's API uploads each image as a blob, then creates one post with an app.bsky.embed.images embed that holds all of them. In Turboyap it's type: "slideshow" sent to a Bluesky account.

Send more than 4 slides and Turboyap can post the first 4 with fit.overflow: "truncate", or you choose which 4 with a per-target media_ids. A slideshow with one slide posts to Bluesky as an image post.

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), and each value links to the Bluesky documentation it comes from.

LimitValueSource
Images per post2–4Bluesky: posts guide
FormatsJPEG, PNG, WebP, GIF (Turboyap converts anything else to JPEG)Bluesky: posts guide
File size1 MB (1,000,000 bytes) per imageBluesky: posts guide
DimensionsFits 2000×2000 (Turboyap resizes to fit)Bluesky: posts guide
Aspect ratioAnyBluesky: posts guide
Caption300 characters, counted as graphemes (sent as text)Bluesky: post lexicon
Caption size3,000 bytes of UTF-8Bluesky: post lexicon
Link cardNo: link is ignored on Bluesky multi-image posts, with a warningBluesky: posts guide
Rate limit5,000 write points per hour and 35,000 per day per account; a post costs 3Bluesky: rate limits
Rate limitcreateSession: 30 per 5 minutes and 300 per day per accountBluesky: rate limits

Compare with other platforms in the social media API limits tracker.

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

# 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": "slideshow",
    "media_ids": ["med_…", "med_…", "med_…"],
    "caption": "Three things to check on every ice cream label.",
    "targets": [{ "account_id": "acct_bluesky…" }],
    "schedule_at": "2026-10-20T18:00:00-04:00",
    "fit": { "overflow": "truncate" },
    "idempotency_key": "bluesky-multi-image-post-2026-10-20",
    "dry_run": true
  }'

TypeScript with @turboyap/sdk

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 ["slide-1.png", "slide-2.png", "slide-3.png"]) {
  const { media } = await ty.uploadMedia(await readFile(file), { filename: file, contentType: "image/png" });
  media_ids.push(media.id);
}

const post = {
  type: "slideshow" as const,
  media_ids,
  caption: "Three things to check on every ice cream label.",
  targets: [{ account_id: account.id }],
  schedule_at: "2026-10-20T18:00:00-04:00",
  fit: { overflow: "truncate" as const },
  idempotency_key: "bluesky-multi-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:

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

Then ask:

Upload the slides in ~/content/labels and post the best 4 to our Bluesky on Thursday at noon ET, with alt text for each. Dry-run first and fix anything Bluesky rejects.

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.

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.

CodeWhenFix
too_many_itemsMore than 4 images for Bluesky.Set fit.overflow to "truncate" to post the first 4, or pass targets[i].media_ids with your best 4 slides (keep your call-to-action slide).
too_few_itemsFewer than 2 images. One slide posts as a single image instead, with a warning.Add media so there are at least 2, or send it as an image post.
caption_too_longCaption over 300 characters (counted as graphemes).Shorten it, or pass targets[i].caption with a version under 300 characters.
caption_too_longCaption over 3,000 bytes of UTF-8 (emoji and non-Latin scripts take several bytes each).Shorten it, or use fewer emoji.
invalid_requestlink on Bluesky multi-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_unsupportedA 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_pastschedule_at is more than a minute in the past.Use "now" or a future time with a timezone offset.
account_unavailableThe Bluesky account needs to be reconnected.Reconnect the account, then retry.
unsupported_platformA target is on a platform Turboyap doesn't support yet.Remove that target. Supported platforms: tiktok, instagram, bluesky (beta), telegram (beta), discord (beta).
insufficient_scopeThe 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_allowedThe 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_scopeAn 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_outcomeTurboyap 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

Read more in the autonomy dial.

FAQ

How many images can a Bluesky post have through the API?

Up to 4. Send more and Turboyap returns too_many_items; set fit.overflow to "truncate" to post the first 4, or pick 4 with a per-target media_ids.

Can I send the same slides to TikTok and Bluesky in one request?

Yes: list both accounts in targets. Turboyap plans each account separately, so with fit.overflow: "truncate" TikTok gets up to 35 slides and Bluesky the first 4. To choose Bluesky's 4 yourself, pass targets[i].media_ids.

Does each image get its own alt text?

Yes. alt_text maps each media id to its description, and Turboyap sends each one with its image.

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.