| name | x-bookmark-briefing |
| description | Automates the process of creating a daily briefing from your X (Twitter) bookmarks. It scrapes recent bookmarks, filters out low-value replies, categorizes the content, saves a report, and emails it to you. Finally, it commits and pushes the report to a private GitHub repository (supporting both `gh` automation and manual Git remotes). |
X Bookmark Briefing Skill
Description
This skill automates the process of creating a daily briefing from your X (Twitter) bookmarks. It scrapes recent bookmarks (capped at 25/40), filters out low-value replies, categorizes the content, saves a report, and emails it to you. Finally, it commits and pushes the report to a private GitHub repository (supporting both gh automation and manual Git remotes).
Behavior
User can invoke this skill with simple commands:
- "run"
- "Run the X Bookmark Briefing"
- "Check my X bookmarks and email me a summary"
- "Scrape my bookmarks, summarize them, and push to GitHub"
Agent Behavior:
When this skill is invoked, the agent must:
- Execute the complete workflow (scrape → analyze → save → email → git push)
- Automatically use the
/google-workspace skill for email delivery (no need for user to specify)
- Handle all steps autonomously unless user input is required (e.g., missing config, login needed, or optional extended scraping)
Prerequisites
- Required Skill:
/google-workspace (for email delivery)
- Agent Instruction: If
/google-workspace is not available when attempting to send email, inform the user: "This skill requires the /google-workspace skill to send emails. Please add it to continue."
Workflow Steps
1. Configuration & Directory Check
Agent Instruction:
- Detect Operating System:
- Determine the current OS (macOS, Windows, or Linux).
- Set base paths accordingly:
- macOS/Linux:
OUTPUT_DIR = ~/Documents/X_Briefings/, CONFIG_DIR = ~/.accomplish/x-briefing/
- Windows:
OUTPUT_DIR = %USERPROFILE%\Documents\X_Briefings\, CONFIG_DIR = %USERPROFILE%\.accomplish\x-briefing\
- Check Config: Look for config file at
{CONFIG_DIR}/config.json.
- Expected format:
{
"GMAIL_TO_EMAIL": "your@email.com",
"SCRAPE_LIMIT": 25,
"AUTO_CONTINUE": false,
"CATEGORIES": ["Backend", "DevOps", "Platform", "Agents", "DSA", "AI", "Hiring", "System Design", "Resources", "Other"],
"FILTER_REPLIES": true
}
- If missing:
- Inform the user: "For future automation, please create the config file at
{CONFIG_DIR}/config.json with your email: {\"GMAIL_TO_EMAIL\": \"your@email.com\"}"
- Ask: "What email address should I use for this briefing?"
- Store the provided email for use in Step 5.
- If possible, create the config file with provided email and default values.
- If exists:
- Read
GMAIL_TO_EMAIL (required)
- Read optional:
SCRAPE_LIMIT (default: 25), AUTO_CONTINUE (default: false), CATEGORIES (default list), FILTER_REPLIES (default: true)
- Store all values for use in subsequent steps.
- Check Output Directory:
- Target:
{OUTPUT_DIR} (OS-specific path from Step 1.1)
- Check if it exists. If not, create it.
- Load Checkpoint (Duplicate Prevention):
- Check for
{CONFIG_DIR}/last_scrape.json.
- Expected format:
{
"lastScrapedIds": ["link1", "link2", "link3", "link4", "link5"],
"lastScrapeDate": "YYYY-MM-DD",
"lastScrapeTimestamp": "YYYY-MM-DDTHH:MM:SSZ",
"totalScraped": 25
}
- If exists: Parse JSON and read
lastScrapedIds array, lastScrapeDate, and lastScrapeTimestamp.
- If missing or invalid format: Set
lastScrapedIds = [], lastScrapeDate = null, lastScrapeTimestamp = null (first run).
- Store these values for use in Step 1.5 and Step 2.
1.5. Pre-flight Validation
Agent Instruction:
- Validate Email Format:
- Check if
GMAIL_TO_EMAIL is a valid email format (contains @ and domain).
- If invalid: Ask user to provide a valid email address.
- Check Scrape Frequency:
- If
lastScrapeTimestamp exists, calculate time difference from now.
- If less than 6 hours: Warn user "Last scrape was {X} hours ago. Running too frequently may trigger rate limits. Continue anyway?"
- If user says NO: Stop workflow.
- If user says YES or time > 6 hours: Continue.
2. Browser Scraping (Strict Limit & Human Behavior)
Agent Instruction:
- Launch the browser and navigate to
https://x.com/i/bookmarks.
- Check for login: If missing, ask user to log in and wait.
- Phase A: Scrape Initial Batch
- Use
SCRAPE_LIMIT from config (default: 25).
- Execute the script below with
LIMIT = SCRAPE_LIMIT and lastScrapedIds from Step 1.
- If
lastScrapedIds is not empty, inform user: "Checking for new bookmarks since {lastScrapeDate}..."
- Show progress: Update user every 5 bookmarks: "Scraped {current}/{SCRAPE_LIMIT} bookmarks..."
- Handle Scraping Results:
- If scraping fails (0 bookmarks and errors exist):
- Inform user: "Scraping failed. Retrying in 5 seconds..."
- Wait 5 seconds and retry once.
- If still fails: "Unable to scrape bookmarks. Please verify you're logged in and try again." Stop workflow.
- If 0 bookmarks scraped (no errors):
- Inform user: "No bookmarks found. Your bookmark list may be empty." Stop workflow.
- Phase B: Smart Continuation Logic
- If
hitCheckpoint = true:
- Inform user: "Found {count} new bookmarks since last scrape. Filtered out {filteredCount} low-value replies."
- Proceed to Step 3.
- If scraped SCRAPE_LIMIT without hitting checkpoint:
- If
AUTO_CONTINUE = true: Automatically continue with 15 more.
- If
AUTO_CONTINUE = false: Ask user: "I have scraped {SCRAPE_LIMIT} bookmarks. Do you want me to continue scraping more (max 15 more)?"
- If YES: Execute script with
LIMIT = 15, MODE = "extra_slow", and lastScrapedIds. Combine results. Show progress.
- If NO: Proceed with the scraped bookmarks.
Scraper Script Logic:
(async function() {
async function scrapeBookmarks(limit = 25, mode = "normal", lastScrapedIds = [], filterReplies = true) {
const tweets = [];
const seenIds = new Set();
const errors = [];
const filtered = { replies: 0, total: 0 };
let hitCheckpoint = false;
let checkpointId = null;
const wait = (min, max) => new Promise(r => setTimeout(r, Math.floor(Math.random() * (max - min + 1)) + min));
const simulateHumanInteraction = async () => {
const scrollAmount = Math.floor(Math.random() * 100) - 50;
window.scrollBy(0, scrollAmount);
if (Math.random() > 0.7) await wait(500, 1500);
const elements = document.querySelectorAll('article');
if (elements.length > 0) {
const randomEl = elements[Math.floor(Math.random() * elements.length)];
randomEl.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
}
};
const extractLinks = (article) => {
const links = [];
const linkElements = article.querySelectorAll('a[href^="http"]:not([href*="/status/"])');
linkElements.forEach(el => {
const href = el.href;
if (!href.includes('t.co') && !href.includes('twitter.com') && !href.includes('x.com')) {
links.push(href);
}
});
return links;
};
let noNewTweetsCount = 0;
let scrollAttempts = 0;
const maxScrollAttempts = mode === "extra_slow" ? 20 : 15;
while (tweets.length < limit && noNewTweetsCount < 3 && scrollAttempts < maxScrollAttempts && !hitCheckpoint) {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
let addedNew = false;
for (const article of articles) {
if (tweets.length >= limit || hitCheckpoint) break;
try {
const textEl = article.querySelector('[data-testid="tweetText"]');
if (!textEl) continue;
const text = textEl.innerText;
const userEl = article.querySelector('[data-testid="User-Name"]');
const linkEl = article.querySelector('a[href*="/status/"]');
const timeEl = article.querySelector('time');
const link = linkEl ? linkEl.href : null;
if (link && lastScrapedIds.length > 0 && lastScrapedIds.includes(link)) {
hitCheckpoint = true;
checkpointId = link;
break;
}
const isReply = text.includes("Replying to @");
let shouldKeep = true;
if (filterReplies && isReply) {
const keywords = ["http", "github", "resource", "hiring", "job", "guide", "learn", "tool", "ai", "platform", "check this", "read", "article", "blog", "tutorial", "course"];
const hasKeyword = keywords.some(k => text.toLowerCase().includes(k));
if (!hasKeyword) {
shouldKeep = false;
filtered.replies++;
}
}
filtered.total++;
if (shouldKeep && link && !seenIds.has(link)) {
seenIds.add(link);
const externalLinks = extractLinks(article);
tweets.push({
text: text,
author: userEl ? userEl.innerText.split('\n')[0] : "Unknown",
handle: userEl ? userEl.innerText.split('\n')[1] : "Unknown",
time: timeEl ? timeEl.getAttribute('datetime') : new Date().toISOString(),
link: link,
externalLinks: externalLinks,
isReply: isReply
});
addedNew = true;
}
} catch (e) {
errors.push({ error: e.message, timestamp: new Date().toISOString() });
}
}
if (hitCheckpoint) break;
if (!addedNew) noNewTweetsCount++;
else noNewTweetsCount = 0;
await simulateHumanInteraction();
const scrollFactor = mode === "extra_slow" ? 0.4 : 0.7;
window.scrollBy(0, window.innerHeight * (scrollFactor + Math.random() * 0.2));
const waitTime = mode === "extra_slow" ? [3000, 6000] : [2000, 4000];
await wait(waitTime[0], waitTime[1]);
scrollAttempts++;
}
return {
count: tweets.length,
tweets: tweets,
errors: errors.length > 0 ? errors : undefined,
reachedLimit: tweets.length >= limit,
hitCheckpoint: hitCheckpoint,
checkpointId: checkpointId,
filtered: filtered
};
}
return await scrapeBookmarks(25, "normal", [], true);
})();
3. Analysis & Summarization
Agent Instruction:
-
Calculate Statistics:
- Total bookmarks: {count}
- New bookmarks: {count if lastScrapeDate exists, else same as total}
- External links: Count bookmarks with externalLinks array length > 0
- Scrape duration: Calculate from start to end of scraping
- Dynamic category counts: For each category in CATEGORIES, count how many bookmarks will be assigned to it based on content analysis
-
Pass the combined JSON output to the LLM with context:
"Analyze these X bookmarks.
{If lastScrapeDate exists: 'These are {count} new bookmarks since {lastScrapeDate}.'}
Group them into categories from config: {CATEGORIES}.
CRITICAL FORMATTING RULES:
- Title MUST include timestamp:
# X Bookmark Briefing - {YYYY-MM-DD} at {HH:MM}
- Summary section is MANDATORY with all statistics
- Author format MUST be:
### Author Name (@handle)
- NO EMOJIS anywhere in the report
EXACT STRUCTURE TO FOLLOW:
X Bookmark Briefing - {YYYY-MM-DD} at {HH:MM}
Summary
- Total: {count} bookmarks
{If lastScrapeDate: '- New: {count} since {lastScrapeDate}'}
{If filtered.replies > 0: '- Filtered: {filtered.replies} low-value replies'}
- External Links: {count with externalLinks}
- Scrape Time: {duration}s
{For each category with bookmarks: '- {CategoryName}: {count} bookmarks'}
Then for each category with bookmarks:
{Category Name}
For each bookmark:
Author Name (@x_handle)
Time: {ISO timestamp}
Link: {tweet URL}
{Tweet content}
Action Items: Suggest next steps (e.g., 'Read this article', 'Apply here', 'Clone this repo').
Output a clean and properly formatted Markdown report with NO EMOJIS."
4. File Generation
Agent Instruction:
- CRITICAL - Generate unique filename with timestamp:
- REQUIRED FORMAT:
X_Briefing_YYYY-MM-DD_HHMM.md
- EXAMPLE:
X_Briefing_2026-02-20_0830.md
- Use 24-hour format for time (HHMM without colon)
- DO NOT use formats like:
briefing_2026-02-20.md or X_Briefing_2026-02-20.md
- MUST include underscore and 4-digit time:
X_Briefing_YYYY-MM-DD_HHMM.md
- Save File:
{OUTPUT_DIR}/X_Briefing_YYYY-MM-DD_HHMM.md (using OS-specific path from Step 1.1)
- Store the timestamp for use in Step 6 (git commit and branch naming).
4.5. Save Checkpoint (Duplicate Prevention)
Agent Instruction:
-
Extract ONLY the first 5 tweet links from the scraped results. If fewer than 5 bookmarks were scraped, use as many as available (but never more than 5).
-
CRITICAL: Create/update {CONFIG_DIR}/last_scrape.json (using OS-specific path from Step 1.1) with this EXACT structure:
{
"lastScrapedIds": ["link1", "link2", "link3", "link4", "link5"],
"lastScrapeDate": "YYYY-MM-DD",
"lastScrapeTimestamp": "YYYY-MM-DDTHH:MM:SSZ",
"totalScraped": 25
}
REQUIRED FIELDS - ALL MUST BE PRESENT:
lastScrapedIds: Array of ONLY first 5 tweet links (use tweets.slice(0, 5).map(t => t.link))
lastScrapeDate: Current date in YYYY-MM-DD format (e.g., "2024-01-16")
lastScrapeTimestamp: Current timestamp in ISO 8601 format (e.g., "2024-01-16T08:30:00Z")
totalScraped: Total number of bookmarks scraped in this run (integer)
DO NOT save as a plain array. It MUST be a JSON object with all 4 fields above.
-
Ensure the directory {CONFIG_DIR} exists before writing.
5. Email Delivery
Agent Instruction:
- Use the email obtained from Step 1 (either from config file or user input).
- Send the email using the
/google-workspace skill:
- Open Gmail in the browser
- Navigate to compose
- Fill in recipient email
- Subject: "X Bookmark Briefing - {YYYY-MM-DD}"
- Body: Paste the markdown report content
- Send the email
- If
/google-workspace skill is not available or fails:
- Inform user: "Email delivery requires the
/google-workspace skill. The report has been saved to {OUTPUT_DIR}/X_Briefing_YYYY-MM-DD_HHMM.md"
- Continue to Step 6 (Git push)
6. Git & GitHub Automation
Agent Instruction:
- Directory:
{OUTPUT_DIR} (using OS-specific path from Step 1.1)
- Git Init: Check if
.git exists. If not, run git init.
- Check Remote: Run
git remote -v.
- Remote Setup:
- If remote exists: Continue.
- If no remote:
- Try
gh auth status.
- If
gh works: gh repo create x-briefings --private --source=. --remote=origin.
- If
gh fails or is missing:
- Notify user: "I cannot create the GitHub repo automatically because the GitHub CLI (
gh) is missing or not authenticated. Please create a repository manually on GitHub."
- Ask user: "What is the new repository URL?"
- Run
git remote add origin [user_provided_url].
- Smart Push with Conflict Handling:
git add .
git commit -m "Briefing for {YYYY-MM-DD} at {HH:MM}"
- Check for remote changes:
git fetch origin
- If local is behind remote (conflicts detected):
- Create timestamped branch:
git checkout -b briefing-{YYYY-MM-DD}-{HHMM}
- Push to branch:
git push origin briefing-{YYYY-MM-DD}-{HHMM}
- Inform user: "Pushed to branch 'briefing-{YYYY-MM-DD}-{HHMM}' due to remote changes. Please merge manually."
- If no conflicts:
- Push to main:
git push origin main
- Inform user: "Successfully pushed briefing to GitHub (main branch)."