Skip to content

fal3/BlueSkyXMirror

BlueSkyXMirror

CI License: MIT Node.js Docker

Mirror any Bluesky account's posts to X (Twitter) in real-time. Uses Bluesky's Jetstream WebSocket API for instant delivery and xurl CLI for posting to X. Supports text posts, images, videos, reposts, self-reply threading, quote posts, link embeds, and optional watermarking.

Features

  • Real-time — WebSocket-based, not polling. Posts appear on X within seconds.
  • Images — Downloads from Bluesky CDN, optional watermark, uploads to X.
  • Videos — Downloads HLS playlist, transcodes to MP4, optional watermark.
  • Self-reply threading — Maintains thread structure on X.
  • Reposts — Posts as "RT @handle: text" with original media.
  • Quote posts — Includes link to original Bluesky post.
  • Link embeds — Passes through external URLs.
  • State persistence — Survives restarts. Tracks posted URIs and cursor.
  • Auto-reconnect — Exponential backoff on WebSocket disconnect.
  • Health checks — HTTP /healthz and /readyz endpoints.
  • Configurable — Reply filtering, watermark text, repost toggle, all via env vars.

Prerequisites

Tool Version Required Purpose
Node.js ≥ 20 Runtime
xurl latest Post to X
ffmpeg any Media watermarking (optional)

Quick Start

One-liner with setup script

git clone https://github.com/fal3/BlueSkyXMirror.git
cd BlueSkyXMirror
./setup.sh --handle mayorwu.boston.gov --xurl-app my-mirror
npm start

Manual setup

git clone https://github.com/fal3/BlueSkyXMirror.git
cd BlueSkyXMirror
npm install
cp .env.example .env
# Edit .env with your settings (see Configuration below)
npm start

Finding a Bluesky DID

Every Bluesky account has a DID (Decentralized Identifier). You need this to configure the mirror.

curl -s "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=someone.bsky.social" | jq .did

Or use the setup script, which resolves it automatically from the handle.

Configuration

All configuration is via environment variables or a .env file.

Variable Required Default Description
BSKY_DID Bluesky DID to mirror (e.g., did:plc:xxx)
XURL_APP xurl app name for X authentication
BSKY_HANDLE (auto-resolved) Bluesky handle for display/watermark
WATERMARK_ENABLED true Add watermark to images/videos
WATERMARK_TEXT via Bluesky · @{handle} Custom watermark text
MIRROR_REPOSTS true Mirror reposts
MIRROR_REPLIES self self (own threads), all, or none
STATE_FILE ./state.json Path to state persistence file
MAX_RETRIES 3 Retry count for failed operations
HEALTH_PORT 8080 Health check HTTP port (0 to disable)
MAX_STORED_URIS 500 Max tracked URIs in state
VIDEO_PROCESS_WAIT_MS 15000 Wait time after video upload for X processing
RECONNECT_DELAY_MS 5000 Initial WebSocket reconnect delay
JETSTREAM_URL wss://jetstream2.us-east.bsky.network/subscribe Custom Jetstream endpoint

Setting Up xurl

See docs/setup-xurl.md for the complete guide. Quick version:

# 1. Install xurl
brew install xurl-cli/tap/xurl    # macOS

# 2. Create an X Developer App at https://developer.x.com
#    Set permissions to Read+Write
#    Set callback URL to http://localhost:8739/callback

# 3. Register with xurl
xurl auth apps add my-mirror
# Follow the OAuth flow

# 4. Test it
xurl post "hello from xurl" --app my-mirror

Docker

Build and run

docker build -t bluesky-x-mirror .
docker run -d --name mirror \
  --env-file .env \
  -v $(pwd)/state.json:/app/state.json \
  -v ~/.config/xurl:/home/node/.config/xurl:ro \
  -p 8080:8080 \
  bluesky-x-mirror

Docker Compose

cp .env.example .env
# Edit .env
docker compose up -d

Running as a systemd Service

# /etc/systemd/system/bluesky-mirror.service
[Unit]
Description=BlueSkyXMirror
After=network.target

[Service]
Type=simple
User=mirror
WorkingDirectory=/opt/BlueSkyXMirror
ExecStart=/usr/bin/node src/index.js
Restart=always
RestartSec=10
EnvironmentFile=/opt/BlueSkyXMirror/.env

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now bluesky-mirror
sudo journalctl -u bluesky-mirror -f

Health Checks

# Liveness — always 200 if process is running
curl http://localhost:8080/healthz

# Readiness — 200 if receiving events, 503 if stale (>5 min)
curl http://localhost:8080/readyz

Response:

{
  "status": "ok",
  "uptime": 3600,
  "cursor": "1711500000000000",
  "lastEvent": "2026-03-26T20:00:00.000Z",
  "lastEventAgo": 5
}

Agent Setup Guide

For AI coding agents — follow these exact steps to set up a mirror from scratch:

# Step 1: Clone and install
git clone https://github.com/fal3/BlueSkyXMirror.git
cd BlueSkyXMirror
npm install

# Step 2: Resolve the Bluesky DID
HANDLE="target.bsky.social"
DID=$(curl -sf "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${HANDLE}" | grep -o '"did":"[^"]*"' | cut -d'"' -f4)
echo "DID: $DID"

# Step 3: Ensure xurl is installed and an app is registered
which xurl || echo "ERROR: xurl not installed"
xurl auth apps list  # Verify app exists

# Step 4: Create .env
cat > .env << ENVEOF
BSKY_DID=${DID}
BSKY_HANDLE=${HANDLE}
XURL_APP=my-mirror
WATERMARK_ENABLED=true
MIRROR_REPOSTS=true
MIRROR_REPLIES=self
HEALTH_PORT=8080
ENVEOF

# Step 5: Start
node src/index.js

# Step 6: Verify
sleep 5
curl -sf http://localhost:8080/healthz | jq .

Or use the setup script which does steps 2–4 automatically:

./setup.sh --handle target.bsky.social --xurl-app my-mirror
npm start

Error Reference

Error Code Cause Fix
BSKY_DID is required EXIT 1 Missing DID in config Set BSKY_DID in .env
XURL_APP is required EXIT 1 Missing xurl app name Set XURL_APP in .env
Download failed: 404 Bluesky media not found Image/video was deleted; skipped automatically
Download failed: 429 Bluesky CDN rate limit Auto-retried with backoff
No media_id in upload response xurl upload failed Check xurl app permissions (need Read+Write)
xurl post failed: 403 X API permission denied Regenerate X app with Read+Write permissions
xurl post failed: 429 X API rate limit Auto-retried; Free tier = 50 tweets/24h
WebSocket closed (1006) Network interruption Auto-reconnects with exponential backoff
WebSocket error: ECONNREFUSED Jetstream server down Auto-reconnects; try alternate Jetstream URL
/readyz returns 503 503 No events in >5 minutes Check Jetstream connection; verify DID is correct

Known Limitations

  • X Free tier: 1,500 tweets/month, 50/day per user. High-volume accounts may need Basic ($100/mo).
  • 280 character limit: Long Bluesky posts are truncated with "..." at 277 chars.
  • No image alt text on X: X's API doesn't support alt text via the upload endpoint used by xurl.
  • Video processing: X needs ~15 seconds to process uploaded videos before they can be attached to tweets.
  • No DM mirroring: Only public posts and reposts.
  • Single account: Mirrors one Bluesky account per instance. Run multiple instances for multiple accounts.

License

MIT © Alex Fallah

About

Mirror any Bluesky account's posts to X (Twitter) in real-time. Images, videos, threads, reposts. Jetstream WebSocket + xurl CLI.

Topics

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors