Audio Briefing (CarPlay)

Here’s what Claude came up with for what it christened “NewsBlur Listen”:

The prompt asks Claude Code to add a Listen button to NewsBlur, so you can hear your news instead of reading it, for example while driving.

What you’d be able to do. You could tap Listen on any story and your phone reads it aloud. You could also tap “Listen to this view” on a feed, a folder, or your saved stories, and it reads them one after another, like a playlist. The Daily Briefing could be read aloud as one short episode.

Where the voice comes from. It uses the voice already built into your phone or browser, the same one used for accessibility features. That means NewsBlur’s servers don’t have to create any audio, which keeps costs close to zero. This was your original point about local models.

It reuses what NewsBlur already has. Sam built a mini player for podcasts, with a queue, “play next,” and resume where you left off. Listen puts spoken stories into that same player. Summaries come from Ask AI, and the briefing comes from the existing Daily Briefing.

It cleans up the text first. NewsBlur’s server tidies each story before it’s read. It removes links, images, share buttons, and newsletter footers, and says “Table skipped” instead of reading out numbers. If a feed only gives a teaser, it tells you “This is an excerpt.”

Full story or summary. You pick a default. Auto, the default, reads short stories in full and summarizes long ones, and a “Continue with full story” button switches to the full text if the summary grabs you. Summaries are limited so a long drive can’t run up a huge AI bill.

=====
The spec document it generated is below.

=====
NewsBlur Listen: Claude Code prompt and spec

Sep 24, 2026 · @anon

For Samuel

Everything from “Prompt for Claude Code” down is meant to be pasted into Claude Code from the repo root. I wrote it against AGENTS.md, ARCHITECTURE.md, and your blog posts on the mini media player, Ask AI, and the Daily Briefing, so it reuses what you already built instead of starting a new player.

I made every product decision I could. The few that are really yours (pricing tier, where the code lives, a couple of defaults) are collected at the end under Open decisions, so Claude Code will ask you about exactly those with AskUserQuestion and nothing else.

The one line version: a Listen button that turns stories, summaries, and briefings into speech using the device’s own voice engine, played through the mini player queue you already have.

Prompt for Claude Code

You are building Listen for NewsBlur: text to speech for stories, AI summaries, whole queues of stories, and the Daily Briefing, so readers can get through their feeds in the car or on transit. Listen plays through the existing mini media player and its Up Next queue. Speech comes from the device’s built-in engine, so it adds no audio generation load to NewsBlur’s servers.

How to work

  1. Read AGENTS.md, ARCHITECTURE.md, and clients/ios/CLAUDE.md before anything else, and follow them over this spec where they disagree on process.
  2. Before planning, ask me the questions in Open decisions at the end using AskUserQuestion, in short rounds. Everything else in this spec is decided; do not re-ask it unless the code makes it impossible.
  3. The module and model names below come from the public docs and may have drifted. Confirm each one by searching the code before you build on it, and tell me if something is missing or named differently.
  4. Do not create a branch or worktree unless I ask. Do not deploy.
  5. Restart Celery after touching anything that runs in a task, including apps/ask_ai/ and apps/briefing/.
  6. Style: Black at 110 with isort, snake_case in JavaScript, tests in Test_ classes with test_ methods, every new iOS file in Swift, Android UI checked in the light, dark, black, and sepia themes with theme colors centralized. No TODOs or placeholders. Reference file names in comments.
  7. Commit early and often on Android work, and at the end of each phase everywhere else.
  8. Build in the phase order at the end. Each phase must ship on its own with its tests passing.

What already exists

Listen is mostly glue between five shipped features. Reuse them; do not build parallel versions.

Existing feature Where it lives What Listen takes from it
Mini media player (web only) Backbone frontend under media/js/newsblur/; state saved to the account and synced over WebSocket The player UI, Up Next queue with Play Next and Play Last, History tab with resume positions, speed control, Media Session API wiring. Speech becomes a new item kind in this same queue.
Text view / original text Node text service, fetched per story The full article text for truncated feeds.
Ask AI apps/ask_ai/ (Celery tasks, providers, MAskAIResponse cache, summary presets, model selector) Summaries, cached per story, using the reader’s chosen model.
Daily Briefing apps/briefing/ (MBriefing, sections, Bullets / Editorial / Headlines styles, Celery Beat every 15 minutes) The briefing text and its section order, spoken as one episode.
Story clustering Story loading and scoring code Dedupe the same story across feeds in a queue.

Also relevant: intelligence scores (+1 focus, 0 neutral, -1 hidden), saved stories and tags (MStarredStory), Premium Archive history, the browser Reading Archive (MArchivedStory), and the existing mobile offline read queue. The mini player has not been ported to iOS or Android yet, and forum users have asked for exactly that, so the mobile phases below port the queue for media too.

What Listen does

There are four ways to start listening. All of them produce speech items in the Up Next queue.

Entry point Where the control goes What plays
Listen to story Story toolbar and story menu, plus “Listen next” and “Listen last” One story, full or summary per the reader’s mode setting
Listen to this view Feed bar / story list header Every story in the current view, as a queue
Listen to briefing Top of the Daily Briefing view The briefing as one continuous episode
Continue with full story Player, lock screen, car controls Switches a summary to the full text in place

Listening to everything

There are two ways to hear all feeds at once, and both reuse ranking NewsBlur already has. Listen never invents its own ordering: what you hear is what the screen would show, in the same order.

  • Summarized: Daily Briefing audio. The briefing already picks and ranks stories by trending engagement, how often the reader reads each feed, classifier training, and recency.
  • Story by story: “Listen to this view” on All Site Stories. It follows that view’s order, read filter, and intelligence level, so in Focus only focus stories play. In Summary mode this becomes a spoken digest of everything unread, bounded by the queue cap and the summary limits below.

Speech engine

On-device speech is the only engine in this build. Put it behind a small SpeechEngine interface on each client (speak chunk, pause, resume, stop, set rate, set voice, progress callback) so a server voice engine can be added later without touching the queue.

  • Web: window.speechSynthesis. Speak one chunk per utterance, because Chrome cuts off long utterances. Load voices after the voiceschanged event. Keep the existing Media Session metadata and handlers for speech items. Mobile browsers usually stop speech in a background tab or with the screen locked; accept that on web and point mobile users to the apps.
  • iOS (Swift): AVSpeechSynthesizer with the audio session in .playback category and .spokenAudio mode, the background audio capability, MPNowPlayingInfoCenter, and MPRemoteCommandCenter for play, pause, next, previous, skip, and bookmark. Track progress with the willSpeakRangeOfSpeechString delegate.
  • Android: TextToSpeech wrapped in a Media3 SimpleBasePlayer inside a MediaSessionService foreground service, so the notification, lock screen, Bluetooth buttons, and later Android Auto all work. Use UtteranceProgressListener for progress and request audio focus.

The playback unit

A speech item is a list of chunks, one per paragraph or heading. Position is story_hash plus chunk index, which is what History, resume, and cross-device sync store. Because speech has no reliable timeline, the player shows progress as chunks and estimated minutes left, and skip back / skip forward move one paragraph instead of a number of seconds.

The listen script

The server turns a story into a clean listen script so web, iOS, and Android all speak identical text and the cleaning rules live in one place with tests. Clients never clean HTML themselves.

{
  "story_hash": "42:a1b2c3",
  "mode": "full",
  "source": "original_text",
  "language": "en",
  "title": "...",
  "feed_title": "...",
  "author": "...",
  "word_count": 1840,
  "est_minutes": 11,
  "chunks": [
    {"kind": "intro", "text": "Title. From Feed, by Author."},
    {"kind": "heading", "text": "..."},
    {"kind": "paragraph", "text": "..."},
    {"kind": "notice", "text": "Table skipped."}
  ]
}

source is one of original_text, feed_content, excerpt, or summary. Estimate minutes at 170 words per minute at 1x.

Text source

  1. Use the original text if it is already stored.
  2. Otherwise request it from the Node text service with a 10 second timeout.
  3. On failure, use the feed content.
  4. If what remains is under 150 words and the feed is known to truncate, or the text ends in a “read more” link, mark source as excerpt and add the notice “This is an excerpt.” after the intro.

Cleaning rules

  • Intro chunk: title, then “From {feed}”, then “, by {author}” when present. No date by default.
  • Keep headings as their own chunks. Split paragraphs longer than 400 characters at sentence boundaries, and split CJK text on its own sentence punctuation.
  • Links: keep the link text, drop the URL. Drop bare URLs entirely.
  • Images and figures: skip. With the “Read image descriptions” setting on, speak meaningful alt text as “Image: {alt}” and ignore alt text that is empty, a file name, or the word “image”.
  • Tables become one “Table skipped.” notice. Code blocks become “Code block skipped.” Inline code is spoken as text.
  • Remove embedded videos, iframes, share buttons, “related posts”, comment counts, and newsletter footers (unsubscribe, view in browser, forwarding lines).
  • Remove bracketed reference markers like [1] and footnote backlinks. Keep footnote text at the end under a “Footnotes” heading.
  • Strip emoji and decorative symbols. Collapse whitespace. Leave numbers, currency, and abbreviations to the speech engine.
  • Language: use the feed’s declared language, then a detector on the cleaned text, falling back to the reader’s app language.

Cache scripts in Redis keyed by story hash, mode, and a cleaner version number, for 7 days. Bumping the version invalidates old scripts.

Full or summary

Each reader picks a default mode: Full, Summary, or Auto (the default). The mode is decided per story when its script is requested, never for the whole queue at once.

flowchart TD
  A[Story up next] --> B{Source is excerpt?}
  B -- yes --> F[Read excerpt with notice]
  B -- no --> C{Mode}
  C -- Full --> G[Read full text]
  C -- Summary --> S[Read summary]
  C -- Auto --> D{Over threshold words?}
  D -- no --> G
  D -- yes --> S
  S --> E[Offer Continue with full story]

Never summarize an excerpt. A summary of a teaser would sound like a summary of the article, which is misleading.

Summaries

  • Add a listen_summary preset to Ask AI, cached in MAskAIResponse separately from the existing summary presets, using the reader’s selected Ask AI model.
  • The prompt asks for 80 to 150 words of plain spoken prose: no bullets, no markdown, no URLs, no “this article” throat clearing, names spelled out on first use.
  • Summaries are generated by the existing Ask AI Celery task. The client asks for the script; if the summary is not ready it gets status: pending and waits the same way the Ask AI dialog does. To avoid dead air, request the summary for the next two queue items while the current one plays.
  • If generation fails or times out after 20 seconds, fall back to the full text and say “Summary unavailable, reading the full story.”
  • After a summary ends, pause 2 seconds before the next item so “Continue with full story” is easy to hit. Continuing starts the full script at the first paragraph and keeps the queue position.
  • Auto threshold setting: 300, 600 (default), or 1,000 words.
  • Readers without Ask AI access get Full behavior. Summary and Auto show as locked options that open the upgrade dialog.

Summary limits

A 50 story queue in Summary mode could mean 50 model calls in one drive, so summaries are bounded:

  • Generate at most two items ahead of the one playing. Never generate for a whole queue up front, and never for items the reader skips before their turn.
  • A cached summary costs nothing and does not count. Check whether the MAskAIResponse cache is shared across readers for the same story and question; if it is, a popular story is summarized once for everyone.
  • New listen summaries count against the existing Ask AI usage limit if there is one. If there is none, add a daily cap per reader, 100 by default.
  • When the cap is reached, Summary and Auto fall back to Full for the rest of the day, with one spoken notice: “Daily summary limit reached, reading full stories.”
  • Briefing audio reads text that is already generated, so it makes no new model calls.
  • Add a Prometheus metric for listen summaries per reader per day, so the cap can be tuned from real usage.

Queues

Speech items and media items share the one Up Next queue. Extend the saved queue item schema with kind: "speech" plus story_hash and mode; existing media items keep working unchanged.

“Listen to this view” builds the queue from exactly what the reader is looking at:

  • Sources: a feed, a folder, All Site Stories, Infrequent Site Stories, a saved search, Saved Stories or a saved tag, full-text search results, and a blurblog.
  • Order: the view’s current sort order, newest or oldest first.
  • Filter: the view’s current read filter (unread only by default) and intelligence level. If the reader is in Focus, only focus stories are queued. Hidden stories are never queued.
  • Snapshot: the queue is fixed at the moment the reader presses play. It does not reshuffle as new stories arrive.
  • Cap: 50 stories by default, setting of 10, 25, 50, or 100. Load the list in pages; only the stories near the front need scripts.
  • End of queue: if new stories arrived in that view, say “{n} new stories. Keep going?” and show a button. With “Continue with new stories” on, just keep going.
  • Clusters: play only the first story of a cluster. Mark its siblings read only if the reader’s existing cluster behavior already does that when reading on screen.
  • Replacing an existing queue: “Listen to this view” asks “Replace Up Next or add to the end?” when the queue is not empty.

Controls

  • Play and pause, previous story, next story, skip back and forward by one paragraph, and tap a chunk in the expanded player to jump to it.
  • Speed from 0.5x to 3x in the mini player’s existing steps. Speech speed is remembered separately from media speed, since people listen to synthetic voices faster.
  • Sleep timer: end of current story, 15, 30, or 60 minutes.
  • Save (star) the current story from the player, lock screen, and car controls, so readers can keep something for later without touching the screen. Use the bookmark command on iOS and a custom session command on Android.
  • Listening never moves the reader’s position in the story list. With the “Follow along” setting on, the web and apps scroll to the current story and highlight the current paragraph.

Read state

  • Default: mark a story read when its last chunk finishes. Setting: when finished, when started, or never.
  • A skipped story stays unread. A story stopped partway stays unread and keeps its chunk position for resume.
  • Mark read through the same endpoint the reader already uses, so unread counts update everywhere. On mobile without a connection, use the existing offline read queue.
  • History shows listened stories with their position, like media items today.
  • If the reader marks stories read from the story list while a queue is running, drop those stories from Up Next, except the one playing.
  • Two devices: position syncs like the mini player does now. When a second device starts playing, the first one pauses and shows where the other left off.

Archives

  • Premium Archive stories older than the unread window play exactly like recent ones, because their text is stored.
  • Saved Stories and saved tags are the natural “listen later” playlist. Starring at a desk and listening on the commute is the main use case, so “Listen to this view” must work on any tag.
  • Reading Archive pages from the browser extension can be played one at a time or as a queue built from Reading Archive search results.
  • If a stored story has no usable text left, speak “No text available, skipping.” and move on without marking it read.

Daily Briefing

  • Build a briefing script from MBriefing: an intro chunk (“Your morning briefing for Thursday, September 24.”), then each enabled section in the reader’s order, with the section name as a heading chunk.
  • Bullets and Editorial styles: speak the written text. Headlines style: speak the titles with feed names.
  • Every item keeps its story_hash. “Open this story” from the player plays that story’s full script next, then returns to the briefing where it left off.
  • Listening to a briefing does not mark its source stories read. Listening to a story opened from it does, per the read state setting.
  • On mobile, fetch the briefing script when a briefing is delivered, so it is ready offline.
  • Readers who only see the briefing preview can listen to the preview items.

Podcasts and video in a speech queue

  • A story with an audio enclosure: setting Play episode (default, handed to the existing media item path), Skip, or Read show notes.
  • A story whose main content is a video or YouTube embed: Skip by default and leave it unread, since video is no use in a car. Setting to read the description instead.

Edge cases

Case Behavior
Story over 10,000 words Play normally. Show estimated minutes before starting. Headings allow jumping.
Image-only post (comics, photos) Say “Image post, skipped.” and leave it unread.
Story updated upstream while playing Keep the loaded script. Pick up the new version next time.
Story deleted or feed unsubscribed mid-queue Finish the current chunk, then drop that feed’s items from Up Next.
No installed voice for the story’s language Setting: use the default voice with a notice (default), or skip.
Network drops Keep playing prefetched scripts. Clients prefetch scripts for the next 3 items. If the next item is not ready, pause and say “Waiting for connection.”
Phone call, navigation prompt, other audio Pause on interruption or focus loss, resume after. Duck under navigation prompts.
Headphones or car Bluetooth disconnect Pause.
Reader opens a different story while listening Listening continues. Reading and listening never change each other’s position.
Empty view Disable “Listen to this view” with a tooltip: “No stories to listen to.”
Mark all as read while listening Drop those items from Up Next, keep the current one.

Backend

Create a Django app apps/listen/ (confirm with me first, see Open decisions) with the script builder, views, and tests.

Endpoint Purpose
`GET /listen/script?story_hash=&mode=full summary
GET /listen/scripts?story_hashes[]=...&mode= Batch version for prefetching up to 5 items
GET /listen/briefing_script?briefing_id= The briefing as one script with per item story hashes
GET /listen/queue_stories?... Story hashes for “Listen to this view”, taking the same parameters as the river and feed story endpoints so the filter and order match the screen
  • Queue state, positions, and History extend the mini player’s existing save and WebSocket sync code rather than adding new storage. Find it by searching the frontend for the Play Next handler and following it to the server.
  • Add the endpoints to the public API docs so third party apps can use scripts too.
  • Add Prometheus counters through the existing monitor app: scripts built, cache hits, summaries requested, summaries failed.
  • Test every endpoint with make api.

Clients

  • Web: a SpeechPlayer module next to the mini player in media/js/newsblur/, implementing SpeechEngine over speechSynthesis, and a new item renderer in the player. Voice picker lists voices grouped by language.
  • iOS: Swift ListenPlayer, SpeechEngine, and a mobile Up Next view. This is also the port of the mini player queue to iOS, so media items must play in it too.
  • Android: ListenService (Media3 MediaSessionService), the TTS player, and the Up Next sheet, matching the Ask AI bottom sheet style. Same port of the media queue. Check every screen in all four themes.
  • The mobile players must keep playing with the app backgrounded and the phone locked. That is the whole point of the feature.

Settings

Store these with the reader’s other preferences so they sync across devices. Put them under the player’s gear menu and in Preferences.

Setting Options Default
Listen mode Full, Summary, Auto Auto (Full without Ask AI access)
Auto threshold 300, 600, 1,000 words 600
Mark as read When finished, when started, never When finished
Voice Per language, from installed voices System default
Missing voice Default voice with notice, skip Default voice with notice
Speech speed 0.5x to 3x 1x
Read image descriptions On, off Off
Podcast episodes in a queue Play episode, skip, read show notes Play episode
Video posts in a queue Skip, read description Skip
Queue limit 10, 25, 50, 100 50
Continue with new stories On, off Off
Follow along On, off Off
Show Listen button On, off On

The last setting mirrors Ask AI’s “Show Ask AI button” preference, for readers who want none of this.

Tiers (proposed)

  • Listen to stories and queues in full: every user. It runs on the reader’s device and costs nothing to serve beyond text extraction that already exists.
  • Summary and Auto modes, and briefing audio: Premium Archive and Pro, matching Ask AI and the Daily Briefing.

Build phases

Phase Scope Done when
1 apps/listen script builder and endpoints; web Listen to story and Listen next / last as speech items in the mini player A web reader can queue three stories and hear them in order with History and resume working
2 Listen to this view on web, read state rules, cluster dedupe, queue cap, end of queue prompt Queue from a folder in Focus plays only unread focus stories and marks each read on finish
3 iOS: port of the Up Next queue for media and speech, background playback, lock screen, prefetch, offline A locked iPhone plays a 10 story queue with no network after the first minute
4 Android: same as phase 3, all four themes Same test on the emulator with Wi-Fi and data disabled through adb
5 Summary and Auto modes, listen_summary preset, briefing audio on all platforms Auto reads a 2,000 word story as a summary and “Continue with full story” switches in place
6 CarPlay and Android Auto browsing of Up Next Queue appears and plays from the car screen

Tests

Write the tests before each piece of the script builder. Use real HTML fixtures from these kinds of stories: a plain article, a newsletter with footer, a post with code and tables, an image-only comic, a truncated excerpt, a paywalled teaser, a non-English article, a CJK article, a 12,000 word longread, and a story with footnotes.

  • Script builder: expected chunks for every fixture, the excerpt rule, language detection, and cache invalidation on version bump.
  • Mode decision: every branch of the flowchart, including no Ask AI access.
  • Queue endpoint: matches the story list endpoint for the same parameters, respects Focus and the read filter, and caps correctly.
  • Briefing script: section order, each writing style, and preserved story hashes.
  • Read state: finish, start, never, skip, and partial stop.
  • Mobile: manual runs on the booted simulator and emulator with screenshots in /tmp, following the AGENTS.md device rules.

Acceptance criteria

  • One tap starts listening to any story on web, iOS, and Android.
  • A queue from any view plays hands free, with the phone locked, until it ends or the sleep timer stops it.
  • Lock screen, Bluetooth, and car buttons do play, pause, next, previous, and save.
  • Read state after a listening session matches the setting exactly, and unread counts agree on every device.
  • Nothing is ever read aloud as a URL, a file name, emoji, markup, or table cells.
  • No server side audio is generated. Server load added is script building, cached, plus summaries through Ask AI.
  • Turning off “Show Listen button” removes every Listen control.

Open decisions (ask me these first)

  1. Tiers: is full text listening free for everyone, and are summaries and briefing audio Premium Archive only?
  2. Code location: new apps/listen app, or inside apps/reader and apps/ask_ai?
  3. Order: web first as planned, or start with iOS since the main use case is the car?
  4. Is one shared Up Next queue for speech and media right, or do you want them separate?
  5. Default Auto threshold of 600 words and queue cap of 50: keep or change?
  6. Name: “Listen” everywhere, or something else?
  7. Summary limits: tie listen summaries to the existing Ask AI limit, or a separate daily cap of 100 per reader?

Sources