Turns Claude Code's thinking spinner into a live developer-news ticker, in the CLI and the Desktop app. Headlines are cached locally and tightened by a Claude…

Turns Claude Code's "thinking" spinner into a live developer-news ticker.

You know the word Claude Code flashes while it thinks?
Meandering… (7m 33s · ↓ 27.2k tokens · thinking more with xhigh effort)
spindle swaps that word for a fresh developer-news headline, pulled from a local cache:
Apple ships Swift 6.2… (7m 33s · ↓ 27.2k tokens · thinking more with xhigh effort)
No ads, no telemetry, no API keys, no daemon. The news sits in a local file, and nothing of spindle's runs while Claude is actually thinking.
claude in a terminal and the news shows up in its thinking spinner.In the Desktop app the headline shows while Claude thinks or writes. When the row is describing a tool step, like Editing app.ts, that step stays in view: the news never hides what Claude is doing.
Prerequisites: Claude Code 2.1.143 or newer (2.1.287 or newer for the Desktop app) and Python 3.8+ (found as python3, py -3 or python).
In Claude Code:
/plugin marketplace add IzhanAli/Spindle-for-CC
/plugin install spindle@spindle
In the Desktop app, add the IzhanAli/Spindle-for-CC marketplace, then install spindle from + > Plugins > Add plugin in the Code tab. The CLI and the Desktop app read the same settings, so a user-scope install in either one covers both.
Then start a new session. spindle fetches and cleans its first batch of news.
Installing the plugin gave Claude Code:
headline-writer agent that cleans every feed title into a tight spinner label. Only cleaned headlines ever reach the spinner./spindle: commands (below).hooks/spinner.ts) that shows the same headlines in the Desktop app's spinner row, which doesn't read the spinnerVerbs setting the terminal uses.The news cache lives in ~/.cache/spindle.
You don't have to do anything. Use Claude Code the way you always do. The next time it pauses to think, the spinner shows news instead of a random word.
| Command | What it does |
|---|---|
/spindle:status | show what's cached and what's in the spinner right now |
/spindle:refresh | pull fresh news now, in the background — the spinner updates itself |
/spindle:configure [what you want] | tailor topics, feeds and sources — e.g. /spindle:configure focus on Rust and Go |
/spindle:reset [all] | restart the rotation (all also clears the cache) |
/spindle:uninstall | clean up settings + cache before /plugin uninstall spindle@spindle |
Will this slow Claude down? Will I ever wait on it? No, and no. While Claude thinks, nothing of spindle's is running — Claude is just reading a few words it loaded at startup. In the Desktop app, the mod picks the turn's headline once, as the turn starts, from the same spinnerVerbs list; drawing the row only reads that pick. The session-start hook takes a fraction of a second (a local settings write). Everything slow — fetching feeds, the headline agent — runs in a detached background process, including when you run /spindle:refresh or /spindle:configure. When it finishes, the spinner picks up the new headlines on its own.
Is it going to mess with my Claude settings? It writes one key, spinnerVerbs, and touches nothing else. Its hook lives in the plugin, not your settings file. The Desktop mod only reads settings; it never writes them.
Do I need an API key? No. Out of the box it reads free, public feeds (Hacker News, Reddit, and a set of RSS feeds), and the headline agent uses your existing Claude Code login. Keys are only for the optional news-API mode below.
Does the headline agent use my Claude usage? A little. A refresh sends only new, not-yet-cleaned headlines to Haiku, 20 per headless claude -p call with extended thinking off — usually one call of a few thousand tokens. Refreshes happen only when a session starts and the cache is over 30 minutes old. Each headline is cleaned once and then cached. The agent runs with no tools, no MCP servers and no hooks, and nothing from it is saved to your session history.
What if the agent can't run? Then the spinner doesn't change. Cleaning is mandatory: spindle never shows a raw feed title. Stories the agent couldn't clean are retried in the background at your next session start (at most every 5 minutes), and /spindle:status tells you how many are cleaned.
Where does the news come from? Can I pick my own topics? By default: mobile + general dev feeds (iOS, Android, Swift, Kotlin, Flutter, React Native, plus AI/LLMs, Python, DevOps, and friends). Run /spindle:configure and say what you want, or see Make it yours.
Does it phone home? No telemetry, ever. The only network it does is fetching public news feeds and the one headline-agent call, and only during a refresh — never while Claude is thinking.
How do I turn it off? Run /spindle:uninstall, then /plugin uninstall spindle@spindle, and start a new session.
spindle runs with zero configuration, but it's built to be fiddled with. The quickest way is to ask for what you want:
/spindle:configure only Rust, Go and Postgres news, and drop Reddit
Or edit the file yourself. Copy the fully commented template to ~/.config/spindle/config.toml. It's config.example.toml in this repo, and the plugin also keeps a copy under ~/.claude/plugins/. Every setting is explained in its comments.
topics — the keywords spindle ranks news by. Point them at your stack.[[rss]] — add or drop feeds. Only want Rust news? Only your favorite blogs? Rewrite the list.[hackernews] / [reddit] — tune score thresholds or swap subreddits.pool_size — how many headlines are in play each session (default 14).[ai] — tune the headline agent: model (defaults to haiku), max_words, batch_size, and claude_bin if claude isn't on your PATH. Cleaning itself can't be turned off.More sources via news APIs. Set mode = "api" to pull from NewsAPI + GNews + DEV.to instead of the scraper feeds. NewsAPI and GNews want free keys; put them in ~/.config/spindle/.env (see .env.example). DEV.to and Hacker News don't need keys. spindle stays under each API's free-tier daily limit automatically, so you won't blow through a quota.
agents/headline-writer.md is a regular plugin agent. You can use it in a session as spindle:headline-writer, and it's also the single source of the prompt spindle's refresh uses. During a refresh, spindle reads that file and runs a locked-down headless session for each batch of up to 20 headlines, up to 3 at once:
claude -p --agents '{"headline-writer": {…from the .md…, "tools": []}}' \
--agent headline-writer --model haiku --tools "" \
--strict-mcp-config \
--settings '{"disableAllHooks": true, "alwaysThinkingEnabled": false}' \
--no-session-persistence --output-format json
The numbered headlines go in on stdin with the word and character budget, and {"labels": [...]} comes back. The child session also gets SPINDLE_CHILD=1, so spindle's own hook stands down if it runs anyway. A failed batch — claude missing, logged out, offline, timeout, a reply with the wrong number of labels — is retried once. Stories still uncleaned after that are held out of the spinner and retried in the background at the next session start; raw titles are never shown. Extended thinking is off because rewriting a headline doesn't need it: with it on, a 10-headline call took ~80s instead of ~4s.
Spindle-for-CC/
├── .claude-plugin/
│ ├── plugin.json # plugin manifest
│ └── marketplace.json # this repo is its own marketplace
├── hooks/
│ ├── hooks.json # SessionStart → spindle session-start; loads the mod
│ └── spinner.ts # mod: headlines in the Desktop app's spinner row
├── types/index.d.ts # the mod's $.state contract
├── tests/spinner.test.ts # the mod's tests (`claude plugin test .`)
├── tsconfig.json # type-checks the mod (`tsc -p .`)
├── agents/headline-writer.md # the headline-rewriting agent
├── skills/ # /spindle:status, refresh, configure, reset, uninstall
├── bin/run # finds Python 3.8+; what the hook and skills call
├── bin/spindle # Python entry point
├── config.example.toml # fully annotated config
├── .env.example # optional news-API keys
├── LICENSE # MIT
└── src/spindle/
├── __main__.py # CLI entry point
├── config.py # TOML load + defaults
├── model.py # RawItem / Story dataclasses
├── util.py # width-aware truncate, url norm, similarity
├── http.py # conditional GET, never raises
├── summarizer.py # headline cleaning via the headline-writer agent
├── storage.py # atomic JSON cache
├── history.py # rotation window
├── normalizer.py # RawItem → Story
├── deduplicator.py # url + title-similarity dedup
├── scorer.py # weighted ranking
├── integration.py # spinnerVerbs merge into settings.json
├── pipeline.py # refresh / sync / session-start orchestration
└── fetcher/
├── __init__.py # aggregation + FetchContext
├── common.py # shared fetch helpers
├── hn.py # Hacker News (Algolia; both modes)
├── api/ # api mode: keyed sources
│ ├── newsapi.py
│ ├── gnews.py
│ └── devto.py
└── scraper/ # scraper mode: no-auth sources
├── rss.py # RSS 2.0 / Atom
└── reddit.py # Reddit (hot.json)
There's no build and no dependencies — the Python standard library, and a TypeScript mod that Claude Code loads as is. To hack on it, point Claude Code at your checkout:
claude --plugin-dir ./Spindle-for-CC
For the Desktop app, which takes no flags, set CLAUDE_CODE_PLUGIN_DIRS to the checkout's absolute path in the env block of ~/.claude/settings.json. Loading the checkout this way also writes the mod's editor types to .claude-plugin/types/ (git-ignored). To check the mod:
claude plugin validate . # what the mod hooks and calls, and anything refused
claude plugin test . # tests/spinner.test.ts
tsc -p . # type-check, once the checkout has been loaded
The CLI works on its own too: sh bin/run status | refresh --sync -v | clean -v | reset | uninstall.
Config discovery order: --config PATH → $SPINDLE_CONFIG → $SPINDLE_HOME/config.toml → ~/.config/spindle/config.toml.
MIT — see LICENSE.
hooks/spinner.ts 87 lines1// The Desktop app's half of spindle: a mod that puts the headlines in the
2// Code tab's spinner row.
3//
4// The terminal needs none of this. It samples its spinner word from the
5// `spinnerVerbs` key the SessionStart hook writes, and the Desktop app draws
6// its own row and ignores that key. So this module reads the same list back
7// through `$.settings.read()`, picks one headline per turn, and swaps it into
8// the Desktop row's word. The work happens once, as the turn starts; drawing
9// the row only reads the pick.
10//
11// Where the row describes a tool step (`Editing app.ts`), that step stays: a
12// headline never hides what Claude is doing.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register } from 'claude-code'
16
17const headline = atom({ plugin: 'spindle', key: 'headline' } as const, null)
18
19// What the Desktop row says when it has no step of its own to show.
20const IDLE_WORD = 'Working'
21
22function isHeadlineList(value: unknown): value is string[] {
23 return Array.isArray(value) && value.every(v => typeof v === 'string')
24}
25
26/** The pool the SessionStart hook installed, read as the engine reads it. */
27async function installedHeadlines($: EngineInterface): Promise<string[]> {
28 const { spinnerVerbs } = await $.settings.read()
29 if (typeof spinnerVerbs !== 'object' || spinnerVerbs === null) return []
30 const { verbs } = spinnerVerbs as { verbs?: unknown }
31 if (!isHeadlineList(verbs)) return []
32 return [...new Set(verbs.map(v => v.trim()).filter(v => v !== ''))]
33}
34
35function shuffled(items: readonly string[]): string[] {
36 const out = [...items]
37 for (let i = out.length - 1; i > 0; i--) {
38 const j = Math.floor(Math.random() * (i + 1))
39 const swap = out[i] as string
40 out[i] = out[j] as string
41 out[j] = swap
42 }
43 return out
44}
45
46export const register: Register = on => {
47 // Each headline shows once before any repeats, in a fresh order per pass.
48 // A refresh that lands mid-session changes the pool, which starts a new pass.
49 let pool = ''
50 let queue: string[] = []
51
52 on('turn.start', async ($, e, next) => {
53 try {
54 const headlines = await installedHeadlines($)
55 const key = headlines.join('\n')
56 if (key !== pool) {
57 pool = key
58 queue = []
59 }
60 if (queue.length === 0) {
61 const shown = await read($, headline)
62 queue = shuffled(headlines)
63 // Don't open a pass with the headline the last one closed on.
64 if (queue.length > 1 && queue[0] === shown) queue.push(queue.shift() as string)
65 }
66 const pick = queue.shift() ?? null
67 await update($, headline, () => pick)
68 } catch {
69 // No new pick this turn: the row keeps the last one, or its own word.
70 }
71 return next(e)
72 })
73
74 // Desktop only: the terminal already shows the headline through
75 // `spinnerVerbs`, and its spinner redraws never reach this module.
76 on('ui.render', { component: 'Spinner', surface: 'desktop' }, async ($, e, next) => {
77 const { word, message, mode } = e.props
78 // A state message (compacting, retrying, ...) outranks any word.
79 if (message !== null) return next(e)
80 const isToolStep = (mode === 'tool-use' || mode === 'tool-input') && word !== IDLE_WORD
81 if (isToolStep) return next(e)
82 const current = await read($, headline)
83 if (current === null) return next(e)
84 return next({ ...e, props: { ...e.props, word: current } })
85 })
86}
87types/index.d.ts 11 lines1// The values spindle's mod (hooks/spinner.ts) keeps in `$.state`.
2
3/** The headline the Desktop spinner shows this turn; null leaves the row's own word. */
4export type Headline = string | null
5
6declare module 'claude-code' {
7 interface PluginState {
8 spindle: { headline: Headline }
9 }
10}
11