xwatch
English | 한국어
Collect new posts from watched X (Twitter) accounts via the official X API v2.
xwatch polls the accounts you follow, keeps only the posts each one's text filter
admits, archives them, and can notify you when something new appears. It reads only
new posts — the last post id seen per account is passed to the API as since_id,
so a poll with nothing new reads (and bills) nothing.
1. Cost
xwatch reads the official X API, which is pay-per-use — about $0.005 per post returned (as of 2026-07-28; a 2M-read monthly cap). What that means in practice:
- Watching a handful of accounts costs a few dollars a month: a poll reads only posts newer than the last one seen, so an idle account costs nothing, and dropping replies/retweets at the API cuts the read further.
- Backfilling an account reads up to its most recent ~3,200 posts once — about $16 at
the cap;
--max-posts Nbounds it. - The LLM features (summary, classification, translation) run on Gemini's free tier by default — no charge — and downloading media is a plain HTTP fetch, not the X API, so it is free too.
Client-side filtering (keywords, the ad filter) does not save reads — you already paid to
read the post; only --no-replies / --no-retweets avoid the read at the API. Prices change,
so check the current rates in the X developer portal (https://developer.x.com/en/portal/products).
2. Install
pip install xwatch
That is everything — collecting and archiving, notifying to Telegram / Slack / Discord (via pushpush), and the LLM features (summary, classification, translation). No extras to pick.
3. Set up
xwatch talks to the X API with a bearer token. Create a project and app in the X developer portal — https://developer.x.com/en/portal/dashboard — and copy its Bearer Token (the "Keys and tokens" tab).
Store the token where xwatch looks for it — a 0600 JSON file beside its config, or the environment:
mkdir -p ~/.config/xwatch
printf '{"X_BEARER_TOKEN": "%s"}\n' "$YOUR_TOKEN" > ~/.config/xwatch/credentials.json
chmod 600 ~/.config/xwatch/credentials.json
# or, one-off: export X_BEARER_TOKEN=...
4. Command-line usage
xwatch @nasa # print @nasa's latest posts (no store)
xwatch add nasa # start watching an account (from now on)
xwatch add BTS_twt --no-replies # skip that account's replies (drop conversational noise)
xwatch add realDonaldTrump --route telegram # deliver this account's posts to a channel
xwatch accounts # list watched accounts
xwatch poll # collect every account's new posts once
xwatch poll --translate Korean # deliver each post translated, above the original
xwatch poll --filter-ads # drop promotional posts from delivery (still archived)
xwatch poll --no-notify # archive new posts without sending any notification
xwatch watch --every 10 # poll every 10 minutes in the foreground
xwatch posts --handle nasa --since 2026-07-01
xwatch schedule install --every 15 # run `xwatch poll` from cron every 15 min
add starts watching from now: it marks the account's current newest post as the
starting point, so the first poll collects only posts published afterwards, not a
backfill of the recent timeline. Pass --backfill to opt into collecting recent posts
on the first poll instead.
Delivery is opt-in per account. A --route names a route you set up in pushpush — a
channel on Telegram, Slack, or Discord, named whatever you called it there. That account's
new posts are sent to it; an account with no route is archive-only, collected and stored but
never sent. So a plain xwatch add nasa watches and archives quietly, and you add a route
(e.g. --route telegram) to the accounts you want pushed to you.
A long post is never dropped for length. Each messenger sets its own text limit — Telegram's is 4,096 characters, Slack has none in practice — and a message longer than the route's limit is delivered as several messages in order (split on paragraph, then line, then word boundaries) rather than refused. The limit belongs to the messenger, so it is read from the route rather than hardcoded.
5. Coding agents
xwatch is also an installable plugin for Claude Code and Codex — this repo doubles
as a plugin marketplace. The plugin only shells out to the xwatch command, so install the
CLI first; your token stays in your own credentials file.
Claude Code
/plugin marketplace add seokhoonj/xwatch
/plugin install xwatch@xwatch
Codex
codex plugin marketplace add seokhoonj/xwatch
codex plugin add xwatch@xwatch
Then just ask — "watch @nasa and show its new posts". The skill confirms any billed X API read before it runs.
6. Python usage
The CLI is a thin shell over the library, so you can drive the same pipeline directly:
from xwatch import make_client, load_accounts, poll_accounts, read_state, FileStore
client = make_client() # bearer token from the credentials store
report = poll_accounts(load_accounts(), client, read_state(), store=FileStore()) # collect, archive, advance watermarks
for post in report.deliverable:
print(post.author, post.text[:80])
A collected Post carries the full text and its captured payload; small helpers read the parts:
from xwatch import cashtags, media_urls, translate_post, classify_ad
for post in report.deliverable:
print(cashtags(post)) # ("MU", "DRAM") -- tickers, bare
print(media_urls(post)) # image / video-thumbnail URLs
print(translate_post(post, target_language="Korean").text)
print(classify_ad(post).is_ad) # LLM ad judgment
For a one-off pull without watching, make_client() gives a Client with resolve_user
and fetch_new_posts; the archive is a FileStore you can query. import xwatch; help(xwatch) lists the full surface.
7. Filtering an account's posts
Each account can narrow what it collects:
--no-replies/--no-retweetsdrop that kind at the API, so they are never fetched (cheaper and cleaner than discarding them after).--include WORDkeeps only posts whose text contains every listed word;--exclude WORDdrops any post whose text contains a listed word (both case-insensitive, repeatable). Good for cutting promotional posts:
xwatch add BTS_twt --no-replies --exclude sponsored --exclude ad
These live in accounts.toml, so you can also edit them by hand:
[[account]]
handle = "BTS_twt"
include_replies = false
excludes = ["sponsored", "ad"]
Note: the --include/--exclude text filters trim only delivery — every fetched
post is archived regardless, and the read is unchanged (the timeline fetch still carries
those posts; the X API has no server-side text filter), which for a handful of accounts is
negligible. --no-replies/--no-retweets, by contrast, drop at the API and so cut both
the read and what is archived.
8. Ad classification with an LLM
The keyword filter only catches words you listed, so a heavily-promoting account
defeats it both ways: its ad vocabulary is product names and calls to action rather
than a fixed keyword set, so real ads slip through while an ordinary post that happens
to contain a listed word is wrongly dropped. xwatch classify judges each archived
post by its meaning instead — a small model returns is-ad plus a one-line reason — and
saves the verdict to the post's record, so the judgment is made (and billed) once and
reused:
xwatch classify --handle trader # judge this account's archived posts, save the verdicts
xwatch classify --limit 200 # only the most recent 200 (one LLM call each)
xwatch classify --reclassify # re-judge posts that already have a verdict
xwatch classify --provider claude # use Claude instead of the default (Gemini)
xwatch posts --handle trader --no-ads # hide the ads; --ads shows only them
A post already classified is skipped on the next run, so re-running classify only
spends on newly collected posts. posts --ads/--no-ads uses a post's stored verdict
when it has one and falls back to the account's keyword filter otherwise — so you can
classify only the accounts that need it and leave the rest on keywords.
The backend is pluggable (it runs on the thinchat library): the default is Google's Gemini
free tier (no per-call charge); --provider claude (or openai, ollama) switches, and
--model overrides the model. Classifying needs an API key for the chosen provider
(GEMINI_API_KEY, CLAUDE_API_KEY, ..., the same keys the summary feature uses), set in
the environment or the credentials file. On a paid backend each post is one small call, so
--limit bounds the spend; on the free tier the daily request cap does.
9. Translated, ad-filtered delivery
A poll can reshape each notification as it goes out, both opt-in and both running on the
same LLM backend as classify:
-
--translate LANGUAGErenders each post into a language, shown above the original so the source stays for reference:@trader 시장이 조정 국면에 들어섰습니다. 현금 비중을 높이세요. ────────── The market has entered a correction. Raise cash. https://x.com/trader/status/… -
--filter-adsruns the ad judgment at send time and does not deliver a post it judges promotional. The post is still archived and its verdict stored — nothing is lost, the notifications are just quieter.
Both degrade safely: a translation or classification failure delivers the original post
rather than dropping it, so an LLM outage never stalls a watch. Turn either on permanently
for a scheduled poll via config.toml, so a cron xwatch poll picks it up with no flags:
translate = "Korean" # any language name -- "Spanish", "Japanese", ...
filter_ads = true
10. What each post keeps
The archive stores the whole post, not just its visible text: the full body of a long
"note" tweet (not the truncated preview), a retweet's original text, and the post's media,
engagement metrics, and entities — cashtags ($MU, $DRAM), hashtags, mentions, and the
expanded links behind its t.co shorteners. Downloaded image and video-thumbnail files live
beside the posts under archive/media/, keyed so an image shared across a retweet is stored
once.
11. How it stays cheap and correct
since_idbounds every fetch. Only posts newer than the last one seen come back; an idle account costs nothing. Dropping replies/retweets at the API keeps even a chatty account cheap.- The watermark advances past every fetched post, even ones the text filter drops — so a filtered-out post is never re-fetched, and no post is delivered twice.
- The resolved user id is cached per handle, so a poll never re-pays to look up an account it already knows.
- Files are split by kind (the XDG layout): hand-editable config and the token in
~/.config/xwatch, the archive in~/.local/share/xwatch, run state in~/.local/state/xwatch. Resetting settings never touches the archive.
12. Where things live
| Path | What |
|---|---|
~/.config/xwatch/accounts.toml |
the watched accounts (hand-editable) |
~/.config/xwatch/config.toml |
non-secret settings (translate, filter_ads, data dir) |
~/.config/xwatch/credentials.json |
the bearer token, and any LLM provider keys (0600) |
~/.local/share/xwatch/archive/posts/ |
the collected posts, one JSON file each |
~/.local/share/xwatch/archive/media/ |
downloaded image / video-thumbnail files |
~/.local/state/xwatch/state.json |
the per-account since-id watermarks and user-id cache |
13. License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file xwatch-0.1.2.tar.gz.
File metadata
- Download URL: xwatch-0.1.2.tar.gz
- Upload date:
- Size: 97.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1d0e042cbb026a5f7cbdac444c264691b8963106ba22d20fed49b367311c196
|
|
| MD5 |
0557da2b409599b3650583da2d1dd611
|
|
| BLAKE2b-256 |
cd1746a48c0c5a2151cf254c465b259999b1efdf4d81819c2d5f2b7dd4142004
|
Provenance
The following attestation bundles were made for xwatch-0.1.2.tar.gz:
Publisher:
publish.yml on seokhoonj/xwatch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
xwatch-0.1.2.tar.gz -
Subject digest:
e1d0e042cbb026a5f7cbdac444c264691b8963106ba22d20fed49b367311c196 - Sigstore transparency entry: 2304357878
- Sigstore integration time:
-
Permalink:
seokhoonj/xwatch@1aac87027f6032efd59abe61e35f1352ce6454d7 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/seokhoonj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1aac87027f6032efd59abe61e35f1352ce6454d7 -
Trigger Event:
release
-
Statement type:
File details
Details for the file xwatch-0.1.2-py3-none-any.whl.
File metadata
- Download URL: xwatch-0.1.2-py3-none-any.whl
- Upload date:
- Size: 74.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2d71b6346eb1f3a4f38d1e86f192605b7f090b0ba84cbc16ed519f652307389f
|
|
| MD5 |
b04fac4e2840a3e6fe38c71282bc36df
|
|
| BLAKE2b-256 |
129823a0afe0ef2ca2fe20b26d0ee6d47144038c372d3134de35a2236ec46bc4
|
Provenance
The following attestation bundles were made for xwatch-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on seokhoonj/xwatch
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
xwatch-0.1.2-py3-none-any.whl -
Subject digest:
2d71b6346eb1f3a4f38d1e86f192605b7f090b0ba84cbc16ed519f652307389f - Sigstore transparency entry: 2304358061
- Sigstore integration time:
-
Permalink:
seokhoonj/xwatch@1aac87027f6032efd59abe61e35f1352ce6454d7 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/seokhoonj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1aac87027f6032efd59abe61e35f1352ce6454d7 -
Trigger Event:
release
-
Statement type: