| name | fetch-tweets |
| description | Search and curate X/Twitter behind one selector - keyword, topic roundup, a single or tracked-account digest, an X list, or the AI-agent buzz preset - clustered into signal-scored sub-narratives. |
| metadata | {"title":"Fetch Tweets","category":"basics","var":"","tags":["social"],"requires":["TWITTER_API_KEY","XAI_API_KEY?"]} |
${var} — <source>:<arg> where <source> ∈ keyword | topic | account | list | agent-buzz. The <arg> is source-specific (a query, a topic, a handle, comma-separated list IDs, or an optional focus). If no source: prefix is given, the source is inferred from the shape of <arg> (see Source selector). Required for keyword and list; optional for topic, account, and agent-buzz.
Today is ${today}. This skill fetches X/Twitter content along one of five source axes and produces a curated digest — clustered by sub-narrative, ranked by signal, one insight per item — never a flat chronological dump.
Source selector
Parse ${var} into SOURCE and ARG before doing anything else.
Explicit form (recommended): <source>:<arg>
keyword:$SOL OR solana OR "solana network" - raw X search query, passed to the search verbatim (OR/AND honored; twitterapi advanced_search primary, Grok x_search fallback).
topic:brain-computer interfaces — a single topic roundup. topic: (empty arg) → resolve a topic list from MEMORY.md, then built-in defaults.
account:vitalikbuterin — one account's recent tweets. account: (empty arg) → digest every handle in memory/topics/tracked-accounts.yml.
list:1953536336675365173,1937207796270829766 — one or more numeric X list IDs. Append |<topic> for a topic booster: list:195...,193...|AI agents.
agent-buzz — the curated AI-agent-ecosystem preset. agent-buzz:MCP protocol prioritizes a project/topic within the preset.
Implicit form (back-compat with migrated bare-var configs): when ${var} has no recognized source: prefix, infer SOURCE in this order:
${var} is empty → topic (default multi-topic roundup).
${var} is all-digits, or comma-separated all-digits (optionally with a |<topic> suffix) → list.
${var} is @handle or matches ^[A-Za-z0-9_]{1,15}$ (a bare handle) → account.
- Anything else →
keyword.
Note: agent-buzz has no distinct implicit shape (its arg looks like a keyword/topic), so it is only selectable via the explicit agent-buzz / agent-buzz:... prefix.
Once SOURCE and ARG are set, jump to the matching branch below. Only one branch runs per invocation.
Shared preamble (all branches)
-
Read memory/MEMORY.md for context and the recent memory/logs/ (each branch specifies its lookback window — 2 or 3 days) to dedup already-reported tweets.
-
Load the dedup set SEEN_TWEETS by unioning two sources:
- The branch's persistent seen-file (per-mode path below), if it exists — read all URLs.
- The branch's log lookback window — grep each
memory/logs/*.md file in range for lines matching https://x.com/.
Per-mode seen-files (kept at their legacy paths so dedup history survives the merge):
| mode | seen-file | log lookback |
|---|
| keyword | memory/fetch-tweets-seen.txt | 3 days |
| topic | memory/tweet-roundup-seen.txt | 3 days |
| account | (logs only — see branch) | 2 days |
| list | memory/list-digest-seen.txt | 2 days |
| agent-buzz | (logs only — 3-day status/<id> set) | 3 days |
-
Formatting invariants shared by every branch's notification:
- Use
x.com/handle (never @handle) so Telegram doesn't ping/tag users. (Exception: the account-digest and agent-buzz formats below historically use @handle in-body; keep their documented format but prefer x.com/handle when practical.)
- Every surviving tweet gets a tappable Markdown link —
[View](url) / [View tweet](url). If a URL is unavailable, drop the link and say "(link unavailable)".
- Never fabricate engagement counts. Missing →
0, not a guess.
- Notify only on signal. A legitimately empty or all-duplicate run logs its status and sends nothing.
Voice
Used by the account and agent-buzz branches for one-line takes/insights. If soul/SOUL.md and soul/STYLE.md are populated, read both and match the operator's voice. If they are empty templates or absent, write in a clear, direct, neutral tone — state what the tweet says, no hedging or editorializing beyond the tweet itself.
Branch: keyword (source:keyword)
Search X for tweets matching ARG and produce a curated digest grouped by sub-narrative.
Seen set: memory/fetch-tweets-seen.txt + last 3 days of logs (loaded in preamble).
-
Build the search prompt. Pass ARG to Grok verbatim as the query — do NOT narrow it to a single angle; broad coverage is the goal. Ask for at least 15–20 candidate tweets (you'll cull to ~7–10). Always require explicit engagement counts (likes, retweets, replies) so ranking is data-driven.
-
Fetch tweets. Record SOURCE_PATH=twitterapi|api|websearch for the log.
FROM_DATE=$(date -u -d "yesterday" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d)
TO_DATE=$(date -u +%Y-%m-%d)
Path A - twitterapi.io (primary; see the Fetching (all branches) contract): structured X search, no prompt. Put the date window inside the query with since:. Pass ARG as the query verbatim (OR/AND honored) plus since:$FROM_DATE:
Q="${ARG} since:${FROM_DATE}"
HTTP=$(./secretcurl -s -o /tmp/tw-keyword.json -w '%{http_code}' --max-time 60 -G "https://api.twitterapi.io/twitter/tweet/advanced_search" \
--data-urlencode "query=$Q" --data-urlencode "queryType=Latest" \
-H "X-API-Key: {TWITTER_API_KEY}")
echo "twitterapi http=$HTTP bytes=$(wc -c </tmp/tw-keyword.json)"
On HTTP=200 with a non-empty .tweets[], parse and mark SOURCE_PATH=twitterapi:
jq -r '.tweets[] | [.id, .author.userName, .text, .url, .createdAt, .likeCount, .retweetCount, .replyCount] | @tsv' /tmp/tw-keyword.json
Paginate with &cursor=<next_cursor> (from .next_cursor while .has_next_page) only if you need more than the first page to reach ~15-20 candidates. Engagement counts are exact ground-truth; permalinks are the real .url.
Path B - xAI Grok x_search (fallback) (only if Path A returned non-2xx / empty / timeout, or TWITTER_API_KEY is unset; see the contract - set the Bash tool timeout >=180000, capture the HTTP status):
PROMPT="Search X for tweets about: ${ARG}. Date range: ${FROM_DATE} to ${TO_DATE}. Return at least 15-20 candidate tweets - mix of high-engagement posts and smaller accounts that add a distinct angle. For each tweet include: @handle, the full text, date posted, exact engagement counts (likes, retweets, replies - never N/A; if unknown, say 0), and the direct link (https://x.com/handle/status/ID). Return as a numbered list."
jq -n --arg p "$PROMPT" '{model:"grok-4.6", input:[{role:"user",content:$p}], tools:[{type:"x_search"}]}' > /tmp/xai-ft-keyword.json
HTTP=$(./secretcurl -s -o /tmp/xai.json -w '%{http_code}' --max-time 150 -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {XAI_API_KEY}" \
-d @/tmp/xai-ft-keyword.json)
echo "xai http=$HTTP bytes=$(wc -c </tmp/xai.json)"
Status codes: FETCH_TWEETS_OK (notified) | FETCH_TWEETS_EMPTY | FETCH_TWEETS_ERROR | FETCH_TWEETS_NO_NEW.
Branch: topic (source:topic)
Gist of the latest X chatter on one or more configurable topics.
Seen set: memory/tweet-roundup-seen.txt + last 3 days of logs.
-
Resolve the topic list (priority order):
ARG set → TOPICS=("$ARG") (single-topic mode).
- Else if MEMORY.md has a
## Tweet Roundup Topics section → use its bulleted lines, one query per line.
- Else built-in defaults:
artificial intelligence OR AI agents OR LLM
crypto OR bitcoin OR DeFi
technology OR startups OR open source
-
Fetch per topic - track SOURCE ∈ {twitterapi, api, websearch, failed} per topic.
FROM_DATE=$(date -u -d "yesterday" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d)
TO_DATE=$(date -u +%Y-%m-%d)
Path A - twitterapi.io (primary): for each topic, structured search with the topic as the query and the window inside it via since::
Q="${TOPIC} since:${FROM_DATE}"
HTTP=$(./secretcurl -s -o /tmp/tw-topic.json -w '%{http_code}' --max-time 60 -G "https://api.twitterapi.io/twitter/tweet/advanced_search" \
--data-urlencode "query=$Q" --data-urlencode "queryType=Latest" \
-H "X-API-Key: {TWITTER_API_KEY}")
echo "twitterapi http=$HTTP bytes=$(wc -c </tmp/tw-topic.json)"
On HTTP=200 with a non-empty .tweets[], parse and mark SOURCE=twitterapi. Extract each tweet's handle (.author.userName), text, engagement counts (.likeCount/.retweetCount/.replyCount), and permalink (.url); take up to the first 8 substantive tweets:
jq -r '.tweets[] | [.id, .author.userName, .text, .url, .createdAt, .likeCount, .retweetCount, .replyCount] | @tsv' /tmp/tw-topic.json
Path B - xAI Grok x_search (fallback) (only if Path A returned non-2xx / empty / timeout, or TWITTER_API_KEY unset): for each topic, call Grok's x_search.
PROMPT="Search X for recent tweets about: ${TOPIC}. Date range: ${FROM_DATE} to ${TO_DATE}. Return up to 8 substantive tweets. For each: @handle, full text, date, exact engagement counts (likes, retweets, replies; 0 if unknown), and the direct link https://x.com/handle/status/ID."
jq -n --arg p "$PROMPT" '{model:"grok-4.6", input:[{role:"user",content:$p}], tools:[{type:"x_search"}]}' > /tmp/xai-ft-topic.json
./secretcurl -s -o /tmp/xai-topic-out.json -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {XAI_API_KEY}" \
-d @/tmp/xai-ft-topic.json
Constraints: never notify an empty roundup (silence beats filler); never @handle anyone; never report a URL already in SEEN_TWEETS. Status codes: TWEET_ROUNDUP_OK | TWEET_ROUNDUP_EMPTY.
Branch: account (source:account)
Two sub-modes: single handle (decision-ready gist of one account) vs. all tracked accounts (theme-grouped digest of a watchlist). Choose by ARG.
Seen set: last 2 days of logs — extract every https://x.com/ URL under a prior ### fetch-tweets account entry into SEEN_URLS.
account — single handle (ARG is one @handle)
-
Normalize ARG. Strip leading @, https://x.com/, https://twitter.com/, https://nitter.net/, trailing slash / /status/.... Lowercase. Reject if empty, contains whitespace, or >15 chars. On reject → REFRESH_X_NO_VAR: send ./notify "fetch-tweets: REFRESH_X_NO_VAR — set an X handle" and exit 0. Store the cleaned handle as ACCOUNT.
-
Load tweets:
- Path A - twitterapi.io (primary): pull this account's recent timeline directly, then filter to the last 2 days client-side (no server-side date filter on this endpoint):
FROM_2D=$(date -u -d "2 days ago" +%Y-%m-%d 2>/dev/null || date -u -v-2d +%Y-%m-%d)
HTTP=$(./secretcurl -s -o /tmp/tw-account.json -w '%{http_code}' --max-time 60 -G "https://api.twitterapi.io/twitter/user/last_tweets" \
--data-urlencode "userName=$ACCOUNT" -H "X-API-Key: {TWITTER_API_KEY}")
echo "twitterapi http=$HTTP bytes=$(wc -c </tmp/tw-account.json)"
On HTTP=200 with a non-empty .data.tweets[], parse and mark source=twitterapi. Filter on .createdAt (drop tweets older than the window) and use .isReply to tag reply vs original (this endpoint has no quote flag - treat non-reply as original; if you need reliable quote detection fall back to Path B):
jq -r --arg FROM "$FROM_2D" '.data.tweets[] | select((try (.createdAt | strptime("%a %b %d %H:%M:%S %z %Y") | strftime("%Y-%m-%d")) catch "0000-00-00") >= $FROM) | [.id, .author.userName, .text, .url, .createdAt, .likeCount, .retweetCount, .replyCount, .isReply] | @tsv' /tmp/tw-account.json
- Path B - xAI Grok x_search (fallback) (only if Path A returned non-2xx / empty / timeout, or
TWITTER_API_KEY unset): search this account's recent tweets via Grok's x_search.
PROMPT="Search X for the latest tweets, replies, and quote tweets from @${ACCOUNT} in the last 2 days. Return each with full text, timestamp, type (original|reply|quote), what it replies to/quotes if any, exact engagement counts (likes, retweets, replies; 0 if unknown), and the permalink https://x.com/${ACCOUNT}/status/ID. Skip retweets of others. Return chronological."
jq -n --arg p "$PROMPT" '{model:"grok-4.6", input:[{role:"user",content:$p}], tools:[{type:"x_search"}]}' > /tmp/xai-ft-account.json
./secretcurl -m 30 -s -o /tmp/xai-account-out.json -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {XAI_API_KEY}" \
-d @/tmp/xai-ft-account.json
Parse with the standard extractor. Record .
Constraints: never fabricate engagement; never include a SEEN_URLS URL; an insight that only paraphrases is not an insight (drop the cluster); MEMORY.md updates are one line each. Status codes: REFRESH_X_OK | REFRESH_X_EMPTY | REFRESH_X_NO_NEW | REFRESH_X_NO_API_KEY | REFRESH_X_ERROR | REFRESH_X_NO_VAR.
account — all tracked accounts (ARG empty)
Use this to answer "what did these specific people post" across a watchlist.
-
Read config memory/topics/tracked-accounts.yml. If missing or accounts: [] → log TWEET_DIGEST_NO_CONFIG and exit (no notification). Schema:
accounts:
- handle: vitalikbuterin
why: ethereum core thinking # optional — grouping/context label
- handle: balajis
why: macro + tech narratives
-
Fetch recent tweets per account. For each handle:
- Path A - twitterapi.io (primary): pull the handle's recent timeline, filter to the last 3 days client-side, take the 5 most substantive; drop retweets of others:
FROM_3D=$(date -u -d "3 days ago" +%Y-%m-%d 2>/dev/null || date -u -v-3d +%Y-%m-%d)
HTTP=$(./secretcurl -s -o /tmp/tw-acct1.json -w '%{http_code}' --max-time 60 -G "https://api.twitterapi.io/twitter/user/last_tweets" \
--data-urlencode "userName=$HANDLE" -H "X-API-Key: {TWITTER_API_KEY}")
echo "twitterapi http=$HTTP bytes=$(wc -c </tmp/tw-acct1.json)"
On HTTP=200 with a non-empty .data.tweets[], parse and mark source=twitterapi:
jq -r --arg FROM "$FROM_3D" '.data.tweets[] | select((try (.createdAt | strptime("%a %b %d %H:%M:%S %z %Y") | strftime("%Y-%m-%d")) catch "0000-00-00") >= $FROM) | [.id, .author.userName, .text, .url, .createdAt, .likeCount, .retweetCount, .replyCount] | @tsv' /tmp/tw-acct1.json
- Path B - xAI Grok x_search (fallback) (only if Path A returned non-2xx / empty / timeout, or
TWITTER_API_KEY unset):
PROMPT="Search X for the latest tweets from:${HANDLE} in the last 3 days. Return the 5 most interesting or substantive tweets. For each: full text, date, direct link (https://x.com/${HANDLE}/status/ID). Skip retweets of others."
jq -n --arg p "$PROMPT" '{model:"grok-4.6", input:[{role:"user",content:$p}], tools:[{type:"x_search"}]}' > /tmp/xai-ft-acct1.json
./secretcurl -m 30 -s -o /tmp/xai-acct1-out.json -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {XAI_API_KEY}" \
-d @/tmp/xai-ft-acct1.json
Parse with the standard jq extractor.
If neither TWITTER_API_KEY nor XAI_API_KEY is set, log TWEET_DIGEST_NO_KEY: skill requires TWITTER_API_KEY (or XAI_API_KEY) and exit (no notification).
Dedup: drop any candidate URL already in SEEN_URLS (last 2 days of logs).
-
Status codes: TWEET_DIGEST_OK (notified or clean) | TWEET_DIGEST_NO_CONFIG | TWEET_DIGEST_NO_KEY.
Branch: list (source:list)
Cross-list narrative resonance + signal-scored top tweets from tracked X lists in the past 24h. Lists are curator signal — the value is cross-list resonance + insight + a verdict, not a flat top-N-per-list dump.
Seen set: memory/list-digest-seen.txt + last 2 days of logs.
-
Parse and validate ARG.
if [ -z "$ARG" ]; then
echo "LIST_DIGEST_NO_CONFIG: var must contain at least one X list ID" \
>> "memory/logs/$(date -u +%Y-%m-%d).md"
exit 0
fi
IDS_PART="${ARG%%|*}"
TOPIC_FILTER=""
[ "$ARG" != "$IDS_PART" ] && TOPIC_FILTER="${ARG#*|}"
for LIST_ID in $(echo "$IDS_PART" | tr ',' ' '); do
if ! [[ "$LIST_ID" =~ ^[0-9]+$ ]]; then
echo "LIST_DIGEST_NO_CONFIG: invalid list ID '$LIST_ID' (must be numeric)" \
>> "memory/logs/$(date -u +%Y-%m-%d).md"
exit 0
fi
done
If XAI_API_KEY is unset, fall back to Path B. If no path returns data, log LIST_DIGEST_NO_CONFIG: XAI_API_KEY required and stop without notifying.
-
Fetch each list's top tweets (past 24h) - xAI primary, WebSearch fallback.
Note: twitterapi.io has no list: operator (its advanced_search supports from: to: url: lang: #tag @mention since: until: only), so this branch keeps the xAI Grok x_search path as primary - it is the only path that can read an X list by ID. TWITTER_API_KEY does not apply to the list source.
Path A - X.AI Responses API (primary):
FROM_DATE=$(date -u -d "yesterday" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d)
TO_DATE=$(date -u +%Y-%m-%d)
PROMPT="Look at X list https://x.com/i/lists/${LIST_ID}. Step 1: report the list name and a one-line description. Step 2: identify the most engaging tweets posted by members of this list between ${FROM_DATE} and ${TO_DATE} UTC. Return the top 12 tweets ranked by engagement (likes, retweets, replies). For EACH tweet you MUST return: (a) @handle, (b) the full tweet text (not a paraphrase), (c) explicit engagement counts as separate fields — likes:N, retweets:N, replies:N, views:N if available, (d) the direct permalink in the form https://x.com/<handle>/status/<id>, (e) media type (image|video|none), (f) one-line context if it's a reply or quote tweet (who/what). Skip retweets of accounts NOT on this list. If a tweet has an image and you can analyze it, include a one-line image description."
jq -n --arg p "$PROMPT" --arg fd "$FROM_DATE" --arg td "$TO_DATE" \
'{model:"grok-4.6", input:[{role:"user",content:$p}], tools:[{type:"x_search", from_date:$fd, to_date:$td, enable_image_understanding:true}]}' \
> /tmp/xai-ft-list.json
./secretcurl -s -o /tmp/xai-list-out.json --max-time 180 -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {XAI_API_KEY}" \
-d @/tmp/xai-ft-list.json
Exit taxonomy: LIST_DIGEST_NO_CONFIG (var empty/invalid OR no fetch path — log only) | LIST_DIGEST_EMPTY (every list 0 tweets OR all candidates already seen — log only) | LIST_DIGEST_PARTIAL (some lists succeeded/some failed — notify survivors, surface failures) | LIST_DIGEST_OK (≥1 fresh tweet — notify).
Branch: agent-buzz (source:agent-buzz)
A topic-filtered preset: a curated, narrative-aware read on what the AI-agent scene on X talked about in the last 24h. Curation, not aggregation — 6 high-signal tweets in 2 clusters beats 10 of mixed noise. ARG (optional) is a project/topic to prioritize.
Seen set: last 3 days of logs — extract every https://x.com/.../status/<id> already posted by this skill; treat those IDs as the dedup set.
-
Fetch candidates:
FROM_DATE=$(date -u -d "1 day ago" +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d)
TO_DATE=$(date -u +%Y-%m-%d)
Path A - twitterapi.io (primary): structured search over the AI-agents conversation, window inside the query via since::
Q='("AI agents" OR "autonomous agents" OR "agent framework" OR "agent frameworks" OR MCP OR "agent protocol" OR "agent benchmark") since:'"${FROM_DATE}"
HTTP=$(./secretcurl -s -o /tmp/tw-buzz.json -w '%{http_code}' --max-time 60 -G "https://api.twitterapi.io/twitter/tweet/advanced_search" \
--data-urlencode "query=$Q" --data-urlencode "queryType=Latest" \
-H "X-API-Key: {TWITTER_API_KEY}")
echo "twitterapi http=$HTTP bytes=$(wc -c </tmp/tw-buzz.json)"
On HTTP=200 with a non-empty .tweets[], parse and mark source=twitterapi. Collect up to ~40 candidates (paginate with &cursor=<next_cursor> while .has_next_page if the first page is thin):
jq -r '.tweets[] | [.id, .author.userName, .author.isBlueVerified, .text, .url, .createdAt, .likeCount, .retweetCount, .replyCount] | @tsv' /tmp/tw-buzz.json
Note: advanced_search does not return follower_count or role_guess. On this path treat both as null (the step-3 role multiplier and the follower-ratio skip-gates simply don't fire); use .author.isBlueVerified as a weak credibility proxy and lean on raw engagement. If ARG is set, append its terms to Q (AND) or issue a second constrained search and merge.
Path B - xAI Grok x_search (fallback) (only if Path A returned non-2xx / empty / timeout, or TWITTER_API_KEY unset; the response for each tweet must include explicit engagement counts + follower count, or step 3 scoring can't run):
PROMPT="Search X from ${FROM_DATE} to ${TO_DATE} for tweets in the AI-agents conversation: autonomous agents, agent frameworks, MCP / agent protocols, agent products, agent benchmarks, agent research papers. Return up to 40 candidates. For EACH candidate you MUST return: @handle, follower_count (integer or null), role_guess (builder|founder|researcher|investor|commentator|anon), one-line claim (what they actually said - not a paraphrase, the thesis), likes (int), retweets (int), replies (int), posted_at (ISO), direct_link (https://x.com/username/status/ID). Prefer builders/founders/researchers. Skip obvious engagement-farming threads (\"RT if you agree\", reply-guy pileons, giveaways)."
jq -n --arg p "$PROMPT" --arg fd "$FROM_DATE" --arg td "$TO_DATE" \
'{model:"grok-4.6", input:[{role:"user",content:$p}], tools:[{type:"x_search", from_date:$fd, to_date:$td}]}' \
> /tmp/xai-ft-buzz.json
./secretcurl -s -o /tmp/xai-buzz-out.json -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {XAI_API_KEY}" \
-d @/tmp/xai-ft-buzz.json
Status codes: AGENT_BUZZ_OK (≥1 cluster notified) | AGENT_BUZZ_EMPTY (fetch succeeded, nothing survived — send Agent Buzz — ${today}: quiet day, no survivors.) | AGENT_BUZZ_ERROR (all sources failed — notify Agent Buzz — ${today}: all sources failed (${error summary}). and log the per-source failure).
Log (all branches)
Append ONE entry per run to memory/logs/${today}.md under a single ### fetch-tweets heading (the health loop parses this shape). The first bullet is the discriminator naming the branch/mode that ran; the rest are branch-specific bullets. Always include the reported tweet URLs as bullets (for next-run dedup).
### fetch-tweets
- mode: <keyword|topic|account|list|agent-buzz>
- status: <STATUS_CODE for the branch that ran>
- source: <SOURCE_PATH / per-source counts / per-list outcome, as applicable>
- <branch-specific bullets — carry over each branch's fields:>
- keyword: signal one-liner; per-cluster URLs with `likes:N rts:N replies:N` + insight
- topic: `topics: [t1: N tweets, t2: 0 (dropped)]`; `source: cache:X websearch:Y failed:Z`
- account(1): Verdict + lede; Counts (N tweets, X orig/Y reply/Z quote, T threads, deduped K); Clusters; Threads; Vibe
- account(all):themes covered; per-account tweet counts
- list: Lists tracked; Per-list `list1=ok(N) | list2=quiet(N) | list3=error`; Verdict; Narratives count
- agent-buzz: source used; candidates N → kept M; cluster names
- urls:
- https://x.com/handle1/status/...
- https://x.com/handle2/status/...
On empty/no-new/error/no-config statuses, write the ### fetch-tweets heading + mode: + status: bullets only (skip the detail sections) so skill-health still observes the run. After logging, update the branch's persistent seen-file where one exists (keyword / topic / list — see the seen-file table).
Output shape note
No chain consumes this skill's output as of this commit (no consume: [fetch-tweets] references). If a downstream chain step starts consuming it, emit a flat list of URLs before the clustered/branch output so consumers aren't broken by cluster or narrative headers.
Fetching (all branches)
Every branch fetches X in a three-tier cascade. Attempt each tier in order; only drop to the next on a real failure (non-2xx, empty body, or timeout), and always record the true reason.
Path A - twitterapi.io (primary). TWITTER_API_KEY is injected into your environment for this skill (declared in requires:). The primary fetch path in every branch (except list, which has no twitterapi list operator) is a direct curl to https://api.twitterapi.io/twitter/... with the X-API-Key: {TWITTER_API_KEY} header. It is fast (~700ms), returns structured ground-truth JSON with exact engagement counts and real permalinks (.url) - so there is no fabrication risk and no prompt to coax. Two endpoints:
- Search:
GET /tweet/advanced_search?query=<q>&queryType=Latest -> {tweets:[...], has_next_page, next_cursor}. Put date windows inside the query with X operators since:YYYY-MM-DD until:YYYY-MM-DD; other operators: from: to: url: lang: #tag @mention, plus OR/AND and quotes. Tweets are under .tweets[]. (There is no list: operator - see the list branch.)
- Timeline:
GET /user/last_tweets?userName=<handle> -> {status:"success", data:{tweets:[...]}, has_next_page, next_cursor}. No server-side date filter - filter client-side on .createdAt or paginate with &cursor=<next_cursor>. Tweets are under .data.tweets[].
Fields: id, text, url, createdAt, likeCount, retweetCount, replyCount, quoteCount, viewCount, author.userName, author.name, author.isBlueVerified (last_tweets also isReply, author.followers). Capture the status with -w '%{http_code}' and print http=<code> before deciding anything. HTTP=200 with a non-empty tweet array -> use it (SOURCE_PATH=twitterapi). No prefetch, no cache - just make the call.
Path B - xAI Grok x_search (fallback). Only when Path A fails (non-2xx / empty / timeout) or TWITTER_API_KEY is unset. Direct curl to https://api.x.ai/v1/responses with Authorization: Bearer {XAI_API_KEY} using Grok's x_search tool. The x_search call typically takes 30-120s (it searches X live), so set the Bash tool timeout to at least 180000 (180s) and add --max-time 150 to the curl so it fails cleanly rather than hanging. A slow curl is not a missing key. Build the JSON body to a fixed file with jq -n first, then send with -d @file - every ./secretcurl command must be 100% literal (no $VAR, or the permission layer blocks it). Parse with the standard extractor:
jq -r '.output[] | select(.type == "message") | .content[] | select(.type == "output_text") | .text' /tmp/xai.json
HTTP=200 with a non-empty body -> use it (SOURCE_PATH=api).
Path C - WebSearch / WebFetch (last resort). Only when both A and B fail (or no key is set at all). Lowest quality (WebSearch favours old high-engagement tweets; WebFetch reads a public profile page). Never reach for it while a key works.
Reason codes (record the true one on every fallback - never write "XAI_API_KEY unavailable" / "sandbox" / "expansion blocked" when a key was set): key-unset (the relevant key was actually empty), http-<code> (non-2xx), empty (200 but no tweets parsed), timeout (curl exceeded --max-time).
Environment Variables
TWITTER_API_KEY - twitterapi.io API key (sent as the X-API-Key header). Declared in requires:, so it is injected into this skill's environment; it is the primary fetch path for every branch except list (twitterapi has no list operator). Fast, structured ground-truth with exact engagement counts and real permalinks.
XAI_API_KEY - X.AI API key for Grok's x_search tool (declared XAI_API_KEY?, optional). The fallback path when TWITTER_API_KEY is unset or twitterapi returns non-2xx / empty / timeout, and the primary path for the list branch. If both keys are unset, branches degrade to WebSearch/WebFetch at lower quality; the account (all) sub-mode instead hard-exits (TWEET_DIGEST_NO_KEY).