Search X/Twitter for tweets about a token, keyword, username, or topic — clustered by sub-narrative
Scanned 6/6/2026
Install to Claude Code
npx -y skills add anajuliabit/aeon --skill fetch-tweets --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Fetch Tweets?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/anajuliabit-fetch-tweets)More formats (shields.io, HTML) on the badges page.
---
name: Fetch Tweets
description: Search X/Twitter for tweets about a token, keyword, username, or topic — clustered by sub-narrative
var: ""
tags: [social]
---
<!-- autoresearch: variation B — sharper output via clustering + signal line + insight extraction -->
> **${var}** — Search query for X/Twitter. **Required** — set your query in aeon.yml.
Today is ${today}. Search X for tweets matching **${var}** and produce a *curated* digest grouped by sub-narrative — not a flat chronological list.
## Steps
1. **Load previously-reported tweet URLs** from two sources, then union them into `SEEN_TWEETS`:
- **Persistent seen-file** (`memory/fetch-tweets-seen.txt`) — if it exists, read all URLs.
- **Last 3 days of `memory/logs/`** — grep each log file for lines matching `https://x.com/`.
You'll use `SEEN_TWEETS` in step 5 to filter duplicates.
2. **Build the search prompt for Grok.** Pass `${var}` to Grok **verbatim** as the search query — let Grok interpret OR/AND operators in the var as-is. Do NOT narrow it to a single angle; broad coverage is the goal.
Ask Grok to return **at least 15–20 candidate tweets** (you'll cull to ~7–10 in curation). Always require explicit engagement counts (likes, retweets, replies) so ranking is data-driven, not vibes.
3. **Search tweets.** Use whichever path is available, and **record which path produced the result** (you'll log it in step 6 as `SOURCE_PATH=cache|api|websearch`):
**Path A — pre-fetched cache** (preferred, when the workflow ran `scripts/prefetch-xai.sh`):
```bash
cat .xai-cache/fetch-tweets.json 2>/dev/null | jq -r '.output[] | select(.type == "message") | .content[] | select(.type == "output_text") | .text'
```
**Path B — X.AI API** (fallback, when `XAI_API_KEY` is set and cache is empty):
```bash
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)
curl -s -X POST "https://api.x.ai/v1/responses" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XAI_API_KEY" \
-d '{
"model": "grok-4-1-fast",
"input": [{"role": "user", "content": "Search X for tweets about: ${var}. 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."}],
"tools": [{"type": "x_search"}]
}'
```
Parse with: `jq -r '.output[] | select(.type == "message") | .content[] | select(.type == "output_text") | .text'`
**Path C — WebSearch fallback** (use when both cache and `XAI_API_KEY` are unavailable):
Use the built-in WebSearch tool with a query like `site:x.com "${query_terms}" after:${FROM_DATE}`. Note at the top of the log entry: "XAI_API_KEY not available; results compiled via WebSearch — quality lower than usual". WebSearch tends to favour high-engagement older tweets — **prioritise results dated within the last 48 hours**.
4. **Empty-data handling** (distinguish empty from error):
- **Legitimate empty** (API returned 0 tweets, no results): log `FETCH_TWEETS_EMPTY (source=${SOURCE_PATH})` to `memory/logs/${today}.md` and **stop — no notification**.
- **API/cache error** (HTTP error, malformed JSON, all paths failed): log `FETCH_TWEETS_ERROR (last_path=${SOURCE_PATH}, reason=...)` so skill-health can pick it up, and **stop — no notification**.
5. **Deduplicate against `SEEN_TWEETS` from step 1.** Compare each candidate URL against the set. Remove any tweet already reported. If ALL candidates are dupes: log `FETCH_TWEETS_NO_NEW: all results already reported` and **stop — no notification**.
6. **Curate — this is the new core step.** Don't dump all surviving tweets into the notification. Instead:
a. **Cluster** the surviving candidates into 2–4 sub-narratives based on what they're actually claiming or discussing. Examples for a token query: "price action", "team announcement", "criticism/FUD", "ecosystem integration". Examples for a person query: "their new post", "responses to it", "unrelated activity". Pick cluster names that describe *the angle*, not the topic itself.
b. **Within each cluster, rank by signal**, not just engagement. Signal = (likes + 2×retweets + replies) but **demote** tweets that are pure replies, generic shilling, or near-duplicate paraphrases of another already-included tweet. Drop tweets with <5 total engagement unless they come from an account that adds a unique angle.
c. **Cap each cluster at 2–3 tweets** and total at **7–10 tweets**. Quality over quantity — if only 5 tweets pass the bar, send 5. Do not pad.
d. **Extract the claim or signal** for each tweet — the summary should say *what's new or interesting*, not paraphrase the literal text. Bad: "User says token is going up." Good: "Calls out the team's silence on the postponed unlock — first major holder to do so publicly."
e. **Compute a one-line signal** for the top of the notification: a single observation about the *shape* of the conversation. Examples: "Sentiment split — 4 bullish on the launch, 3 critical of the unlock terms." or "Mostly quiet — 6 of 7 tweets are from the same 3 accounts repeating yesterday's narrative." This is the most valuable single sentence for a reader scanning fast.
7. **Save the results** to `memory/logs/${today}.md`. Include for each kept tweet: URL, handle, engagement counts, the cluster it belongs to, and the source path used. Format:
```
### fetch-tweets (${var})
- source_path: cache
- signal: [the one-liner from step 6e]
- cluster: [name]
- https://x.com/handle/status/ID — likes:N rts:N replies:N — [insight summary]
```
7b. **Update the persistent seen-file** — append each new tweet URL (one per line) to `memory/fetch-tweets-seen.txt`. Create the file if it doesn't exist.
8. **Send a notification via `./notify`** with the curated, clustered output. Format:
```
*Top Tweets — ${var} (${today})*
_${signal_one_liner}_
*${cluster_1_name}*
1. x.com/handle — [insight summary]
Likes: X | RTs: Y | Replies: Z
[View tweet](https://x.com/handle/status/ID)
2. x.com/handle — [insight summary]
Likes: X | RTs: Y | Replies: Z
[View tweet](https://x.com/handle/status/ID)
*${cluster_2_name}*
3. x.com/handle — [insight summary]
...
```
IMPORTANT formatting rules:
- Use `x.com/handle` (NOT `@handle`) so Telegram doesn't ping/tag users.
- Each tweet MUST have a `[View tweet](URL)` link — required so users can tap to open it.
- Use Telegram Markdown link format: `[link text](url)`.
- The signal one-liner goes in italics (`_..._`) directly under the title.
- Cluster headers use `*bold*`.
## Output shape note
This skill has no chain consumers as of this commit (no `consume: [fetch-tweets]` references). If a downstream chain step starts consuming this output, emit a flat list of URLs before the clustered output so consumers aren't broken by cluster headers.
## Sandbox note
Sandbox may block outbound curl. Path A (cache) avoids the issue entirely. If Path B is needed and curl fails, fall back to Path C (WebSearch) — it's a built-in Claude tool and bypasses the sandbox.
## Environment Variables Required
- `XAI_API_KEY` — X.AI API key (optional; skill falls back to WebSearch when not set, but quality is lower).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!