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.
- 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
/healthzand/readyzendpoints. - Configurable — Reply filtering, watermark text, repost toggle, all via env vars.
| Tool | Version | Required | Purpose |
|---|---|---|---|
| Node.js | ≥ 20 | ✅ | Runtime |
| xurl | latest | ✅ | Post to X |
| ffmpeg | any | ❌ | Media watermarking (optional) |
git clone https://github.com/fal3/BlueSkyXMirror.git
cd BlueSkyXMirror
./setup.sh --handle mayorwu.boston.gov --xurl-app my-mirror
npm startgit clone https://github.com/fal3/BlueSkyXMirror.git
cd BlueSkyXMirror
npm install
cp .env.example .env
# Edit .env with your settings (see Configuration below)
npm startEvery 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 .didOr use the setup script, which resolves it automatically from the handle.
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 |
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-mirrordocker 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-mirrorcp .env.example .env
# Edit .env
docker compose up -d# /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.targetsudo systemctl daemon-reload
sudo systemctl enable --now bluesky-mirror
sudo journalctl -u bluesky-mirror -f# 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/readyzResponse:
{
"status": "ok",
"uptime": 3600,
"cursor": "1711500000000000",
"lastEvent": "2026-03-26T20:00:00.000Z",
"lastEventAgo": 5
}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 | 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 |
- 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.
MIT © Alex Fallah