# Pulse

> Search X (Twitter) for mentions of a project, product, or topic, classify each mention (problem raised, suggestion, praise, question, noise, spam), enrich with author follower/verification data, and generate a markdown digest. Use when the user runs "/pulse <project>" or asks to check X mentions, GitHub project buzz, social feedback for one of their repos, or general chatter about a topic they're tracking.

- Skill: `mohi-devhub/pulse` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add mohi-devhub/pulse`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mohi-devhub/pulse/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: mohi-devhub (https://skillmd.com/u/mohi-devhub)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mohi-devhub/pulse

---


# /pulse

Given a project (or topic) name argument (e.g. `/pulse myproject`), search X for
mentions of it, classify and enrich them, and produce a digest. Works equally well for
a software project/product or a general topic you want to keep an eye on — nothing
about the config or pipeline assumes "software." This skill is read-only — it never
posts, replies, likes, or writes anything to X.

## 0. Prerequisites check

This skill requires two things to be set up (see this project's README, "One-time
setup"): an X API MCP server connected (added via `claude mcp add`, backed by the
`xurl` OAuth bridge talking to `api.x.com/mcp`) for user lookups, and a registered
`xurl` app with **both** an OAuth2 user-context login *and* an app-only Bearer Token
for search (see step 2 for why both are needed).

- If the MCP tools aren't available, or `xurl auth apps list` shows no app with a
  cached app-only token: stop and tell the user what's missing, pointing at the README
  rather than guessing at setup steps yourself.
- Tool names on the MCP server can vary by version/config — look for the tools that do
  user lookup (e.g. something like `get_users_by_usernames`) rather than assuming an
  exact name.

If you don't already know which `xurl` app name to use, run `xurl auth apps list` to
find it (there should be exactly one non-default app registered).

## 1. Resolve the project argument

Run:

```
python3 .claude/skills/pulse/scripts/config.py query <project>
```

(Run it from the pulse project root, or set `PULSE_CONFIG=/path/to/pulse-config.json`
if this skill has been copied elsewhere.)

- If it exits non-zero / returns `{"error": ...}`: the project isn't in
  `pulse-config.json`. Tell the user the project name is unknown and list the
  `available` projects from the output. If `pulse-config.json` doesn't exist yet at
  all, tell them to copy `pulse-config.example.json` to `pulse-config.json` and add
  their project first. Stop here.
- On success you get back `{"project", "query", "context_terms", "require_context",
  "github"}`. The `query` field is the fully-scoped X search query string to use —
  don't rebuild it yourself, the co-occurrence logic for generic/collision-prone
  project names already lives in that script.

## 2. Search X for mentions

**Search does not go through the MCP tools.** The hosted MCP's `search_posts_all` tool
only works over OAuth2 user-context auth, but X's search endpoints (both full-archive
and recent) require App-Only (Bearer Token) auth — a 403 with
`"Supported authentication types are [OAuth 2.0 Application-Only]"` is what you get if
you try it through MCP. Instead, call `xurl` directly via Bash, using `--auth app`:

```bash
Q='<the query string from step 1>'
ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$Q")
xurl --auth app --app <app-name> "/2/tweets/search/all?query=${ENC}&max_results=25&tweet.fields=created_at,author_id,conversation_id,in_reply_to_user_id,public_metrics&expansions=author_id"
```

1. **Try `/2/tweets/search/all` (full-archive) first.** Bound the request: fetch at
   most ~3 pages / ~100 results total via `max_results` + `next_token` pagination — do
   not paginate through everything, this is meant to be a small, bounded number of API
   calls per run.
2. **If that call fails on access grounds** (403/access-tier error — not the
   auth-type error above, which `--auth app` already avoids), fall back to
   `/2/tweets/search/recent` (last 7 days) with the same query and auth. Record
   `search_mode` as `"recent (full-archive unavailable: <short reason>)"` in the final
   digest so the user knows the coverage window was smaller than requested. Never
   silently return an empty result without saying which mode actually ran.
3. Do **not** filter out replies. Problems and suggestions are often buried in
   reply threads, not top-level posts — the query already excludes retweets
   (`-is:retweet`) but nothing else. Determine `is_reply` from whether
   `in_reply_to_user_id` is present or `conversation_id != id`. If the parent post's
   text is cheaply available, use it as extra classification context, but don't spend
   an extra call fetching parent posts for every single reply.
4. De-duplicate by post ID across pages before moving on.

If the search step fails entirely (rate-limited, auth expired, endpoint genuinely
missing) — stop and report the specific error to the user. Do not produce a digest
that silently claims "0 mentions found" when the real story is "the search failed."

## 3. Enrich with author data

For the unique set of authors in your result set, use the connected server's user
lookup tool(s) to get follower count, verified status, and bio where available. Batch
this lookup (most user-lookup endpoints accept multiple IDs/handles per call) rather
than making one call per mention — stay within the "small, bounded number of API
calls" goal from step 2.

Enrichment is for sorting/flagging only — never drop or hide a mention because its
author has a small following. A problem raised by a 12-follower account is just as
valid as one from a 50k-follower account.

## 4. Classify each mention

For each mention, make a judgment call using the post text (plus reply/thread context
if you already have it cheaply) and assign exactly one category:

These categories are intentionally generic — they read one way for a software
project and another way for a general topic, but the classification logic doesn't
change:

- **bug_report** — a concrete problem being raised: something broken, wrong, or
  unsatisfactory. For a software project that's an error/crash/regression; for a
  general topic it could be a factual correction, a documented complaint, or a real
  problem someone's pointing out. If a post both praises the thing *and* raises a
  problem ("love this but X crashes on Windows" / "great point but that stat is
  outdated"), classify by the actionable part: bug_report.
- **feature_request** — a suggestion or ask for something new/different. For a
  software project that's a feature/integration ask; for a general topic it's an idea,
  a request, or "someone should really look into/cover X."
- **question** — asking how something works, or for more information, without
  raising a problem.
- **praise** — positive feedback with no actionable ask.
- **noise** — a search false-positive: the term matched but the post isn't actually
  about the thing you're tracking (e.g. a homonym, unrelated context). Flag these
  honestly — they measure search quality, not sentiment.
- **spam** — promotional/marketing content unrelated to genuine engagement with the
  thing you're tracking, where the search term just happens to appear.

Also write a short third-person **paraphrase** of what the author said — summarize in
your own words, don't quote verbatim (avoids reproducing large chunks of someone's
post text and keeps the digest scannable).

## 5. Build the digest payload and render it

Assemble a JSON object shaped exactly like this (see
`.claude/skills/pulse/scripts/digest.py` docstring for the authoritative schema):

```json
{
  "project": "<project>",
  "date": "<YYYY-MM-DD, UTC, today>",
  "search_mode": "full_archive" or "recent (full-archive unavailable: ...)",
  "query": "<the query string from step 1>",
  "mentions": [
    {
      "author_handle": "...",
      "author_followers": 0,
      "author_verified": false,
      "category": "bug_report|feature_request|question|praise|noise|spam",
      "paraphrase": "...",
      "url": "https://x.com/<handle>/status/<id>",
      "is_reply": true
    }
  ]
}
```

Write this payload to a temp file, then run:

```
python3 .claude/skills/pulse/scripts/digest.py < payload.json
```

This renders the markdown digest, saves it to `digests/<project>-<date>.md`, and
prints it to stdout — print/show that output to the user as the final result of this
skill. Report the saved file path too (digest.py prints it to stderr as
`[saved to ...]`).

## Notes for future scheduled runs

Nothing in this skill is interactive-only — steps 1-5 are the same whether a human
typed `/pulse myproject` or a cron job ran `claude -p "/pulse myproject"` headlessly in
a CI pipeline. If you're ever asked to help set up a scheduled version, that's the
integration point: no rewrite needed, just a wrapper that invokes this same skill
non-interactively on a schedule.

