---
name: sync-x
description: Fetch new posts from the X accounts listed in config.toml and file the matching ones into the Markdown library, judging each pre-filtered candidate against the interest list. Calls the paid X API and bills per post read. Use only when explicitly asked to sync, fetch, or pull from X or Twitter — never to answer a question about what the library already holds.
---

# sync-x

Pull new X posts into the library. This runs in three steps, and **your only job
is the middle one**: code fetches, you judge, code writes.

You never choose a filename, a path, or a directory. You never write into
`library/`. Every path component and every deterministic frontmatter field comes
from `candidates.json`, which the fetcher wrote.

**This spends money.** Reads bill at $0.005 per post. The fetcher refuses to
exceed the ceiling in `config.toml`, so you cannot overspend by running it — but
do not run it repeatedly to "check", and do not run it at all if the user only
asked a question about the library.

## Step 1 — fetch

Run exactly this, with no additions:

```
uv run ${CLAUDE_PLUGIN_ROOT}/scripts/sync_x.py --fetch
```

No flags, redirects, pipes, or other shell constructs. The command is a fixed
literal; anything else is denied by the hook.

It prints a JSON document. The parts you need:

- `candidates` — the posts to judge. Already filtered: anything already in the
  library, and anything with no keyword hit, was dropped before you saw it.
- `judgments_path` — the exact file to write in step 2.
- `complete` — whether the fetch can prove it caught up.
- `accounts` — per-account status, including anything that failed.

**If `candidates` is empty, stop.** Say nothing new was found, relay any
incomplete accounts, and do not write anything.

## Step 2 — judge

For each candidate, decide **which configured interests the post is actually
about**. Read the `description` of each interest in
`$NEWS_AGENT_HOME/config.toml` and judge against that description, not against
the keywords.

`matched_keywords` tells you why a post survived the gate. It is context, not an
answer — the gate is deliberately loose and fires on things like "A.I. Cable &
Connector" for the interest `ai`.

**Returning `interests: []` is correct and expected.** That is the false
positive being caught, and it is a normal outcome for some fraction of every
batch. You are under no obligation to justify a keyword hit. A run where every
candidate is dropped is a successful run. Do not stretch a description to fit a
post, and do not invent an interest label that is not in `config.toml`.

A post may also belong to an interest whose keywords did not fire. That is
allowed — judge the post, not the match.

Then write **exactly one entry per candidate** to the path given in
`judgments_path`, using the `Write` tool. That is the only path you may write to.

```json
{
  "judgments": [
    {
      "id": "1900000000000000001",
      "interests": ["ham radio"],
      "title": "Club logs 412 contacts on Field Day",
      "summary": "A club reports its Field Day totals, most worked on 20m."
    },
    {
      "id": "1900000000000000003",
      "interests": [],
      "title": "",
      "summary": ""
    }
  ]
}
```

Rules the writer enforces, so save yourself a rejected entry:

- `id` — copy it verbatim from the candidate. Never invent or alter one.
- `interests` — a list of labels from `config.toml`, or `[]` to drop the post.
- `title` — one line, at most 200 characters. Plain and factual; it is a filing
  title, not a headline. No clickbait, no emoji.
- `summary` — **two or three sentences**, plain prose, no marketing tone. Say
  what the post says. Do not add context the post does not contain.

  The hard limit is **1,000 characters** and the fetch report states it as
  `max_summary_chars` — that number is the authority, not this line. **Treat it
  as a bound, not a target.** A brief is one good-morning read; a hundred items
  at a thousand characters each is not. The limit exists so one runaway judgment
  cannot bloat the document, and an item over it is rejected whole rather than
  trimmed.
- Title and summary are ignored when `interests` is `[]`, so leave them empty.

**Never omit a candidate.** Silence is treated as a failure, not a drop — if you
mean to drop a post, say so with `interests: []`.

## Step 3 — write

Run exactly this, with no additions:

```
uv run ${CLAUDE_PLUGIN_ROOT}/scripts/sync_x.py --write
```

It validates every judgment, writes the library files, and prints a report.

## Reporting

Tell the user, briefly:

- how many posts were written, and how many were dropped as off-topic;
- the cost from `estimated_cost_usd`;
- **anything in `problems`, verbatim in substance.**

Two things must never be smoothed over:

- **`complete: false`** — the fetch could not prove it caught up. Name the
  accounts and their status. Do not describe the run as a full sync.

  **Report `first_run` and `truncated` as the different things they are.**
  `truncated` means posts existed between the last sync and this one and are now
  unreachable — a real gap, worth saying plainly. `first_run` means the account
  had no cursor yet, so there was no earlier point to have fallen behind from:
  nothing is missing, there is simply no baseline to prove completeness against.
  Every account reports `first_run` the first time it is synced, and a handful
  of new accounts will make a run incomplete for a morning. Saying "17 accounts
  fell behind" when the truth is "17 accounts are new" is the kind of alarming
  and wrong that trains a reader to stop reading the field.
- **`skipped` above zero** — entries the writer rejected. Those posts were not
  written. Say so and say why; they will be re-fetched next run.

`--write` exits non-zero for either. That is the run telling you something, not
a crash to work around. Do not retry either command to make the message go away,
and do not fix a problem by writing files yourself — `$NEWS_AGENT_HOME` is user
state. If `config.toml` needs a change, tell the user what to edit.
