> ## Documentation Index
> Fetch the complete documentation index at: https://docs.letmepost.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Bluesky

> AT Proto. Live end-to-end. App-password connect, no OAuth.

<Warning>
  **Before you start.** Bluesky doesn't have OAuth — you connect with an app password generated at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords). Don't paste a main account password; the platform's session endpoint accepts it but the publisher refuses by design.
</Warning>

## Quick reference

| Limit / capability   | Value                                                           |
| -------------------- | --------------------------------------------------------------- |
| Character limit      | 300 graphemes                                                   |
| Images per post      | 4                                                               |
| Image formats        | jpeg, png, webp, gif                                            |
| Image size           | 1,000,000 bytes (\~976 KB)                                      |
| Video formats        | mp4                                                             |
| Video size           | 100,000,000 bytes (100 MB)                                      |
| Video duration       | platform-enforced (no client-side cap)                          |
| Mixed image + video  | Rejected — pick one per post                                    |
| Alt text per item    | 2000 graphemes                                                  |
| Post types supported | text, single image, multi-image, video                          |
| Scheduling           | Yes (via `scheduledAt`)                                         |
| Reply / threads      | Yes — reply to any post via `options.replyToUri` + `replyToCid` |
| First comment        | Yes (`firstComment.text`)                                       |
| Inbox / DM           | Not supported in v1                                             |
| Analytics            | Not supported in v1 (AT Proto exposes no impressions data)      |

## Connect an account

Generate an app password at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords). The format is 19 characters with hyphens (e.g. `abcd-efgh-ijkl-mnop`). Submit it via the dashboard's **Accounts** page or programmatically:

```bash connect.sh theme={"system"}
curl -X POST https://api.letmepost.dev/v1/accounts/connect/bluesky/complete \
  -H "Authorization: Bearer $LMP_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "identifier": "you.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop" }'
```

The `complete` response carries the `id` you'll send posts against. App passwords can be revoked at any time on bsky.app — when that happens the connection breaks cleanly with [`platform_auth_failed`](/errors/platform_auth_failed).

App passwords don't have OAuth scopes; they grant the same permissions as the password itself. AT Protocol JWTs are short-lived (minutes); the publisher refreshes on every 401 so you don't manage tokens. Subscribe to [`token.revoked`](/webhooks/token-revoked) for upstream revocations.

## Post types

### Text post

```json text.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "Shipping letmepost.dev"
}
```

### Single image

```json single-image.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "Look at this",
  "media": [
    { "kind": "image", "mediaId": "med_…", "altText": "Status page screenshot" }
  ]
}
```

### Multi-image

Up to four images per post. Mixing images and video on the same post is rejected by preflight ([`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive)).

```json multi-image.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "Four shots",
  "media": [
    { "kind": "image", "mediaId": "med_a…", "altText": "front" },
    { "kind": "image", "mediaId": "med_b…", "altText": "back" },
    { "kind": "image", "mediaId": "med_c…", "altText": "left" },
    { "kind": "image", "mediaId": "med_d…", "altText": "right" }
  ]
}
```

### Video

```json video.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "demo clip",
  "media": [
    { "kind": "video", "mediaId": "med_…", "altText": "..." }
  ]
}
```

The publisher runs Bluesky's documented video flow transparently: mint a service-auth JWT scoped to `app.bsky.video.uploadVideo`, probe `getUploadLimits` so quota exhaustion fails loudly with [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted), stream bytes to `uploadVideo`, poll `getJobStatus` until COMPLETED, then embed the blob ref as `app.bsky.embed.video`. Videos do **not** go through `com.atproto.repo.uploadBlob` — that's the most common reason naive integrations break.

### First comment

```json first-comment.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "Main post",
  "firstComment": { "text": "Reply that drops in seconds later" }
}
```

The reply posts as a child of the main record under the same DID. If the parent publishes but the comment fails, the parent stays — the response carries a `warnings[]` entry with `code: "first_comment_failed"` instead of failing the whole call.

### Reply (threading)

Thread a post under any existing Bluesky post by passing the parent's strong ref — its `uri` **and** `cid` — in the target's `options`. Both come straight from the parent post's publish response.

```json reply.json theme={"system"}
{
  "targets": [
    {
      "accountId": "...",
      "options": {
        "platform": "bluesky",
        "replyToUri": "at://did:plc:abc/app.bsky.feed.post/3kr…",
        "replyToCid": "bafyrei…"
      }
    }
  ],
  "text": "2/ …continuing the thread"
}
```

`replyToUri` and `replyToCid` must be sent together. To build a multi-post thread: publish post 1, read its `uri` + `cid` from the response, and pass them as post 2's `replyToUri`/`replyToCid`. For replies deeper than the first, also pin the thread's original post with `replyRootUri` + `replyRootCid` so every reply stays anchored to the root — omit the root and it defaults to the parent (correct for a reply to a top-level post).

## Wisdom (platform-specific things that bite)

<Tip>
  * Mentions, hashtags, and URLs are AT Proto **facets** — letmepost auto-detects them from plain text and builds the facet ranges. Don't construct facet byte offsets manually; the post will index correctly without them.
  * Bluesky's image blob ceiling is 976 KB, not 1 MB — a 1,000,000-byte file is at the edge and re-encoded JPEGs around 950 KB are the safe zone.
  * Videos count against a **daily** account quota that lives entirely on Bluesky's side. The first preflight call to `getUploadLimits` surfaces remaining headroom before bytes are sent — exhausted callers fail with [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted) and no upload happens.
  * Bluesky deduplicates videos by content hash, so re-uploading the same clip can short-circuit `JOB_STATE_COMPLETED` without re-transcoding.
  * The `uri` on the response is the AT Proto record URI (`at://did:plc:abc/app.bsky.feed.post/3kr…`), not a bsky.app URL. The public URL is derived from the DID + record key.
  * Bluesky has no scheduled-publish API; `scheduledAt` is handled by letmepost's queue — the post fires from our worker when the time comes.
</Tip>

## Common errors

| Error rule                                                                              | What it means                           | How to fix                                                                                                             |
| --------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes)                   | Post exceeded the 300-grapheme cap      | Trim to ≤300 graphemes.                                                                                                |
| [`bluesky.text.non_empty`](/preflight/bluesky-text-non_empty)                           | Text field was empty or whitespace      | Provide non-whitespace text — Bluesky has no media-only post shape.                                                    |
| [`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive) | Attached both images and a video        | Split into two posts; AT Proto records hold one media kind per record.                                                 |
| [`bluesky.media.count_max`](/preflight/bluesky-media-count_max)                         | More than 4 images or more than 1 video | Reduce to 4 images max, or 1 video.                                                                                    |
| [`bluesky.media.image_size_max`](/preflight/bluesky-media-image_size_max)               | Image exceeded 976 KB                   | Re-encode under 1,000,000 bytes.                                                                                       |
| [`bluesky.media.mime_allowed`](/preflight/bluesky-media-mime_allowed)                   | Unsupported image/video mime            | Images: jpeg/png/webp/gif. Videos: mp4.                                                                                |
| [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted)             | Daily video upload quota hit            | Wait for the quota window to roll over; letmepost reads the live limit from `getUploadLimits` so the check is current. |
| [`bluesky.video.job_failed`](/preflight/bluesky-video-job_failed)                       | Bluesky's transcoder rejected the clip  | Inspect `platformResponse` for the upstream reason — usually codec or duration.                                        |

## What you can't do (yet)

* Starter packs, custom feeds, pin-to-profile.
* Direct messages and DM attachments (AT Proto chat lives on a separate XRPC surface not exposed in v1).
* Analytics — AT Proto records carry no impression count by design.
* Polls, quote posts, and labeler interactions.
* Editing or backdating a published record.

## API reference

* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/bluesky/complete`](/api-reference) — finish a Bluesky connect.
