A calm Twitter/X client for the terminal — with native iOS, macOS, and Linux apps.
Local LLM drops rage-bait before it reaches your eyes.
Your home feed, minus the tweets the LLM quietly ate. The −N in the status bar is all that remains of them. Everything LLM-shaped — filter, translate, explain, summarize — runs on your machine via Ollama. Nothing leaves your box.
unrager is a Rust TUI for reading Twitter/X without the engagement-optimized rage. It connects through the same GraphQL endpoints the web client uses (no API key, no cost), and pipes every incoming tweet through a local Ollama model that classifies it against your personal rubric. Tweets that match are physically removed from the feed before rendering — they never existed.
Everything intelligent runs locally: the rage filter classifies each tweet, translate (T) turns foreign-language posts into English, ask (A) lets gemma explain / counter / summarize a post, and brief (B) writes a short profile analysis from someone's recent timeline. One Ollama process, one gemma model, four features. No cloud round-trips, no provider keys, no token bills, no data leaving your machine.
Every text input (reply, ask, command palette) is a Vim-mode editor — Insert/Normal modes, hjkl, w/b, dd/dw, counts, ^/$. If that's your muscle memory, composing here feels native.
It also has a CLI for one-shot reads and an OAuth 2.0 write path for posting.
unrager # TUI
unrager home -n 20 # one-shot CLI
unrager tweet "..." # post via official API
And it's not just the terminal: unrager serve exposes the same feeds over an HTTP/SSE API that native iOS, macOS, and Linux apps read — the server does all the X work, the clients stay thin. Pair over Tailscale and read your filtered feed from your phone or desktop. See Native apps.
Reading uses your existing browser login. On startup, unrager reads two cookies — auth_token and ct0 — from whichever Chromium-family browser you're logged into x.com with, decrypts them in memory using your OS credential store (macOS Keychain, Linux Secret Service — or Chromium’s keyring-less basic store), and hands them to X's GraphQL endpoints the same way the web client does. Nothing is written to disk, nothing is logged, nothing leaves your machine. The whole path is under src/auth/ — ~300 lines, open-source, cargo-auditable.
Writing is the other story: unrager tweet / reply go through the official X API v2 with your own OAuth 2.0 PKCE client. unrager never sees your password, your read cookies aren't used for writes, and posts are attributed to your developer client (not a shared one). No writes until you run unrager auth login.
curl -fsSL https://unrager.com/install.sh | bash
unrager
# optional: enable the rage filter
ollama pull gemma4Want to poke around without setting up X cookies? Run unrager demo — it launches the TUI against a bundled offline feed of 15 tweets designed to exercise the filter. No cookies, no network, just a terminal (and optionally Ollama to see the filter drop roughly five of them).
Uninstall:
curl -fsSL https://unrager.com/install.sh | bash -s -- --uninstallWorks on macOS (Apple Silicon + Intel) and Linux (x86_64 + aarch64). On Windows, use WSL2 with a Linux browser — the native Windows build path isn't wired up (Chromium cookie decryption on Windows uses DPAPI, which unrager doesn't implement; PRs welcome). Builds from source via cargo install --path . on any Unix platform with Rust 1.85+.
The TUI reads cookies from your logged-in browser automatically (Vivaldi, Chrome, Brave, Edge, Opera, Arc). If you're logged into x.com in more than one of them, pin the source with cookie_browser = "Vivaldi" in config.toml (or the UNRAGER_BROWSER env var) and unrager uses only that browser — never silently falling back to another. The filter enables itself when Ollama is reachable and disables silently when it isn't.
Every tweet is classified by a local LLM against a user-editable rubric (~/.config/unrager/filter.toml). Matching tweets are physically removed — not collapsed, not grayed out, gone. Verdicts cache to SQLite keyed by (tweet_id, rubric_hash), so reloads are instant and editing the rubric invalidates automatically.
drop_topics = [
"american electoral politics, presidents, congress, partisan fights",
"war, military conflict, battlefield footage, casualty counts",
"gender wars, men-vs-women discourse, trad-vs-feminist fights",
# add your own
]
extra_guidance = "Keep technical, scientific, art, music, sports tweets..."
[ollama]
model = "gemma4:latest"
host = "http://localhost:11434"Toggle with <space> r. The status bar shows −N when the filter is actively hiding tweets, or filter off · doctor when Ollama isn't reachable or no gemma4 model is installed — the hint points at unrager doctor, which explains why.
Enter opens a tweet into a split detail pane. The focal tweet and all its replies form one scrollable list. Push deeper into any reply with Enter, pop back with Esc. Press s to cycle reply sort order — newest, likes, replies, retweets, views — it persists across sessions. X expands inline thread replies without leaving the current view.
The left pane stays live. Tab swaps focus between panes, ,/. adjusts the split width.
Every text input in the TUI — reply (r), ask-gemma input (A), command palette (:) — is a miniature Vim editor. Insert/Normal modes, hjkl motion, w/b word jumps, dd/dw, counts, ^/$, everything a two-minute muscle-memory habit expects. The status line at the bottom of each pane shows INSERT or NORMAL and the live character counter (24/280).
Submit (Enter in insert mode) doesn't post over the X GraphQL write endpoint — that path errors out on most accounts in a way unrager can't route around. Instead, the composed text is copied to your clipboard and the parent tweet opens in your browser. Paste, send, done. The full Vim editor experience inside the terminal; the actual post happens in the place X is happiest about. Press Esc twice (Insert → Normal → exit) to close the editor — your draft is kept in memory until you leave the parent tweet's detail pane, so an accidental close doesn't lose your text.
Submitting a reply with r auto-likes the tweet you're replying to — that happens the moment you hit Enter, before the browser opens, so the reciprocal-like etiquette stays intact. The like is gated the same way o is: skipped if it's your own tweet, already liked, or X is write-rate-limiting you.
:search nvidia pulls live results in every language. Press T on any tweet to translate it to English via the same local Ollama instance. Press T again to revert. Translations are ephemeral — in memory only.
Press A on any tweet to open an ask pane powered by your local Ollama gemma model (the same one used for the rage filter and translation). The post is pinned to the top, a chip row exposes preset prompts (1 Explain · 2 Replies · 3 Counter · 4 ELI5 · 5 Entities) that fire with a single keystroke when the input is empty, and the reply streams inline token-by-token. Gemma4's vision is used automatically — up to four photos on the post are base64-attached to the first turn. When you open the pane from the detail view, the loaded replies are pulled into context, so 2 Replies actually summarizes the thread; and when you A a reply inside a thread, the thread's root post and the other replies are included as context so gemma sees the conversation, not just the isolated reply. The pane title shows what's in scope (ask · @handle · 2 imgs · 14 replies · ready). Conversations live only in memory. Thinking is enabled for the chat path since replies benefit from reasoning; filter and translate keep thinking off for speed.
The command palette supports :home, :user <handle>, :search <query>, :mentions, :notifs, :bookmarks, and :read <id|url>. History navigates with ]/[.
Press n or :notifs to open notifications as a detail pane without losing your place in the source timeline. Likes, retweets, follows, and quotes come from the main notifications feed; replies are merged from the mentions endpoint. Type icons stay vivid for scanning, handles keep their palette color. Press x to expand a snippet, Enter to open the target tweet in a stacked detail view on top. Esc pops back to the notifications list; Esc again pops back to the source timeline.
Unread badge (Nn) appears in the header when on other views. Auto-refreshes at the top of the list. Read tracking is separate from tweet seen state.
p opens the profile of whoever your cursor is on — the selected tweet's author, the notification actor, or your own profile if nothing's selected. The header pins a kitty-graphics avatar, display name, handle, and follower / following counts; on non-kitty terminals (or while the image is downloading) it falls back to a text-only header. When X exposes an about_profile block (via the AboutAccountQuery endpoint), the header also surfaces the country flag + based in <country>, the location source ("via United States App Store" / "via Web"), joined <Mon YYYY>, blue-verification start date, the X affiliate badge handle, and the number of past username changes. The country flag also rides next to the handle in every feed row — in the TUI and, via unrager serve, in the iOS, macOS, and Linux apps. Your own profile renders with full metrics forced visible — including an analytics block on the focal tweet (views, likes, retweets, replies, quotes, bookmarks, engagement rate). :user <handle> opens anyone's timeline. Press R to toggle between their tweets and replies. <space> o switches between all tweets and originals only (hides replies, quotes, retweets).
? opens a scrollable help overlay with every keybinding and an iconography reference for all the glyphs used in the interface. Scroll with j/k, any other key closes it.
Key bindings
| Key | Action |
|---|---|
j / k / ↓ / ↑ |
Move selection |
g / G |
Top / bottom |
Ctrl-d / Ctrl-u |
Half-page down / up |
Enter / l |
Open tweet into detail pane |
q / Esc |
Pop detail (or quit on home:following) |
Tab |
Swap active pane |
, / . |
Narrow / widen split |
: |
Command palette |
? |
Help overlay |
<space> |
Leader — which-key popup for session toggles |
<space> o |
Toggle all / originals on home feed |
<space> f |
Toggle For You / Following |
<space> m |
Toggle metric counts |
<space> n |
Toggle display names |
<space> d |
Toggle relative / absolute timestamps |
<space> t |
Cycle x-dark / x-light theme |
<space> i |
Toggle media auto-expand |
<space> a |
Toggle author-avatar chips in feeds (kitty terminals only) |
<space> r |
Toggle rage filter |
R |
Toggle tweets / replies on user profile |
T |
Translate selected tweet to English (toggle) |
A |
Ask gemma about the selected post |
B |
Deep profile brief on the selected author |
f |
Like / unlike |
x |
Expand / collapse tweet body |
X |
Inline thread replies |
s |
Cycle reply sort in detail pane |
p |
Open selected author's profile (falls back to own) |
P |
Open own profile in browser |
n |
Open notifications as a detail pane |
o |
Open tweet in browser (auto-likes, except on your own tweets) |
O |
Open tweet author's profile in browser |
m |
Open all media (photos/GIFs/videos) in native viewer |
M |
Open URLs in tweet body (music links auto-routed through song.link) |
S |
Screenshot composer, default action save to ~/.cache/unrager/screenshots/ |
C |
Screenshot composer, default action copy to clipboard |
y |
Yank fixupx URL to clipboard |
Y |
Yank tweet JSON to clipboard |
r |
Reply to selected tweet — Enter copies text + opens the parent in browser, auto-likes the target (skipped on own tweets) |
c |
Compose a new tweet — Enter copies text + opens X's composer in browser |
Ctrl-r |
Reload source / refresh thread replies |
u |
Jump to next unread |
U |
Mark all as read |
] / [ |
History forward / back |
W |
Changelog (release history) |
Ctrl-c |
Quit immediately |
unrager is modular. Pick the install you want — you can always re-install with a different flavor later.
# TUI + CLI (default) — the primary product
curl -fsSL https://unrager.com/install.sh | bash
# TUI + HTTP API server — for the native iOS / macOS / Linux apps over Tailscale
UNRAGER_FLAVOR=full curl -fsSL https://unrager.com/install.sh | bash
# CLI only (no TUI) — scripts, pipelines, CI
UNRAGER_FLAVOR=cli curl -fsSL https://unrager.com/install.sh | bashRe-run the installer with a different UNRAGER_FLAVOR to switch — it replaces the binary in place. The installer also supports UNRAGER_INSTALL_DIR (default ~/.local/bin) and --uninstall.
# TUI + CLI (default)
cargo install unrager
# TUI + HTTP API server
cargo install unrager --features server
# CLI only
cargo install unrager --no-default-featuresApproximate release-build sizes: full 23 MB, TUI-only 15 MB, CLI-only 12 MB. Feature flags gate:
| Feature | What it pulls | What you lose without it |
|---|---|---|
tui (default) |
ratatui, crossterm, image, termbg | bare unrager can't launch; user/notifs/doctor subcommands hidden |
server (default, implies tui) |
axum, tower, tower-http | unrager serve — the HTTP API the native iOS / macOS / Linux apps talk to |
Upgrading later: cargo install unrager --force — no uninstall step, cargo replaces the binary in place.
unrager serve (requires the server feature, on by default) exposes the HTTP API that the native iOS, macOS, and Linux apps talk to. The server does all the X work — cookie/OAuth auth, GraphQL, the rage filter, ask/brief/translate over Ollama — so the clients stay thin native UIs.
unrager serve # bind 127.0.0.1:7777
unrager serve --bind 0.0.0.0:7777 # reach across Tailscale / LAN from your phoneOver Tailscale (or your LAN), set an ACL so only your own devices can hit the port. The server expects the network to be the trust boundary — there is no app-level auth. Point the app at the server's host/port in its settings.
The API surface (/api/*): all seven sources, tweet detail + thread, profile + likers, compose/reply, like, media proxy (photos, videos, gifs, link cards, polls, YouTube, X broadcasts), and filter/ask/brief/translate streaming over SSE, plus session + filter-config persistence and seen-tracking — feature parity with the TUI.
While unrager serve is running it owns the filter + seen caches; the TUI detects the lockfile (~/.cache/unrager/server.lock) — run one or the other.
Three native clients ship in the repo — all thin UIs over the same /api/* contract unrager serve exposes. None is an App Store / store-published app (unrager uses your X session): you build and run them yourself. Point each at your server in its settings (or bake a UNRAGER_DEFAULT_SERVER default for your tailnet) and read every feed, thread, and profile — with the rage filter, ask, brief, and translate intact.
| Client | Stack | Lives in |
|---|---|---|
| iPhone | UIKit, iOS 26 Liquid Glass | ios/ |
| Mac | AppKit, macOS 26 | macos/ |
| Linux desktop | GTK4 + libadwaita (relm4) | linux/ |
The Apple apps share the UnragerKit Swift package (Codable models, the typed async/await APIClient with SSE, image pipeline, logging). Sideload with your own Apple Developer account:
# build the project (XcodeGen + a local Swift package; no Xcode account needed)
cd ios && xcodegen generate # → Unrager.xcodeproj
# run in the iOS 26 simulator
xcodebuild -scheme Unrager -destination 'generic/platform=iOS Simulator' \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO build
xcrun simctl install booted build/Build/Products/Debug-iphonesimulator/Unrager.app
# install on your own iPhone (one-time: register the device + mint an ad-hoc profile)
python3 scripts/provision.py --udid <UDID> --serial <DIST_CERT_SERIAL> --name "iPhone"
UDID=<UDID> ./scripts/install-device.sh # build → ad-hoc sign → devicectl install + launchmacOS builds with cd macos && xcodegen generate && xcodebuild ...; distribute the Mac app as a notarized DMG (Developer ID).
A native GNOME desktop client — GTK4 / libadwaita via relm4, the Linux peer of the Apple apps. It's a separate cargo workspace (linux/, kept out of the root build since it pulls heavy GTK system deps) that reuses the byte-exact unrager-model wire types. On launch it reuses a running local unrager serve or spawns its own, so a single command gets you a window:
# system deps — Arch: sudo pacman -S gtk4 libadwaita
# Debian/Ubuntu: sudo apt install libgtk-4-dev libadwaita-1-dev build-essential
cd linux && cargo run -p unrager-gtkInline photos fill the tweet column at their true aspect (Compact/Standard/Large media setting), video and GIFs show a poster with a play badge, every screen has loading/empty/error states, and right-clicking any image opens a native copy/save menu. See linux/README.md for the full rundown.
- Inline media — photos, video posters, and GIF first-frames render inside the terminal via the kitty graphics protocol on Ghostty, Kitty, and WezTerm. Multiple images side-by-side. Toggle with
I. Falls back to▣/▶/↻glyphs elsewhere. - Author avatars — every feed row, detail focal, reply, and inline thread carries a square kitty-graphics chip of the poster's avatar; profile headers pin a larger one beside the name. Avatars cache at
~/.cache/unrager/avatars/(LRU-pruned to 50 MB, self-invalidating since X rotates the URL on every photo change). Postcard screenshots composite the chip in too. Toggle feed chips with<space> a; disabling skips downloads and suppresses screenshot avatars. Kitty-only — halfblock and disabled terminals fall back to the no-gutter layout. - Inline cards — YouTube links, X Articles, X Broadcasts (with a red
● LIVEbadge while running), generic link previews (any brand), and polls render as bordered preview cards with cover image, title, description, and metadata.mopens the source URL in your browser. - Originals mode —
Von home feeds hides replies, quotes, and retweets.◇appears in the status bar. Persists across sessions. - Notifications view — press
nor:notifsto browse notifications in a dedicated feed. Enter opens the target tweet or navigates to the actor's profile. Ambient whisper continues in the status bar independently. - Read tracking — tweets mark as read on cursor. For You feed hides already-seen tweets and deduplicates across pages.
ujumps to next unread. - Theme engine — built-in
x-dark(X.com brand colors layered over a Rosé Pine surface palette) andx-light(X.com brand over Solarized Light). Swap live with:theme x-dark|x-light|autoor toggle withZ. The choice persists across sessions. The Twitter blue, like-pink, retweet-green, and quote-purple are real X brand hex values; greys, borders, and the ribbon palette come from Rosé Pine / Solarized so the TUI sits comfortably inside those terminals. - Color-hashed handles — FNV-1a hash into a per-theme 20-color palette, consistent across every mention in every tweet body.
- Zebra striping — alternating row backgrounds drawn from the active theme.
- Share —
ycopies a fixupx embed URL,oopens in browser,mdownloads every attachment on the selected tweet (all photos, GIFs, and video MP4s). On macOS images go to QuickLook (qlmanage -p) — space/Esc closes and focus returns to the terminal — while videos open in QuickTime Player via an osascript wrapper that polls for the document close and reactivates the spawning terminal, so Cmd+W alone gets you back to unrager. Linux usesxdg-openfor everything. Cache lives under~/.cache/unrager/media/<tweet_id>/. - Postcard —
S(orC) opens a theme picker for rasterizing the focal tweet to a PNG. Six distinctive presets (glass,synthwave,cutout,moss,blueprint,arcade), a "match TUI" option, or tune a custom theme from two hex colors (bg + accent; text auto-picked from bg luminance). Commit withs(save to~/.cache/unrager/screenshots/) ory(copy PNG to system clipboard). PressTin the modal to capture the entire reply chain — root tweet down to the focal — as one tall image with a continuous accent bar and hairline dividers between blocks. Pressnto toggle between handle-only (default) andDisplay Name @handlerendering. Pressmto toggle the metrics row (replies / RTs / likes / views / quotes / bookmarks) — off by default for a cleaner postcard look. Both toggles persist insession.json. Every color emoji in the card — country flags, faces, ZWJ sequences like 👨👩👧👦, skin-tone variants, keycaps — renders as a full-color image composited onto the character grid (ab_glyphonly rasterizes monochrome outlines, so the bundled fonts can't draw color emoji themselves). The PNGs are pulled on demand from the maintained Twemoji set, whose version is resolved to the newest release at runtime so the latest Unicode emoji always render — not just whatever a build-time-bundled font happens to know — and cached under~/.cache/unrager/emoji/. Anything offline, 404, or newer than the set falls back to the monospace outline. Renders at 2× density (~1400px wide, retina-sharp) with 22pt editorial-feel typography. Media images are composited beneath the text; videos are skipped. Watermark sits bottom-right in muted color. - Configurable browser —
config.tomlsupports{}URL placeholder for Chromium--app={}kiosk mode.cookie_browser = "Vivaldi"(orUNRAGER_BROWSER) pins cookie extraction to a single browser so a stale login in another never gets picked up. - Digital clock overlay — optional floating clock with big block-character digits. Every element is toggleable via
[clock]inconfig.toml(see below) — time, date, seconds, 12/24h, position, accent color, border. Setenabled = falseto hide completely. - Session persistence — source, selection, toggles, split width, feed mode, reply sort all survive restarts.
CLI
| Command | Purpose |
|---|---|
unrager whoami |
Confirm which account your cookies belong to |
unrager doctor |
Check cookies, Ollama, and gemma4 setup |
unrager update |
Self-update to the latest release |
unrager read <id|url> |
Fetch a single tweet |
unrager thread <id|url> |
Full conversation thread |
unrager home [--following] |
Home timeline |
unrager user <@handle> |
A user's tweets |
unrager search "<query>" |
Live search |
unrager mentions [--user @h] |
Mentions feed |
unrager bookmarks "<query>" |
Search bookmarks |
unrager notifs |
Recent notifications |
All accept -n <count>, --json, --max-pages <n>.
| Command | Purpose |
|---|---|
unrager auth login |
OAuth 2.0 PKCE flow (free) |
unrager auth status |
Show token state |
unrager auth logout |
Delete cached tokens |
unrager tweet "<text>" [--dry-run] |
Post a tweet |
unrager reply <id|url> "<text>" [--dry-run] |
Reply to a tweet |
Requirements
- macOS (stores the cookie key in your login Keychain) or Linux with a Secret Service provider (
kwalletd6on KDE,gnome-keyringon GNOME) - Chromium-family browser logged into X — auto-detected: Vivaldi, Chrome, Chromium, Brave, Edge (all channels), Opera, Arc. Override with
UNRAGER_COOKIES_PATH. - Rust 1.85+ (edition 2024) — only if building from source
- Ollama (optional) — for the rage filter and translation. Default model
gemma4:latest, configurable infilter.toml. - X developer account (optional) — only for posting. OAuth 2.0 Native App + pay-per-use credits at console.x.com.
Configuration
Config paths are platform-native: Linux uses ~/.config/unrager/ + ~/.cache/unrager/, macOS uses ~/Library/Application Support/unrager/ + ~/Library/Caches/unrager/.
| File (Linux) | Purpose |
|---|---|
~/.config/unrager/config.toml |
General settings (browser command, theme, etc.) |
~/.config/unrager/session.json |
TUI session (source, selection, toggles) |
~/.config/unrager/tokens.json |
OAuth 2.0 tokens (mode 0600) |
~/.config/unrager/filter.toml |
Rage filter rubric (auto-created) |
~/.cache/unrager/seen.db |
Read-tracking SQLite |
~/.cache/unrager/filter.db |
Filter verdict cache |
~/.cache/unrager/feed.db |
Materialized Home buffer — pre-fetched, pre-classified For You + Following (capped ring; feed.db.writer.lock guards the single writer) |
~/.cache/unrager/about.db |
AboutAccountQuery cache (country flag + about_profile, keyed by rest_id; negative entries TTL 30d; shared by the TUI and unrager serve, which exposes it to the native clients via GET /api/about/{rest_id}) |
~/.cache/unrager/media/<tweet_id>/ |
Downloaded attachments for m (external viewer) |
~/.cache/unrager/screenshots/ |
PNG screenshots written by S |
~/.cache/unrager/avatars/<sha256>.bin |
Author-avatar disk cache (LRU-pruned to 50 MB) |
~/.cache/unrager/emoji/<stem>.png |
Color emoji PNGs (Twemoji) composited into screenshots |
~/.cache/unrager/mordor-user-<hash>.opus |
Sliced Mordor loop (generated from [sound] source) |
Home (For You + Following) is served from a small local SQLite buffer that a background worker keeps fresh and pre-classified, so the app opens instantly instead of fetching X live on every launch. The worker runs inside unrager serve (so the iOS / macOS / Linux clients get it for free) or, when no server is running, inside the TUI itself; whichever process wins feed.db.writer.lock does the ingest and the rest read the shared buffer. It tops the buffer up toward the cap in the background and then parks (no fetching, no GPU classification) until the app is opened — polling briskly only while a client is actively using the feed — so the buffer is already large when you open the app, and a full, idle buffer costs nothing. The buffer is a capped ring; reaching its bottom is the end of what's cached. Every client shows an "updated Nm ago" freshness indicator (TUI footer; a pinned caption on iOS / macOS / Linux that ticks live), backed by GET /api/feed/status. Defaults are sane; override in config.toml:
[feed]
buffer_cap = 500 # tweets kept per feed (For You / Following)
active_poll_secs = 180 # poll cadence while the app is open
recent_poll_secs = 1200 # cadence while filling the buffer in the background[theme]
name = "auto" # auto | x-dark | x-lightauto follows the terminal background detected at startup (OSC 11). The Z key and :theme <name> command both override and persist whatever you pick. The clock's accent field accepts "auto" (default — follows the theme accent), an ANSI color name, a 256-color index, or a #rrggbb hex.
The Mordor wallpaper and fiery accents on the For You feed require both a dark theme and a dark terminal background. A light terminal suppresses them even if your theme is x-dark, so the cream that bleeds through transparent cells doesn't clash with the image. Terminal-background detection is done once at startup; if you switch your system between light and dark while unrager is running, re-run :theme x-light / :theme x-dark or restart to re-detect.
Opt-in: set UNRAGER_SOUND=1 AND configure an audio file — unrager then plays it on loop while Mordor mode is active and stops the instant you leave For You or switch to a light theme. There is no built-in fallback: if you don't configure a source, Mordor mode is silent. No audio libraries are linked into the binary — playback shells out to whichever of ffplay, mpv, paplay, pw-play, afplay, or aplay is on your $PATH.
To use your own audio, point at it in config.toml:
[sound]
source = "/path/to/your/audio.flac" # any format ffmpeg can readThat's the whole minimum config. On next launch unrager slices, fades, downmixes, and encodes the file into a small Opus loop cached under ~/.cache/unrager/mordor-user-<hash>.opus (~3 KB per second of audio). Requires ffmpeg on $PATH for the one-time encode.
All optional keys:
[sound]
source = "/path/to/your/audio.flac"
start = "0:55" # MM:SS, HH:MM:SS, or raw seconds (default: 0)
end = "1:40" # same formats — or use `duration` instead
duration = 45 # seconds after `start` — ignored if `end` is set
fade_ms = 50 # fade-in/out at loop boundaries (default: 50)
volume = 0.5 # 0.0–1.0 pre-master gain (default: 0.5)The cache key is a hash of the source path, its mtime, and every knob above, so editing any of them (or replacing the source file) invalidates the cache and re-encodes on the next launch. Stale encodes accumulate in ~/.cache/unrager/ — delete mordor-user-*.opus manually if you want to reclaim a few KB.
Precedence — unrager tries these in order, using the first that works:
[sound] source = "..."inconfig.toml(requiresffmpegon$PATH)- A pre-encoded file you dropped at
~/.config/unrager/mordor-sound.{opus,ogg,oga,flac,mp3,wav}— used raw, no processing
If ffmpeg is missing, or the configured source can't be found, or the encode fails, unrager logs a warning and falls through to the next option. If neither source is present, Mordor mode is silent.
Backend compatibility — ffplay/mpv decode everything and get gapless looping via -loop 0 / --loop=inf. paplay handles WAV/FLAC/Ogg (libsndfile), afplay handles WAV/MP3/FLAC (CoreAudio), aplay/pw-play are WAV-only. Players without native loop support run inside a shell while :; do … done with a small pulse between iterations.
Verifying it's working — tail -f ~/.cache/unrager/unrager.log.$(date +%Y-%m-%d) | grep -i mordor while you toggle into For You. You should see sound enabled backend=Ffplay format=Opus path=.../mordor-user-*.opus when the config is picked up, mordor loop started on every Mordor entry, and mordor loop stopped on exit. If you see nothing, either UNRAGER_SOUND=1 isn't set, no source is configured, or the encode failed — earlier log lines will say which.
Every field has a default — omit [clock] entirely to get the defaults (enabled, top-right, time + date, 24h).
[clock]
enabled = true
position = "footer" # footer | header | top_left | top_right | bottom_left | bottom_right
show_time = true
show_date = true
show_seconds = false
hour_format = "auto" # auto | h12 | h24
date_format = "auto" # "auto" or any chrono strftime string (e.g. "%a %d %b")
accent = "cyan" # ANSI name, 0–255 index, or #rrggbb
border = true # only applies to the corner overlayshour_format = "auto" (the default) reads the OS locale via sys-locale — so en_US/en_CA/en_AU/en_IN/etc. see 3:15 PM, while most of Europe/Asia see 15:15. date_format = "auto" picks %a, %b %-d for 12h locales and %a %-d %b otherwise. Force either with hour_format = "h12" / "h24" or an explicit strftime string.
footer / header render the clock right-aligned inside that row — one line of text, no box, no overlay. The four corner positions render as a floating overlay with an optional rounded border.
Write path setup
Posting uses the official X API v2 (not cookie auth), so your account is never at risk.
The fast path:
unrager auth setupInteractive wizard — prints the X developer-portal URL, waits for you to register a Native App, prompts for the Client ID, writes it to ~/.config/unrager/config.toml, then runs auth login at the end. One command, one paste.
The manual path, if you'd rather do it by hand:
- Create a developer account at developer.x.com
- Register a Native App (PKCE, no client secret)
- Set callback URL to
http://127.0.0.1:8765/callback - Configure your Client ID — either export it:
or add it to
export UNRAGER_X_CLIENT_ID=<your_client_id>
~/.config/unrager/config.toml:The env var wins when both are set. There is no embedded default —[oauth] client_id = "<your_client_id>"
auth login,tweet, andreplyfail with an explanatory error if neither is configured. - Load pay-per-use credits at console.x.com
unrager auth login— opens browser for the OAuth flowunrager tweet "hello from unrager"
Why your own Client ID? X enforces per-client rate limits and may flag traffic from many unrelated users sharing a single client. Read and TUI are unaffected — only
tweetandreplygo through OAuth.
Reads: browser cookies -> GraphQL (same endpoints as x.com) -> free, unlimited
Writes: OAuth 2.0 PKCE -> official X API v2 -> pay-per-use
Filter: tweet text -> local Ollama -> HIDE/KEEP -> SQLite cache
Media: CDN fetch -> downscale 400px -> kitty graphics transmit
Security model
- Browser cookies — read at runtime, decrypted in memory using the OS credential store (macOS Keychain, Linux Secret Service), never written to disk or logged
- OAuth tokens —
~/.config/unrager/tokens.jsonmode0600, atomic writes - Client ID — supplied by the user via
UNRAGER_X_CLIENT_IDenv var or[oauth] client_idinconfig.toml. No embedded default, so the app identity used against X is always your own. - Filter — runs entirely locally, tweet text never leaves your machine
cargo fmt --all
cargo clippy --all-targets -- -D warnings
cargo testNot affiliated with X Corp. Uses X's web GraphQL endpoints the same way the web client does. Do not use this to scrape at scale or run bots.
Emoji graphics composited into postcard screenshots are from Twemoji (the maintained jdecked/twemoji fork), licensed CC-BY 4.0.








