# Bluesky

Source: https://staging.postgale.com/docs/bluesky

Publish posts, threads, replies, images, videos and link cards to Bluesky (AT Protocol), answer comments, read post analytics and send direct messages through the Postgale API and MCP server.

Postgale publishes to Bluesky accounts: text posts with links, mentions and hashtags, up to 4 images or one video, link cards, threads
and replies, and deletes of what was published. It also reads and answers the replies to your posts (comments), reports likes,
comments and shares per post, and sends and receives text direct messages. Everything is available over the [REST API](https://staging.postgale.com/docs/api)
(paths relative to `https://staging.postgale.com/api/v1`) and the [MCP server](https://staging.postgale.com/docs/mcp) (`connect_account`, `create_post`, `manage_published_post`,
`list_comments`, `moderate_comment`, `like_post`, `get_analytics`, `list_conversations`, `send_message`, `start_conversation`,
`manage_message`, `bluesky_settings`). Each connected Bluesky account has platform
`bluesky`; its `platformAccountId` is the account's DID (`did:plc:…`).

## Connect an account

Bluesky accounts connect with their **handle** and an **app password**. Postgale never needs the account password.

1. In Bluesky, open **Settings → Privacy and security → App passwords** and choose **Add App Password**. Name it (e.g. *Postgale*),
   check **Allow access to your direct messages** if Postgale should read and send DMs (leave it off otherwise), and copy the
   password: it looks like `xxxx-xxxx-xxxx-xxxx`.
2. In Postgale open **Dashboard → Accounts → Bluesky**, enter the handle (e.g. `yourname.bsky.social` or your own domain) and paste the
   app password.

Or connect from your code (the answer is the connected account; `account.connected` is sent as well):

POST /api/v1/connect/bluesky:
```bash
curl -X POST https://staging.postgale.com/api/v1/connect/bluesky \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"handle":"yourname.bsky.social","appPassword":"xxxx-xxxx-xxxx-xxxx","externalId":"customer-42"}'

# {"platform":"bluesky","account":{"id":"…","platform":"bluesky","accountName":"@yourname.bsky.social","platformAccountId":"did:plc:…","externalId":"customer-42"}}
```

Postgale resolves the handle to its DID and personal data server (PDS), works on any PDS (bsky.social or self-hosted), and signs in
there. The session tokens and the app password are stored encrypted; sessions are renewed automatically, and if the renewal fails
Postgale signs in again with the app password. A wrong handle or password answers 400 with the reason; anything other than an app
password is refused.

If you delete the app password in Bluesky (or change the account password), the next call fails: the account becomes
`needs_reconnect` and `account.disconnected` is sent (reason `token_invalid`). Connect it again with a new app password; the same
account is updated, its post history stays.

## Publish

The post content is the text, up to **300 characters** as Bluesky counts them (graphemes: an emoji with modifiers counts once).
Links, `@handle` mentions and `#hashtags` in the text become clickable: mentions are resolved to the person's DID and left as plain text
when the handle does not exist. Without media, the first link gets a **link card** (title, description and image from the page) when
the page can be read; a card that cannot be built never fails the post.

POST /api/v1/posts:
```bash
curl -X POST https://staging.postgale.com/api/v1/posts \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"content":"Our spring release is out 🌱 https://example.com/blog/spring #release","platforms":[{"accountId":"acc_bsky","platformSpecificData":{"mediaUrls":["https://media.postgale.com/…/hero.jpg"],"altTexts":["The new dashboard in dark mode"],"langs":["en"]}}],"publishNow":true}'
```

| platformSpecificData | What it does |
| --- | --- |
| `mediaUrl` / `mediaUrls` | Up to 4 images (JPEG, PNG, WebP or GIF, each ≤1,000,000 bytes) or **one** MP4 video (≤100 MB, ≤3 minutes). Images and a video cannot be mixed. Public https URLs, e.g. an uploaded media `publicUrl` |
| `altText` / `altTexts` | Alt text for the image(s) or the video (≤2000 characters each) |
| `threadItems` | A thread: up to 25 posts of `{content, mediaUrl?, mediaUrls?, altText?, altTexts?}`, each a reply to the previous one. Replaces the post text |
| `replyTo` | Reply to an existing post: its `at://` URI or a `https://bsky.app/profile/<handle>/post/<id>` link |
| `langs` | Up to 3 language tags, e.g. `["en"]`. Default: the account's `defaultLangs` (see [Settings](#settings)) |
| `disableLinkPreview` | No link card |

Limits are checked when the post is created; file sizes, types and the video length are checked again when the files are fetched,
before anything is posted. The target's `platformPostId` is the post's `at://` URI and `url` its `https://bsky.app/profile/<handle>/post/<id>`
link. For a thread they are those of the first post; if a later post of the thread fails, the thread stops there and the target gets a
warning (the published posts are not repeated).

## Endpoints

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/posts/{id}/unpublish` | Delete the post from Bluesky (for a thread: the first post) |
| GET | `/accounts/{id}/health` | Whether the session still works |
| GET / PATCH | `/accounts/{id}/bluesky-settings` | Default post languages (`defaultLangs`) |
| GET | `/inbox/comments?postId=` or `?accountId=&platformPostId=` | Replies to a post, each with its `cid` |
| POST | `/inbox/comments` | Comment on a post: `{accountId, platformPostId, text}` |
| POST | `/inbox/comments/{id}/reply` | Reply to a comment: `{accountId, text}` |
| DELETE | `/inbox/comments/{id}?accountId=` | Delete one of the account's own replies |
| POST / DELETE | `/inbox/comments/{id}/like` | Like (`{accountId, cid}`) or unlike (`?accountId=&likeUri=`) |
| POST / DELETE | `/inbox/posts/{postId}/like` | Like or unlike any post (`{accountId}`; postId: an at:// URI, URL-encoded) |
| GET | `/analytics/posts/{id}` | Likes, comments, shares (reposts + quotes), replies, reposts, quotes |
| POST | `/inbox/conversations/sync` | Backfill DM conversations: `{accountId}` |
| GET | `/inbox/conversations?platform=bluesky` | Conversations, latest first |
| GET | `/inbox/conversations/{id}/messages` | Messages of a conversation |
| POST | `/inbox/conversations` | Start a conversation: `{accountId, participantUsername \| participantId, text}` |
| POST | `/inbox/conversations/{id}/messages` | Send a text DM: `{text}` |
| POST / DELETE | `/inbox/conversations/{id}/messages/{messageId}/reactions` | Add or remove an emoji reaction: `{emoji}` (also needed to remove) |
| DELETE | `/inbox/conversations/{id}/messages/{messageId}` | Delete a message for the account (the other person still sees it) |
| PATCH | `/inbox/conversations/{id}` | Archive or unarchive: `{status: archived \| active}` |
| POST | `/inbox/conversations/{id}/read` | Mark read in Postgale and on Bluesky |

Every write (deletes, comments, replies, likes, DMs, reactions) is queued for a person while approval applies to the API key, the
organization or the account (202 `pending_approval`; the MCP tools return the queued action). Bluesky posts cannot be edited.

## Comments

Comments are the replies to your posts. Their `id` is the reply's `at://` URI (URL-encode it in a path, e.g.
`/inbox/comments/at%3A%2F%2Fdid%3Aplc%3A…%2Fapp.bsky.feed.post%2F3k…/reply`; a `https://bsky.app/…/post/…` link works too), `parentId`
the post or reply it answers, and `cid` its content id. A top-level comment is a reply to the post itself.

Liking a comment needs its `cid` (Postgale looks it up when you leave it out). The answer carries `likeUri`: keep it to unlike. Unlike
without `likeUri` removes the account's current like, if any.

POST /api/v1/inbox/comments/{commentId}/like:
```bash
curl -X POST https://staging.postgale.com/api/v1/inbox/comments/at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.bsky.feed.post%2F3kxyz/like \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"accountId":"acc_bsky","cid":"bafyrei…"}'

# {"commentId":"at://did:plc:abc/app.bsky.feed.post/3kxyz","liked":true,"likeUri":"at://did:plc:…/app.bsky.feed.like/3l…"}
```

Bluesky only lets an account delete its own replies, and has no hidden replies.

## Analytics

`GET /analytics/posts/{id}` returns, per Bluesky target: `likes`, `comments` (replies), `shares` (reposts + quotes), and `replies`,
`reposts` and `quotes` separately. Bluesky does not expose impressions, reach, clicks or views; requested metrics it cannot return are
listed in `unavailable`. There are no account analytics (`not_supported`).

## Direct messages

Bluesky DMs need an app password created with **Allow access to your direct messages**. Postgale reads the app password's access
when the account connects: without it, DM calls answer 403 `missing_permission` before anything reaches Bluesky, and account health
recommends a new app password. Connect again with one that has DM access to turn them on (settings and history stay).

- `POST /inbox/conversations/sync` `{accountId}` backfills conversations and their latest messages; the worker then keeps them current.
- Messages are **text only**, up to 1000 characters (Bluesky's chat has no attachments: `platform_limitation`). There is no messaging
  window: the person's chat settings decide who can write to them.
- `POST /inbox/conversations` starts a conversation with a handle (`participantUsername`) or a DID (`participantId`).
- Emoji reactions: `POST /inbox/conversations/{id}/messages/{messageId}/reactions` `{emoji: "👍"}`; `DELETE` with the same `{emoji}`
  removes it. Deleting a message removes it for the account only: Bluesky keeps it for the other person.
- Archiving is kept in Postgale (Bluesky has no archive); a new message from the person makes the conversation active again.

## Webhooks

Bluesky has no push events for these, so Postgale's worker polls each account (every 5 minutes by default):

| Event | When |
| --- | --- |
| `comment.received` | Someone replied to one of the account's posts (`comment.cid` included, `postId` set for posts made through Postgale) |
| `mention.received` | Someone mentioned the account |
| `message.received` | A person sent the account a DM |
| `message.sent` | The account sent a DM from somewhere else (e.g. the Bluesky app); sends through Postgale are announced right away |

Events start with the first poll after an account connects (nothing older is replayed). Inbound events are also listed at
`GET /inbox/events`.

## Settings

`GET /accounts/{id}/bluesky-settings` returns `{defaultLangs}`: the languages applied to posts that set no `langs` (null when none).
`PATCH` it with 1-3 BCP-47 tags, or `null` to clear them. A post's own `langs` always win; published posts keep their languages
(Bluesky has no edits). MCP: `bluesky_settings`.

PATCH /api/v1/accounts/{id}/bluesky-settings:
```bash
curl -X PATCH https://staging.postgale.com/api/v1/accounts/acc_bsky/bluesky-settings \
  -H 'Authorization: Bearer sk_…' \
  -H 'Content-Type: application/json' \
  -d '{"defaultLangs":["pt"]}'

# {"defaultLangs":["pt"]}
```

## Bluesky's rules to know

- **Rate limits**: when Bluesky answers 429, Postgale retries after the reset time Bluesky gives.
- **Revoked app passwords**: an app password Bluesky refuses marks the account `needs_reconnect` (`reconnect_required` on the API) and the post is not retried automatically.
- **Analytics**: per post only (likes, replies, reposts, quotes); no views, reach or account analytics.
- **Direct messages**: text only, and only with an app password that allows direct messages.
