06: Everything is JSON
Time: 20 minutes
You need: XActions installed, plus jq. No login for most of this.
You end up with: XActions as a component in your own pipelines rather than a thing you type at.
The idea
XActions prints a formatted report because that is what you want when you are looking at it. Add --json and it prints data instead, and stdout carries nothing else. That single rule is what makes everything below possible: no spinner text, no colour codes, no "Fetching..." line to strip.
xactions profile NASA --json | jq -r .name
NASA
Progress output goes to stderr, so a pipe never has to filter it. That also means you keep seeing progress while piping:
xactions tweets NASA --limit 200 --json > tweets.json # progress still shows on your terminal
xactions tweets NASA --limit 200 --json 2>/dev/null # silence it if you prefer
1. Tab completion first
Before writing anything, make the CLI type itself:
# bash
echo 'source <(xactions completion bash)' >> ~/.bashrc && exec bash
# zsh
echo 'source <(xactions completion zsh)' >> ~/.zshrc && exec zsh
# fish
xactions completion fish > ~/.config/fish/completions/xactions.fish
Now xactions <tab> lists all 56 top-level commands, xactions plugin <tab> lists its sub-commands, and xactions tweets --<tab> lists that command's flags. The script is generated from the live command tree, so regenerate it after upgrading and it picks up whatever is new.
2. When the reader is an agent, not jq
--json is the right shape for a parser. It is the wrong shape for an LLM,
which pays per token for every brace and quote. --compact prints one record
per line, tab-separated key=value, no colours and no spinner:
xactions profile NASA --compact
id=11348282 username=NASA name=NASA followers=92356563 following=117 tweets=74197 verified=false bio=Making the seemingly impossible, possible. ✨
--fields narrows it to the columns you asked for:
xactions tweets NASA --limit 3 --compact --fields id,likes,text
id=2093063226351136958 likes=1639 text=Join us tomorrow at 11am ET (1500 UTC) as the Artemis II crew receives the Congressional Space Medal of Honor! ...
id=2092962731666051534 likes=0 text=RT @LearnWithNASA: Space telescopes help us understand the origins of the universe. ...
id=2092744659667673582 likes=6753 text=A partial lunar eclipse will pass over the Americas ...
Both are global flags: put them before or after the sub-command and they
work on anything that emits records. One record per line also means cut,
awk, grep, and sort work with no JSON parser in the pipeline:
xactions tweets NASA --limit 100 --compact --fields id,likes \
| sort -t= -k3 -rn | head -5
3. Reading a timeline
xactions tweets NASA --limit 100 --json > tweets.json
# What does one post look like?
jq '.[0]' tweets.json
# Just the text
jq -r '.[].text' tweets.json
# Sorted by likes, top 5
jq -r 'sort_by(-.likes)[:5] | .[] | "\(.likes)\t\(.text[0:70])"' tweets.json
Find the field names rather than guessing them:
jq '.[0] | keys' tweets.json
["bookmarkCount","conversationId","fullText","hashtags","id","inReplyToStatusId",
"isQuote","isReply","isRetweet","likes","mentions","permanentUrl","photos","place",
"poll","quotedStatusId","replies","retweets","sensitiveContent","text","timeParsed",
"timestamp","urls","userId","username","videos","views"]
Three worth knowing up front. Media is split into photos and videos, with no combined media array. timestamp is milliseconds while timeParsed is an ISO string. And build post links from the id rather than reading permanentUrl:
jq -r '.[] | "https://x.com/i/web/status/\(.id)"' tweets.json
permanentUrl is composed from the author's handle, and X does not always send a handle on a timeline entry, so it can come back as an empty string. The /i/web/status/<id> form needs only the id and redirects to the real permalink, so it always resolves.
4. Filtering
jq does the filtering, which means you are not limited to flags anyone thought to add.
# Posts carrying media
jq '[.[] | select(((.photos | length) + (.videos | length)) > 0)] | length' tweets.json
# Original posts only, no reposts or replies
jq '[.[] | select(.isRetweet == false and .isReply == false)] | length' tweets.json
# Posts above 5000 likes, with their links
jq -r '.[] | select(.likes > 5000) | "https://x.com/i/web/status/\(.id)"' tweets.json
# Posts mentioning a word, case insensitive
jq -r '.[] | select(.text | ascii_downcase | contains("launch")) | .text' tweets.json
# Every link ever posted, deduplicated
jq -r '[.[].urls] | flatten | unique | .[]' tweets.json
# Engagement rate per post, best first
jq -r 'map(select(.views > 0))
| sort_by(-((.likes + .retweets + .replies) / .views))[:5][]
| "\((((.likes + .retweets + .replies) / .views) * 100) | .*100 | round / 100)%\t\(.text[0:60])"' tweets.json
5. Combining commands
Pipelines get interesting when one command feeds another. Find who an account talks about, then look each of them up:
xactions analyze NASA --limit 200 --json \
| jq -r '.topMentions[:5][] | .value' \
| while read -r handle; do
xactions profile "$handle" --json 2>/dev/null \
| jq -r '"\(.username)\t\(.followersCount // 0)\t\(.bio // "" | .[0:60])"'
done \
| column -t -s $'\t'
Space_Station 8898049 NASA's page for the latest mission and science updates from
NASARoman 48312 The Roman Telescope is a NASA mission that will study dark e
Astro_ChrisW 12576 @NASA Astronaut, Group 23 | Expedition 74 Flight Engineer
astro_anil 16501 NASA Astronaut aboard the ISS | Soyuz launch from Kaza
NASAHubble 8903796 The official X account for the NASA Hubble Space Telescope,
topMentions and topHashtags are arrays of {value, count}, not bare strings. Check a shape before you build on it:
xactions analyze NASA --json | jq '.topMentions[0]'
{
"value": "NASARoman",
"count": 4
}
6. CSV and spreadsheets without jq
For the common cases the CLI writes the file for you. The format follows the extension:
xactions tweets NASA --limit 500 --output nasa.csv
xactions tweets NASA --limit 500 --output nasa.xlsx
xactions followers NASA --limit 1000 --output followers.csv # needs a session
Straight into Google Sheets:
xactions tweets NASA --limit 500 \
--google-sheets <spreadsheet-id> \
--sheet-name "NASA" \
--sheet-mode replace
--sheet-mode takes append, replace, or new-sheet.
7. Exit codes
The read commands exit non-zero on failure, so set -e and && behave:
if xactions profile somehandle --json > /dev/null 2>&1; then
echo "account exists and is readable"
else
echo "not readable: private, suspended, or does not exist"
fi
xactions doctor is the one to reach for in CI: it exits non-zero when something is actually broken, so a scheduled job can check its own footing before doing work.
xactions doctor || { echo "XActions is not healthy, skipping run"; exit 1; }
xactions quickstart --json reports the machine's setup state as data, which is the cheaper check when all you need to know is whether a session exists:
tier=$(xactions quickstart --json | jq -r .tier)
[ "$tier" = "session" ] || echo "guest tier: search and followers will not work"
8. A daily digest
Putting it together. This runs on the guest tier, needs no account, and mails you nothing you did not ask for:
#!/usr/bin/env bash
# digest.sh — top posts from the accounts you follow professionally.
set -euo pipefail
ACCOUNTS=(NASA SpaceX)
SINCE=$(date -u -d '24 hours ago' +%s 2>/dev/null || date -u -v-24H +%s)
OUT="digest-$(date -u +%F).md"
xactions doctor > /dev/null || { echo "xactions unhealthy"; exit 1; }
{
echo "# Digest for $(date -u +%F)"
echo
for handle in "${ACCOUNTS[@]}"; do
echo "## @${handle}"
echo
# `timestamp` is milliseconds, so the cutoff is compared in milliseconds too.
xactions tweets "$handle" --limit 50 --json 2>/dev/null \
| jq -r --argjson since "$((SINCE * 1000))" '
[ .[]
| select(.timestamp != null and .timestamp > $since)
| select(.isRetweet == false)
]
| sort_by(-(.likes // 0))
| .[:3][]
| "- **\(.likes // 0) likes** \(.text | gsub("\n"; " ") | .[0:160])\n https://x.com/i/web/status/\(.id)"
' || echo "_no posts in the last 24h_"
echo
done
} > "$OUT"
echo "wrote $OUT"
chmod +x digest.sh && ./digest.sh
Then put it in cron:
0 8 * * * cd /home/you/digests && ./digest.sh >> digest.log 2>&1
9. When you want a library instead of a pipe
Once the shell script grows conditionals, move to Node. The same data, one import:
import { Scraper } from 'xactions/client';
const scraper = new Scraper();
const profile = await scraper.getProfile('NASA');
console.log(profile.name, profile.followersCount);
// getTweets is an async generator, not a promise for an array. It pages under
// the hood and yields as results arrive, so `break` costs nothing and memory
// stays flat no matter how big the account is.
const tweets = [];
for await (const tweet of scraper.getTweets('NASA', 100)) {
tweets.push(tweet);
}
console.log(`${tweets.length} posts`);
getFollowers, getFollowing, and searchTweets are generators too. Awaiting one directly hands you the generator object, not the data, which is the single most common mistake when moving from the CLI to the library.
The HTTP client is the same one the CLI uses for guest-tier reads: no browser, no Chromium download.
What you learned
--jsonputs data on stdout and nothing else, so pipes are always safe- Progress goes to stderr, so you keep it while redirecting
jqfilters beat waiting for someone to add a flag- Exit codes make XActions safe inside
set -escripts and cron --compactand--fieldsare the agent-shaped output: one record per line, no braces--outputhandles CSV, XLSX, and Google Sheets without any jq at all- Generators, not arrays:
for await, neverawait scraper.getTweets(...)
Next
- 05: Read any account like an analyst if you skipped it
- 04: Build a brand monitor to run continuously instead of on a schedule
- CLI reference for every flag
- REST API if you want this over HTTP instead