Account Portability — Export, Migrate & Diff
Export your full X/Twitter account, migrate to Bluesky/Mastodon, and track changes over time — no API fees.
Overview
XActions Portability is a complete data ownership toolkit:
- Export — Download your entire account (profile, tweets, followers, following, bookmarks, likes) in JSON, CSV, and Markdown
- Archive Viewer — Self-contained offline HTML file to browse your data with search, pagination, and dark theme
- Migrate — Move your social graph to Bluesky (AT Protocol) or Mastodon (ActivityPub) with user matching
- Diff — Compare two exports to see what changed: new followers, lost followers, deleted tweets, engagement shifts
Available via: CLI, API, MCP tools (for AI agents).
Quick Start
Export your account (CLI)
# Full export — all data, all formats
unfollowx export @yourname --auth-token YOUR_TOKEN
# Export only tweets and followers in JSON
unfollowx export @yourname --only tweets,followers --formats json --auth-token YOUR_TOKEN
# Limit to 100 items per category
unfollowx export @yourname --limit 100 --auth-token YOUR_TOKEN
Migrate to another platform (CLI)
# Dry-run migration to Bluesky (shows what would happen)
unfollowx migrate @yourname --platform bluesky --dry-run --auth-token YOUR_TOKEN
# Migrate to Mastodon
unfollowx migrate @yourname --platform mastodon \
--instance mastodon.social --mastodon-token YOUR_MASTODON_TOKEN \
--auth-token YOUR_TOKEN
Compare two exports (CLI)
# Diff two export directories
unfollowx diff exports/user_jan2026 exports/user_feb2026
# Generates a Markdown report showing gained/lost followers, new/deleted tweets, etc.
Architecture
src/portability/
├── exporter.js → Full account export orchestrator with checkpoint resume
├── archive-viewer.js → Self-contained HTML archive generator
├── importer.js → Bluesky & Mastodon migration (user matching via Dice coefficient)
├── twitter-archive.js → Official X data export (zip or folder) importer, summary, export bridge
├── differ.js → Export comparison engine (followers, tweets, engagement)
└── index.js → Barrel re-exports
api/routes/portability.js → REST API endpoints
Export Flow
exportAccount({ username, formats, only, limit })
├── Phase 1: Scrape profile
├── Phase 2: Scrape tweets
├── Phase 3: Scrape followers
├── Phase 4: Scrape following
├── Phase 5: Scrape bookmarks
└── Phase 6: Scrape likes
↓
Write JSON / CSV / Markdown to exports/<username>_<date>/
↓
Generate archive.html (self-contained offline viewer)
↓
Checkpoint saved after each phase (resume on failure)
Diff Flow
diffExports(dirA, dirB)
├── Compare followers → gained[], lost[]
├── Compare following → added[], removed[]
├── Compare tweets → new[], deleted[]
└── Compare engagement → changes per tweet
↓
generateReport(diff) → Markdown summary
API Reference
Export
POST /api/portability/export
Content-Type: application/json
{
"username": "elonmusk",
"formats": ["json", "csv"],
"only": ["profile", "tweets", "followers"],
"limit": 500,
"authToken": "your_auth_token"
}
Response: { id: "export_abc123", status: "started" }
GET /api/portability/export/:id # Check progress
GET /api/portability/export/:id/download # Download archive
GET /api/portability/exports # List all exports
Migrate
POST /api/portability/migrate
Content-Type: application/json
{
"username": "yourname",
"platform": "bluesky",
"dryRun": true,
"authToken": "your_auth_token"
}
Diff
POST /api/portability/diff
Content-Type: application/json
{
"dirA": "exports/user_jan2026",
"dirB": "exports/user_feb2026"
}
Response: Full diff object with gained, lost, added, removed, newTweets, deletedTweets, engagementChanges.
MCP Tools (AI Agents)
| Tool | Description |
|---|---|
x_export_account |
Export a full X/Twitter account to JSON/CSV/Markdown |
x_migrate_account |
Migrate social graph to Bluesky or Mastodon |
Example (Claude Desktop)
"Export @nichxbt's full account to JSON and generate an HTML archive"
The AI agent calls x_export_account with { username: "nichxbt", formats: ["json"], authToken: "..." }.
Import your X archive
You do not need to scrape your own account. X will hand you everything as a zip (Settings > Your account > Download an archive of your data; it arrives a day or so later). importTwitterArchive reads that download, either the .zip itself or the folder you extracted it to, and returns the same normalised records the rest of the portability toolkit works with. Zips are streamed entry by entry, so a multi-gigabyte archive with years of media is fine on a laptop: only one data/*.js section file is held in memory at a time, and media files are indexed, not read.
import {
importTwitterArchive,
summarizeArchive,
formatArchiveReport,
exportArchive,
openArchiveMedia,
} from './src/portability/index.js';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
const archive = await importTwitterArchive('twitter-2026-01-01-abc123.zip', {
onProgress: (p) => process.stderr.write(`${p.phase} ${p.file}\n`),
});
console.log(formatArchiveReport(summarizeArchive(archive)));
// tweets, likes, following, followers, blocks, mutes, dms, lists, media, account, profile
const replies = archive.tweets.filter((t) => t.inReplyTo);
console.log(`${replies.length} of ${archive.tweets.length} tweets are replies`);
// Write it out in the same layout `export` produces (JSON, CSV, Markdown, HTML viewer)
const { dir } = await exportArchive(archive, { outputDir: 'exports/me_archive' });
console.log(`open ${dir}/index.html`);
// Pull a photo straight out of the zip
const first = archive.media[0];
await pipeline(await openArchiveMedia(archive, first.path), createWriteStream(first.file));
Save that as archive.mjs in the repo root and run node archive.mjs (from an installed package, import from xactions/src/portability/index.js).
What you get
| Field | Shape |
|---|---|
account |
{ id, username, name, email, createdAt, createdVia } |
profile |
{ username, name, bio, website, location, avatarUrl, headerUrl } |
tweets[] |
{ id, text, createdAt, url, inReplyTo: { tweetId, userId, username } | null, retweeted, media: [{ id, type, url, previewUrl, file }], metrics: { likes, retweets }, hashtags, mentions, links, lang, source } plus the flat timestamp, likes, retweets fields the exporter and differ read |
likes[] |
{ id, text, url } |
following[], followers[], blocks[], mutes[] |
{ id, url } (the archive only carries account ids) |
dms[] |
one entry per conversation: { id, kind: 'direct' | 'group', participants, messageCount, firstMessageAt, lastMessageAt, messages: [{ id, senderId, recipientId, text, createdAt, media, links, reactions }], events } |
lists[] |
{ kind: 'created' | 'member' | 'subscribed', name, url, description } |
media[] |
{ path, file, dir, size, tweetId, kind } for every file under data/*_media/ |
sections |
{ present, missing } so you can tell an empty section from one the archive does not include |
Multi-part files (tweets.js, tweets-part1.js, ...) are merged in part order. Pass sections: ['tweets', 'likes'] to skip everything else, which is the fast path on a large archive.
Summary report
summarizeArchive(archive) returns counts per section, the tweet date range, tweets per year, the busiest year, top hashtags and mentions, and likes and retweets received. formatArchiveReport(summary) renders it as terminal text:
X archive for @nichxbt (zip)
Account created: 2019-03-01
Tweets span: 2024-01-01 to 2025-03-16
Tweets 5 (3 original, 1 replies, 1 retweets, 2 with media)
Likes 2
Following 3
...
Top hashtags
#xactions 3
Migrate straight from the archive
migrate accepts source: 'twitterArchive'; it imports the archive, writes tweets.json and following.json to exportDir, then runs the normal dry-run or live flow:
import { migrate } from './src/portability/index.js';
const summary = await migrate({
platform: 'bluesky',
source: 'twitterArchive',
archivePath: 'twitter-2026-01-01-abc123.zip',
exportDir: 'exports/me_archive',
dryRun: true,
});
From the command line
All three flows are on the CLI as xactions archive, with no code to write:
xactions archive summary twitter-2026-01-01-abc123.zip # the report above, or --json
xactions archive export twitter-2026-01-01-abc123.zip --out exports/me --formats json,csv,md,html
xactions archive migrate twitter-2026-01-01-abc123.zip --to bluesky # dry run; add --execute --handle --password to post
export writes the same layout xactions export produces, so xactions diff and xactions migrate accept the directory. A spinner reports each section as it is scanned; under --json on a pipe it stays silent. Flags and sample output: cli-reference.md.
Export Formats
| Format | Contents |
|---|---|
| JSON | profile.json, tweets.json, followers.json, following.json, bookmarks.json, likes.json |
| CSV | Same data in spreadsheet-friendly format |
| Markdown | Human-readable summaries per category |
| HTML | archive.html — self-contained dark-theme viewer with search, tabs, and pagination |
Archive Viewer Features
- Tabs: Profile, Tweets, Followers, Following, Bookmarks, Likes
- Search: Full-text search across all sections
- Pagination: 50 items per page with navigation
- Dark theme: Matches X's aesthetic
- Offline: No external dependencies, works without internet
Checkpoint Resume
Exports save a .checkpoint.json file after each phase. If the browser crashes or the script is interrupted, re-running the export will resume from the last completed phase — no duplicate work.
Migration Details
Bluesky
- Connects via AT Protocol (
bsky.social) - Finds matching accounts using Dice-coefficient string similarity on display names
- Dry-run shows all planned actions before executing
- Requires Bluesky credentials (
handle+password)
Mastodon
- Connects via Mastodon REST API
- Searches for matching accounts on the target instance
- Supports any Mastodon-compatible instance (Pleroma, Akkoma, etc.)
- Requires instance URL + API token
Configuration
| Option | Default | Description |
|---|---|---|
formats |
['json', 'csv', 'md'] |
Output formats |
only |
all | Subset: profile, tweets, followers, following, bookmarks, likes |
limit |
unlimited | Max items per category |
outputDir |
exports/<user>_<date> |
Output directory |
dryRun |
false |
Preview migration without making changes |