A macOS menu bar app that filters your X (Twitter) For You feed against your bookmarks, surfaces high-signal posts, and serves them in a local reader accessible from any device on the network.
- Claude Code (user must have it installed with active subscription)
Data-centric, layered, Unix-style pipeline. Each module does one thing. Data flows one direction.
BirdService -> ScorerService -> DigestService -> ServerService -> Browser / WKWebView
| | | |
[Tweet] [ScoredPost] [Digest] [HTML]
Shared types used across all modules. Plain structs, Codable.
Tweet-- id, author, text, createdAt, media, avatarUrl, isRepost, originalAuthor, originalIdBookmark-- same as Tweet (bookmarks are tweets)ScoredPost-- tweet, score, reason, tagsDigest-- date, sections (each section has a timestamp and an array of ScoredPosts)
BirdService -- fetches data from X
- Input: command (bookmarks, home, following)
- Output: [Tweet]
- Wraps the bird CLI binary (bundled with the app)
- Handles --json-full parsing, tweet normalization (full text, avatars, URL entities, repost detection)
- Caches bookmarks and following locally
ScorerService -- scores tweets against bookmarks
- Input: [Bookmark], [Tweet]
- Output: [ScoredPost]
- Wraps
claude -p --model opus --output-format json --json-schema - Returns score, reason, and tags per post
- Dedup: filters out tweets already seen (by tweet ID and original ID for reposts)
DigestService -- assembles a digest from scored posts
- Input: [ScoredPost]
- Output: Digest (structured data)
- Handles section grouping by timestamp
- Handles repost dedup (if original already shown, skip the repost)
ServerService -- serves the reader
- Input: Digest
- Output: HTTP server on a local port
- Serves the HTML reader (embedded in the app)
- API endpoints: /, /api/digest, /api/mtime, /api/generate, /proxy
- Video proxy with range request support
DigestPipeline -- wires fetch -> score -> assemble
- Coordinates BirdService, ScorerService, DigestService
- Manages the seen cache
- Called by the app when user clicks "Generate Digest"
- Menu bar icon with: Generate Digest, Open Reader, Quit
- WKWebView window pointing to localhost (the native reader)
- On launch: start ServerService, show menu bar icon
- Optional: show QR code for iPhone/iPad to connect
- Typora Night theme (dark, #363B40 background)
- Foldable timestamp sections
- Repost indicator (original author shown, retweeter credited above)
- Search: keyword + tag matching (tags generated by Claude at scoring time)
- Floating action buttons: search, home, generate
- Video proxy for X media playback
- Hot reload: polls for new posts, inserts at top without page reload
- Responsive: compact padding on iPhone, full on desktop
TDD for data and service layers. No mocks. Use real JSON fixtures from bird.
- BirdService: feed it a real bird JSON fixture, verify normalized Tweet output
- ScorerService: feed it bookmarks + tweets, verify ScoredPost output has scores and tags
- DigestService: feed it ScoredPosts, verify Digest sections are correct, reposts deduped
- ServerService: start real server, make real HTTP requests, verify responses
- Integration: run the full pipeline with fixtures, verify end-to-end output
Xdigest/
Package.swift
Sources/
XdigestApp/ -- App layer (menu bar, WKWebView)
XdigestCore/ -- Data layer (shared types)
BirdService/ -- Fetch tweets
ScorerService/ -- Score with Claude
DigestService/ -- Assemble digest
ServerService/ -- HTTP server + reader HTML
Tests/
BirdServiceTests/ -- Real fixtures, no mocks
ScorerServiceTests/
DigestServiceTests/
ServerServiceTests/
IntegrationTests/ -- Full pipeline
Fixtures/
bookmarks.json -- Real bird output for testing
home.json
scored.json
Inspired by universalbuild -- typed errors, pipeline halts on first failure, error tells you the stage and the cause.
enum XdigestError: Error, CustomStringConvertible {
// BirdService
case birdNotFound
case birdFetchFailed(command: String, stderr: String)
case birdOutputInvalid(command: String, detail: String)
// ScorerService
case claudeNotFound
case claudeScoringFailed(exitCode: Int, stderr: String)
case claudeOutputInvalid(detail: String)
// DigestService
case digestAssemblyFailed(reason: String)
// ServerService
case serverPortInUse(port: Int)
case serverStartFailed(port: Int, reason: String)
}Each case names the stage (Bird, Claude, Digest, Server) and carries the diagnostic data needed to locate the problem.
The pipeline chains async throwing functions. First failure stops everything:
let tweets = try await birdService.fetchHome(count: 100)
let bookmarks = try await birdService.fetchBookmarks()
let scored = try await scorerService.score(tweets, against: bookmarks)
let digest = try digestService.assemble(scored)
try await serverService.serve(digest)birdNotFound -> user knows to install bird.
claudeScoringFailed(exitCode: 1, stderr: "...") -> user sees exactly what Claude reported.
serverPortInUse(port: 8408) -> user knows another instance is running.
Errors are shown in the menu bar dropdown or as a macOS notification. The full error (stage + detail) is logged. The user always knows what went wrong and where.
- Each module does one thing well
- Data (JSON/structs) is the universal interface between modules
- Pipeline composition: the output of one module is the input of the next
- Text streams: bird outputs JSON, Claude outputs JSON, the server outputs HTML
- Separation of mechanism and policy: services are mechanism, the pipeline is policy