Keep tabs on your tabs.
Tab Out replaces your Chrome new tab page with a dashboard that shows everything you have open -- grouped by domain, with landing pages (Gmail, X, LinkedIn, etc.) pulled into their own group for easy cleanup. Close tabs with a satisfying swoosh + confetti.
Built for people who open too many tabs and never close them.
Elixir port. This fork replaces the original Node.js + Express server with an Elixir application built on Bandit, Plug, and Exqlite. The SQLite schema, JSON API surface, Chrome extension, and dashboard are all unchanged — only the server binary is different. The server ships as a
mix release— a self-contained directory that bundles its own Erlang runtime. Prebuilt releases for macOS (arm64) and Linux (x86_64) are attached to every GitHub Release, so installing on a fresh machine does not require Elixir at all. See Tech stack for the rest of the changes.
Send your coding agent (Claude Code, Cursor, Windsurf, etc.) this repo and say "install this":
https://github.com/zeshhaan/tab-out
The agent will explain what Tab Out does and set everything up. Takes about 2 minutes (plus ~30 seconds the very first time for Mix to fetch and compile dependencies).
- See all your tabs at a glance -- grouped by domain on a clean grid, no more squinting at 30 tiny tab titles
- Landing pages group -- homepages and feeds (Gmail, X, LinkedIn, GitHub, YouTube) are pulled into one card so you can close them all at once
- Close tabs with style -- swoosh sound + confetti burst when you clean up a group. Makes tab hygiene feel rewarding
- Duplicate detection -- flags when you have the same page open twice, with one-click cleanup
- Click any tab to jump to it -- switches to the existing tab, even across windows
- Save for later -- bookmark individual tabs to a checklist before closing them
- Live cross-window sync -- defer a tab in one Chrome window and the dashboard in another window refreshes immediately, no polling (powered by Server-Sent Events over
Phoenix.PubSub) - Tab Out dupe detection -- notices when you have extra new tab pages open and offers to close them
- Expandable groups -- large groups show the first 8 tabs with a clickable "+N more" to reveal the rest
- Auto-updates -- get notified when a new version is available, update with one click
- 100% local -- your browsing data never leaves your machine. No AI, no external API calls
- Always on -- starts automatically when you log in, runs silently in the background
Two modes, pick whichever fits. scripts/install.sh auto-detects which one to use.
Runs entirely from a GitHub Release artifact. Good for installing on a second machine, a fresh laptop, a colleague's box.
curl -sSL https://raw.githubusercontent.com/zeshhaan/tab-out/main/scripts/install.sh | bashThe script downloads the latest prebuilt tarball for your platform (macOS arm64 or Linux x86_64), unpacks it under ~/.local/share/tab-out, downloads and unpacks the Chrome extension into the same directory, writes ~/.mission-control/config.json, installs a Launch Agent / systemd unit, and starts the daemon. No git clone, no Elixir install.
After the script finishes it prints the path to the unpacked extension/ folder — load that in chrome://extensions → Developer mode → Load unpacked.
Good for hacking on it.
# 1. Install Elixir (build-time only)
brew install elixir # macOS
sudo apt install elixir # Debian / Ubuntu
# 2. Clone and run the installer
git clone https://github.com/zeshhaan/tab-out.git
cd tab-out
bash scripts/install.shBecause the script detects both the source tree and elixir on $PATH, it runs mix deps.get + MIX_ENV=prod mix release locally and points the Launch Agent at _build/prod/rel/tab_out/bin/tab_out. First build takes ~30 seconds while Bandit, Plug, Exqlite, Phoenix.PubSub, Req, and Corsica compile; subsequent builds are a few seconds.
Load the Chrome extension from ./extension/ inside the clone (chrome://extensions → Developer mode → Load unpacked).
Open a new tab — you'll see Tab Out. The server auto-starts on future logins via the Launch Agent / systemd unit.
Manual daemon control:
tab_out start # run in the foreground (useful for debugging)
tab_out daemon # run in the background
tab_out stop # stop the daemon
tab_out remote # attach an iex shell to the live daemon(tab_out here means the binary at _build/prod/rel/tab_out/bin/tab_out if you built from source, or ~/.local/share/tab-out/tab_out/bin/tab_out if you used the prebuilt.)
Config lives at ~/.mission-control/config.json:
| Field | Default | What it does |
|---|---|---|
port |
3456 |
Local port for the dashboard |
The release reads this file at boot via config/runtime.exs, so you can edit it and restart without rebuilding.
You open a new tab
-> Chrome extension loads Tab Out in an iframe
-> Dashboard fetches /api/missions, /api/deferred, /api/stats
-> Dashboard opens a live EventSource to /api/events
-> You close a group, save a tab for later, or archive a mission
-> Server broadcasts an event; other open dashboards refresh automatically
Inside the BEAM VM, the application supervisor starts five children in order:
Phoenix.PubSub— in-process broadcast registry for theeventstopicTabOut.DB.Writer— single GenServer owning the one read/write SQLite connection; creates the schema on bootTabOut.DB.ReaderPool—NimblePoolof 4 read-only Exqlite connections; read queries check one out, run, and return itTabOut.Updater— polls GitHub for a newer commit every 48h usingReqBandit— HTTP server hostingTabOut.Router
WAL mode on SQLite lets the writer and the pool's readers run concurrently without blocking each other. Every DB call and every HTTP request is wrapped in a :telemetry span, and TabOut.Telemetry logs a warning if any query exceeds 50ms.
The server runs silently in the background. It starts on login and restarts if any child crashes — BEAM's supervisor handles that without the OS even noticing.
| What | How |
|---|---|
| Server | Elixir (Mix project under lib/tab_out/, shipped as mix release) |
| HTTP | Bandit + Plug |
| CORS | Corsica |
| Database | Exqlite on local SQLite (same missions.db schema as upstream), with a NimblePool of read connections and a writer GenServer |
| Live sync | Phoenix.PubSub + Server-Sent Events over a chunked Plug.Conn |
| Outbound HTTP | Req |
| Instrumentation | :telemetry + Plug.Telemetry, with runtime_tools bundled so bin/tab_out remote → :observer.start() works against a live daemon |
| Concurrency | OTP supervisor keeps the HTTP server, DB writer, reader pool, pubsub, and update-checker alive; restarts cleanly on crash |
| Extension | Chrome Manifest V3 |
| Auto-start | macOS Launch Agent / Linux systemd user service |
| Sound | Web Audio API (synthesized, no files) |
| Animations | CSS transitions + JS confetti particles |
- Ships as a release with its own ERTS.
mix releaseproduces a directory under_build/prod/rel/tab_out/that contains compiled BEAM files and a bundled Erlang runtime. Copy the directory to another machine and it runs — nobrew install, no package manager, no runtime compilation. You need Elixir to build the release; you do not need it to run the release. - BEAM supervision. A crash in one request handler doesn't tear down the VM. The supervisor restarts the failing child in milliseconds, which is nicer than "launchd notices the process died, restarts Node cold."
- Cheap in-process pubsub.
Phoenix.PubSublets us broadcast an event on every mutation and serve Server-Sent Events to connected dashboards with zero extra infrastructure. That's what powers the live cross-window sync — no polling, no WebSockets, no Redis. - Concurrency that matches SQLite's model. A writer GenServer + a
NimblePoolof read connections maps one-to-one onto SQLite's "single writer, many readers under WAL" semantics. - Compatible on-disk format. Same
~/.mission-control/missions.dbschema, same JSON shapes, same config file. You can switch back to the upstream Node version without losing data.
Once the release is running, you can open a remote iex shell against the live daemon:
_build/prod/rel/tab_out/bin/tab_out remoteFrom there:
# Full BEAM process inspector — every GenServer mailbox, ETS table,
# message queue. Tab Out bundles runtime_tools for exactly this.
:observer.start()
# Peek at telemetry handlers
:telemetry.list_handlers([:tab_out, :db, :query, :stop])
# Hit the DB directly
TabOut.DB.query("SELECT COUNT(*) FROM deferred_tabs WHERE archived = 0")Exit with Ctrl+G, q — the daemon keeps running.
MIT