← 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