- name
- x-api
- description
- Use when: monitoring curated X accounts, cashtags, finance tweet pulls. Paid API ($/post). NOT general search (/research).
- user-invocable
- true
- argument-hint
- [probe USERNAME | pull --config FILE]
- allowed-tools
- ["Read","Glob","Grep","Bash","Write"]
- effort
- low
# X API v2 Client
Generic, project-agnostic interface to the X (Twitter) API v2 pay-per-use
developer tier. Built for daily-batch monitoring of a curated account list.
To read one public post or thread, use `xread <url>`: it needs no login and costs nothing.
**Authentication:** Bearer token from `X_API_BEARER_TOKEN` env var. Caller is
responsible for loading the token (e.g., from `.env.local`, `~/.env`, or
launchd plist EnvironmentVariables).
## Subcommands
### `probe USERNAME [MAX_RESULTS] [START_TIME_ISO]`
Smoke-test auth, pull recent tweets from a single account, dump JSON to
`.scratch/x_probe_<username>.json`. Reports cost.
```
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/probe.py aleabitoreddit 10
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/probe.py aleabitoreddit 100 2026-04-01T00:00:00Z
```
### `search.py query|verify|thread` — full-archive search (added 2026-08-20)
Promoted after being hand-rolled 4× across two research sessions. Wraps
`/2/tweets/search/all` with ledger discipline and the operational lessons baked in.
```
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/search.py query '"greater male variability" lang:en' --out hits.jsonl --label mytopic
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/search.py verify StuartJRitchie lakens cremieuxrecueil
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/search.py thread 1234567890 --author whyvert
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/search.py query 'immigration lang:en' --start-time 2026-06-05T00:00:00Z --end-time 2026-08-01T00:00:00Z --pages 1 --max 50 --out summer.jsonl --label summer
```
Date windows (2026-09-05): `query --start-time/--end-time` pass the official
full-archive parameters on every page. Use UTC `YYYY-MM-DDTHH:MM:SSZ`; start
is inclusive and end exclusive. Malformed dates, future dates and start >= end
fail before an API request. Both flags are optional. Keep `--pages` and `--max`
explicit for a bounded sample; without dates, returned pages do not establish
coverage of a requested historical interval. [Official endpoint reference](https://docs.x.com/x-api/posts/search-all-posts).
Evidence: the 2026-09-05 immigration review requested June–September material;
the prior CLI exposed no time parameters and its returned sample began August 10.
Rules it encodes (learned the expensive way):
- **verify handles BEFORE `from:` queries** — a wrong training-data handle reads as a
silent zero, and squatters shadow real names (<100-follower flag printed).
- **Quotes in, pure RTs out by default** (`-is:retweet` auto-appended; quotes carry the
commentary). `--with-retweets` to override.
- **A zero result means "not keyword-reachable in this phrasing," never "doesn't exist"**
— search the discourse's own vocabulary, not your methods jargon.
- 402 = vendor credit balance (developer.x.com), NOT the local $100/mo ledger cap.
- Every returned post bills $0.005 — `--pages` caps spend deliberately.
### `pull --config FILE [--since-hours 24] [--max-pages 2] [--digest-out PATH] [--tracked-tickers-file PATH] [--themes-dir PATH]`
Read a JSON account-list, pull tweets since N hours ago, filter for cashtags
and material-claim keywords, emit a markdown digest with coverage delta.
Hard cap $100/month spend (refuses to run if MTD exceeds). Cost ledger is
wallet-scoped at `~/.local/state/x-api/cost_ledger.jsonl` (NOT CWD-scoped —
spend from every repo counts against the one monthly cap).
```
uv run --project ~/Projects/skills python3 ~/Projects/skills/x-api/scripts/pull.py \
--config .claude/config/x_curated_accounts.json \
--tracked-tickers-file <(ls analysis/entities/*.md | xargs -I {} basename {} .md) \
--themes-dir analysis/themes \
--digest-out .scratch/social_digest_$(date -u +%Y-%m-%d).md
```
## Library
`scripts/x_api.py` — `get_user`, `get_user_tweets`, `CostTally`, `log_cost`,
`month_to_date_usd`. Import from external scripts via:
```python
import sys
from pathlib import Path
sys.path.insert(0, str(Path.home() / "Projects/skills/x-api/scripts"))
from x_api import CostTally, get_user, get_user_tweets, log_cost, month_to_date_usd
```
## Pricing reference (2026-05-01)
| Resource | Price | Note |
|---|---|---|
| Post read | $0.005 | Each tweet returned, not per request |
| User lookup | $0.010 | Per `/users/by/username` or `/users/:id` |
| Owned reads | $0.001 | Reads of the auth'd app's own data only |
| Monthly post-read cap | 2,000,000 | Hard limit at PPU tier |
See `~/Projects/intel/.scratch/x_api_features_research.md` for full feature map
(Lists endpoint, mentions endpoint, context_annotations).
## Architecture notes
- **Lists endpoint refactor deferred** until curated list grows past ~5
accounts. `/2/lists/:id/tweets` cuts request count ~10x for the same
per-post cost; not worth the refactor for 1-3 accounts.
- **Server-side cashtag extraction** is enabled via `tweet.fields=entities`.
Each tweet's `entities.cashtags` field returns X's own ticker tagging,
more accurate than text regex (skips `$` in money figures and quoted text).
- **Material-claim regex** is a starting heuristic. Replace with LLM
classification (Haiku/Flash) once enough volume justifies the cost.
- **Materiality is domain-configurable.** The finance/8-K keyword set is the
default; pass an optional `"material_keywords": [...]` list in the config
JSON to override it for a non-finance domain (e.g. AI-research account
monitoring). Config-driven keywords match as a leading-`\b`-anchored
PREFIX (not whole-word) — a stem like `"distill"` also catches
"distillation"/"distilled". No `material_keywords` key in the config ⇒
the finance default is unchanged (`pull.py::build_material_pattern`).
- **Zero tweets pulled for an active account is usually not a bug** — the
default `get_user_tweets` excludes replies and retweets, and a short
(24-48h) window can simply miss an account's last original post. Before
assuming a resolution/pagination/API bug, run `probe.py USERNAME N
START_ISO` with a wide window (e.g. 7d) and read the raw response: it
prints the resolved user id/name/follower count (catches wrong-account
resolution) and each tweet's exact timestamp (catches window-vs-cadence
mismatches). For accounts whose signal is mostly in-thread commentary,
set per-account `"include_replies": true` in the config — confirmed case
2026-07-20: an account's only post in a 7-day window was a reply, silently
excluded until this flag was added (`get_user_tweets(include_replies=)`).
## What this skill does NOT do
- Posting tweets, DMs, or any write actions
- Real-time streaming (Filtered Stream — defer until >50 accounts)
- Account discovery (curated list management is caller's responsibility)
- LLM claim extraction (text classification belongs in caller, model choice
varies per project)
- Auto-updates to track-record memory files (caller decides when to write)
View on GitHub