skillmake
← marketplace
marketingothersha:2e6773f7b2a97755manual

threads-bluesky-crosspost

Use when fanning one piece of writing out to Threads + Bluesky (+ X) with consistent voice — AT Protocol createSession/createRecord on Bluesky and the Threads Graph API two-step container/publish flow.

Install confidence
curl --create-dirs -fsSL https://skillmake.xyz/i/threads-bluesky-crosspost -o ~/.claude/skills/threads-bluesky-crosspost/SKILL.md
Pinned content
sha:2e6773f7b2a97755
Generated with
manual
Source
docs.bsky.app

The file served at /api/marketplace/threads-bluesky-crosspost-2e6773f7/raw matches this hash. Inspect before install, then copy the command.

6,484 chars · ~1,621 tokens
---
name: threads-bluesky-crosspost
description: Use when fanning one piece of writing out to Threads + Bluesky (+ X) with consistent voice — AT Protocol createSession/createRecord on Bluesky and the Threads Graph API two-step container/publish flow.
source: https://docs.bsky.app/docs/get-started
generated: 2026-05-17T04:18:40.873Z
category: other
audience: marketing
---

## When to use

- Publishing the same long-form take across Bluesky, Threads, and X without retyping or losing voice
- Automating a daily/weekly cross-post from a single source file (Markdown, RSS, or notes app)
- Splitting one essay into a thread that respects each network's character limit (300 on Bluesky, 500 on Threads, 280 on X)
- Handling image embeds — uploading a blob to Bluesky vs supplying a public image URL to Threads
- Building a small CLI or Cloudflare Worker that posts on a schedule

## Key concepts

### AT Protocol session tokens

Bluesky uses two JWTs from com.atproto.server.createSession: accessJwt (short-lived, used as Bearer on every request) and refreshJwt (longer-lived, used only to mint new access tokens). A long-running poster must catch 401s and refresh — access tokens expire 'after a few minutes.'

### Bluesky record model

A Bluesky post is a record in the app.bsky.feed.post collection, written via com.atproto.repo.createRecord. The record must include `text` and `createdAt` (ISO 8601). The response returns `uri` (at://... identifier) and `cid` (content hash) — keep both if you need to reply or delete later.

### Threads two-step publish

Threads splits posting in two: first POST /{user-id}/threads creates a media container (with media_type=TEXT|IMAGE|VIDEO|CAROUSEL and optional text/image_url/video_url), returning a creation_id. Then POST /{user-id}/threads_publish with that creation_id actually publishes. Wait ~30 seconds between the two for image/video processing.

### voice preservation per channel

Bluesky's 300-char limit, Threads' 500-char limit, and X's 280-char limit mean the same source rarely fits all three. A repeatable recipe: write the canonical post for the tightest channel (X), then expand for Threads (add context) and let Bluesky take the X version unchanged. Hashtags and @mentions do not portably translate — strip them and rewrite per network.

### image embeds differ

Bluesky requires uploading the image as a blob via com.atproto.repo.uploadBlob first, then referencing the returned blob ref inside the post record's `embed.images`. Threads instead expects a `image_url` pointing to a publicly reachable URL — you do not upload bytes to Threads, you host the image yourself.

### rate limits

Threads caps profiles at 250 published posts per 24h. Bluesky's PDS enforces per-account write limits (documented separately); back off on 429. For a crossposter, the practical ceiling is whichever network's limit is tightest — design the queue around that.

## API reference

```
POST $PDSHOST/xrpc/com.atproto.server.createSession
```

Authenticate to Bluesky with handle + app password. Returns accessJwt and refreshJwt. Cache accessJwt and refresh on 401.

```
curl -X POST $PDSHOST/xrpc/com.atproto.server.createSession \
  -H "Content-Type: application/json" \
  -d '{"identifier": "'"$BLUESKY_HANDLE"'", "password": "'"$BLUESKY_PASSWORD"'"}'
```

```
POST $PDSHOST/xrpc/com.atproto.repo.createRecord
```

Create a Bluesky post by writing a record in the app.bsky.feed.post collection. Required fields: text, createdAt.

```
curl -X POST $PDSHOST/xrpc/com.atproto.repo.createRecord \
  -H "Authorization: Bearer $ACCESS_JWT" \
  -H "Content-Type: application/json" \
  -d "{\"repo\": \"$BLUESKY_HANDLE\", \"collection\": \"app.bsky.feed.post\", \"record\": {\"text\": \"Hello world! I posted this via the API.\", \"createdAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}"
```

```
POST https://graph.threads.net/v1.0/{threads-user-id}/threads
```

Create a Threads media container. Required: media_type (TEXT | IMAGE | VIDEO). Optional: text (max 500 chars), image_url, video_url, is_carousel_item. Returns the creation_id used in step 2.

```
curl -i -X POST \
  -d "media_type=IMAGE" \
  -d "image_url=<IMAGE_URL>" \
  -d "text=<TEXT>" \
  -d "access_token=<ACCESS_TOKEN>" \
"https://graph.threads.net/v1.0/<THREADS_USER_ID>/threads"
```

```
POST https://graph.threads.net/v1.0/{threads-user-id}/threads_publish
```

Publish a previously-created Threads media container. Wait ~30 seconds after creation before calling publish to let server-side processing finish.

```
curl -i -X POST \
  -d "creation_id=<MEDIA_CONTAINER_ID>" \
  -d "access_token=<ACCESS_TOKEN>" \
"https://graph.threads.net/v1.0/<THREADS_USER_ID>/threads_publish"
```

```
Crosspost workflow recipe
```

End-to-end loop: load canonical post from source -> trim/expand per channel -> call Bluesky createRecord and Threads container+publish -> persist returned IDs (uri/cid for Bluesky, media id for Threads) for later edits/deletes.

```
// pseudocode
const { accessJwt } = await createSession(handle, appPassword);
const bsky = await createRecord(accessJwt, { text: trimTo(post, 300), createdAt: new Date().toISOString() });
const container = await fetch(`https://graph.threads.net/v1.0/${userId}/threads`, { method: 'POST', body: form({ media_type: 'TEXT', text: expandFor(post, 500), access_token }) });
await sleep(30_000);
const threads = await fetch(`https://graph.threads.net/v1.0/${userId}/threads_publish`, { method: 'POST', body: form({ creation_id: container.id, access_token }) });
log({ bsky_uri: bsky.uri, threads_id: threads.id });
```

## Gotchas

- Bluesky accessJwt expires within minutes — long-running posters MUST refresh via refreshJwt or every fresh start hits 401
- Threads expects publicly reachable image_url, not an upload — hosting your own image (R2, S3, CDN) is part of the crosspost stack
- Skipping the ~30s wait between Threads container creation and publish causes intermittent failures for image/video posts
- Threads caps you at 250 published posts per 24h per profile — back off on rate-limit responses or your queue jams
- @mentions and hashtags do not portably translate across networks; strip them and rewrite per channel or links will be dead
- Writing the post for the largest channel first guarantees it will not fit Bluesky/X — draft for the tightest limit, expand outward

---
Generated by SkillMake from https://docs.bsky.app/docs/get-started on 2026-05-17T04:18:40.873Z.
Verify against source before relying on details.

File: ~/.claude/skills/threads-bluesky-crosspost/SKILL.md