Bluesky text post API
Post to Bluesky through an API or MCP server: the 300-grapheme limit, link cards, clickable links and mentions, connecting with an app password, 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 text post is
A Bluesky text post is up to 300 characters of text, with an optional link card under it. Bluesky's API creates it as an app.bsky.feed.post record with com.atproto.repo.createRecord. In Turboyap it's type: "text", and the caption is the post.
Turboyap turns links, @mentions and #hashtags in the text into Bluesky rich text and adds a link card when you pass link.
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.
| Limit | Value | Source |
|---|---|---|
| Media | None (text only) | Bluesky: post lexicon |
| Text | 300 characters, counted as graphemes (sent as text) | Bluesky: post lexicon |
| Text size | 3,000 bytes of UTF-8 | Bluesky: post lexicon |
| Link card | Yes: pass link, sent as app.bsky.embed.external (link card) | Bluesky: posts guide |
| Rate limit | 5,000 write points per hour and 35,000 per day per account; a post costs 3 | Bluesky: rate limits |
| Rate limit | createSession: 30 per 5 minutes and 300 per day per account | Bluesky: rate limits |
Compare with other platforms in the social media API limits tracker.
Connect a Bluesky account
- In Bluesky, open Settings, then Privacy and security, then App passwords, and add one named Turboyap.
- 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. - 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: 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
# 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": "text",
"caption": "Ice cream or frozen dairy dessert? Here'\''s how to tell from the label.",
"link": { "url": "https://example.com/labels", "title": "How to read an ice cream label" },
"targets": [{ "account_id": "acct_bluesky…" }],
"schedule_at": "2026-10-20T18:00:00-04:00",
"idempotency_key": "bluesky-text-post-2026-10-20",
"dry_run": true
}' TypeScript with @turboyap/sdk
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 post = {
type: "text" as const,
caption: "Ice cream or frozen dairy dessert? Here's how to tell from the label.",
link: { "url": "https://example.com/labels", "title": "How to read an ice cream label" },
targets: [{ account_id: account.id }],
schedule_at: "2026-10-20T18:00:00-04:00",
idempotency_key: "bluesky-text-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:
Post "Ice cream or frozen dairy dessert? Here's how to tell from the label." to our Bluesky tomorrow at 9am ET with a link card for https://example.com/labels. Dry-run first and fix anything Bluesky rejects.
The agent calls list_accounts, get_platform_rules, 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 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.
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 | Media ids on a text post. Text posts take no media. | Use type: "image" (one image) or "slideshow" (several) to post media, or remove media_ids. |
invalid_request | A text post with no text and no link. | Set caption to the text of the post. |
caption_too_long | Text over 300 characters (counted as graphemes). | Shorten it, or pass targets[i].caption with a version under 300 characters. |
caption_too_long | Text over 3,000 bytes of UTF-8 (emoji and non-Latin scripts take several bytes each). | Shorten it, or use fewer emoji. |
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_approvaland 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_createtells 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.
FAQ
How long can a Bluesky post be through the API?
300 characters, counted as graphemes, so an emoji with a skin tone counts as one. Bluesky also caps the text at 3,000 bytes of UTF-8, which only matters for text heavy in emoji or non-Latin scripts. Turboyap checks both before scheduling and returns caption_too_long with a fix.
How do I add a link card to a Bluesky post through the API?
Pass link with a url, and optionally a title and description, on a text post. Turboyap sends it as an app.bsky.embed.external embed, with the URL's host name as the title if you don't give one.
Do I need my Bluesky password to post through Turboyap?
No, and you shouldn't use it. Create an app password in Bluesky (Settings, Privacy and security, App passwords) and connect with your handle and that app password. You can revoke it in Bluesky at any time, and Turboyap stores it encrypted so it can renew the session.
Do links and @mentions work in Bluesky posts sent through the API?
Yes. Bluesky makes links, mentions and hashtags clickable only when the post marks them up as rich text, and Turboyap does that for you. A mention that doesn't match a Bluesky account stays plain text.
Related
- Bluesky image post API (beta)
- Bluesky multi-image post API (beta)
- Telegram text message API (beta)
- Discord message API (beta)
- REST API reference
- Platform rules
- Social media API limits tracker
This page as markdown: /api/bluesky/text-post.md. Everything for LLMs: /llms.txt.