A Guake-style drop-down terminal emulator for Linux (X11), built with Rust using alacritty_terminal, gpui (Zed's GPU-accelerated UI framework), and wattaouille for power monitoring.
curl -fsSL https://deb.tab-atelier.wdes.eu/tab-atelier.gpg \
| sudo tee /usr/share/keyrings/tab-atelier.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/tab-atelier.gpg] https://deb.tab-atelier.wdes.eu stable main" \
| sudo tee /etc/apt/sources.list.d/tab-atelier.list > /dev/null
sudo apt update
sudo apt install tab-atelier # desktop / GUI
# or:
sudo apt install tab-atelier-headless # display-less server variantReplace stable with nightly to track main. Snapshot versions look like 0.5.0~nightly20260715082716.9f3ab21-1, following Debian Versioning: 0.5.0 is the release being headed towards, ~ sorts it strictly before that release (so apt steps up onto stable when it lands), the timestamp orders one snapshot against the next, and the short sha tells you which commit a .deb on your disk came from.
The two packages conflict by design (they both ship /usr/bin/catbus-agent). apt install tab-atelier-headless after tab-atelier swaps cleanly; dpkg -i … on both at once is what produced the file-collision error you saw on early .deb builds.
./scripts/setup-apt-publishing.shGenerates an ed25519 signing key dedicated to this repo (NOT a personal key), uploads its private half + fingerprint to APT_SIGNING_KEY / APT_SIGNING_KEY_ID via gh secret set (private bytes pipe straight into gh, never written to disk), enables GitHub Pages on the gh-pages branch, and points the API-side cname at deb.tab-atelier.wdes.eu. The DNS CNAME record still has to be added by hand at the user's DNS provider; the script reports the expected target.
The key's User ID is just tab-atelier release signing — no email. RFC 4880 §5.11 lets a User ID be any UTF-8 string, and apt's [signed-by=…] verifies via fingerprint only (see apt-secure(8)). Public keyservers like keys.openpgp.org require an email for uploads, but we publish the public key directly at https://deb.tab-atelier.wdes.eu/tab-atelier.gpg so users curl it — no keyserver path involved. APT_SIGNING_KEY_ID therefore holds the 40-char fingerprint, not an email, which is what gpg --local-user actually wants anyway.
The public half is exported to assets/tab-atelier-release.gpg for reference; the same key is re-exported onto gh-pages by every workflow run so users can curl it.
The script also drops a revocation certificate at $XDG_CONFIG_HOME/tab-atelier/apt-signing-revocation.asc (mode 600). Move that file off the machine — encrypted USB, password manager, printout — so a compromised laptop doesn't take the kill-switch with it. If the live key ever leaks, gpg --import the cert, re-export the public key (now with the revocation signature baked in), and replace tab-atelier.gpg on gh-pages. Clients re-fetch the key on apt update and refuse the now-revoked signature.
cargo build --release
# Binary at target/release/tab-atelierRequires Rust 2024 edition (rustc 1.92+).
cargo deb -p tab-atelier # → tab-atelier_*.deb
cargo deb -p tab-atelier --variant headless # → tab-atelier-headless_*.deb
sudo apt install ./target/debian/tab-atelier_*.debAlways pass -p tab-atelier — a bare cargo deb in this workspace can package the wrong crate. The .deb lays out the following under FHS-standard paths:
| Path | Permission | Contents |
|---|---|---|
/usr/bin/tab-atelier |
0755 |
The binary |
/usr/bin/catbus-agent |
0755 |
Bundled catbus-agent helper (the "Switch to catbus" tab menu resolves it on PATH) — the file both .debs share, so they conflict by design |
/usr/share/applications/tab-atelier.desktop |
0644 |
Desktop entry (registers Tab Atelier in app launchers) |
/etc/xdg/autostart/tab-atelier.desktop |
0644 |
Autostart entry — session managers launch it on login (untick in the session's autostart list to disable) |
/etc/tab-atelier/preferences.json |
0644 |
System-wide preferences fallback an admin can pre-seed — a conffile |
/etc/claude-code/managed-settings.json |
0644 |
Claude Code hooks so claude tracks agent state + tab context out of the box — a conffile |
/usr/share/icons/hicolor/scalable/apps/tab-atelier.svg |
0644 |
App icon (scalable SVG, via the Icon=tab-atelier line in the .desktop) |
/usr/share/icons/hicolor/{16,24,32,48,64,128,256}x…/apps/tab-atelier.png |
0644 |
Rasterized PNG icon fallbacks (KDE/Plasma often won't render an SVG-only app icon) |
/usr/share/tab-atelier/pets/ |
0644 |
Screen-mate pet sprite sheets (*.png) + animations (*.xml) |
/usr/share/doc/tab-atelier/ |
0644 |
README.md, LICENSE, copyright, openapi.yaml |
conffiles (per debian-policy §10.7.2): two — /etc/tab-atelier/preferences.json and /etc/claude-code/managed-settings.json (declared in Cargo.toml's conf-files). dpkg tracks these across upgrades and prompts before clobbering local edits, the policy-correct way. All per-user state — tab list, scrollback, uptime, energy, preferences, single-instance lock — still lives under the user's $XDG_CONFIG_HOME / $XDG_STATE_HOME (see State below), untouched by dpkg.
dirs (per debian-policy §10.5): the package creates only what it installs — including /etc/tab-atelier and /etc/claude-code for the two conffiles above. The per-user ~/.config/tab-atelier/ and ~/.local/{,state/}tab-atelier/ directories are created lazily by the running application on first save — dpkg never touches them, so a dpkg --purge leaves them in place for the user to remove manually if desired.
Terminal
- Drop-down terminal toggled with global hotkeys (default: ` and XF86Calculator, customizable in preferences)
- Full terminal emulation via alacritty_terminal (colors, scrollback, bracketed paste, ...)
- GPU-accelerated rendering via gpui
- Text selection with mouse, copy/paste from context menu
- Clickable URLs and file paths detected in terminal output — double-click opens; right-click a detected link for Copy path (link)
- Reset input & color for misbehaving programs
Tabs
- Multiple tabs with drag-and-drop reordering
- Double-click to rename, right-click context menu
- "Copy path" right-click entry copies the tab's working directory to the clipboard
- Ctrl+Shift+T to open a new tab (inherits working directory)
- Alt+Tab to cycle between tabs
- Shell exit detection with close/respawn confirmation
- Per-tab agent state LED to the left of the tab name (working / unreviewed / error / dead / idle), driven by an in-tab CLI (see Agent state)
Session
- Tabs, working directories, and full terminal output persisted across restarts
- Active tab selection restored on startup
- Agent auto-resume: tabs that were running
catbus-agent,claudeorcodexat last save reopen withcatbus-agent --resume <uuid>/claude --resume <uuid>/codex resume <uuid>typed into the freshly-spawned shell
Preferences
- Theme selection (Dark, Tomorrow Night Blue, Light)
- Cursor style (Block, Bar — a slim beam — or Underline)
- Window opacity (1%-100% slider)
- Language (English, French)
- Configurable toggle hotkeys (press any key to register, applied immediately)
- Configurable browser and code editor for opening links
- Claude-only mode (right-click → Claude-only mode): forces every new tab to launch
claudeinautomode; right-click → New bash tab cancels it and drops back to a shell. Also settable at launch with--claude-only, or live on a running instance withtab-atelier claude-only on|off.
Monitoring
- Per-tab CPU usage, power draw (watts), energy consumption (Wh), and uptime
- Low battery warning with visual indicator
Desktop
- Screen-mate pets — eSheep-compatible sprites (a Mini Owl, seven rainbow sheep, Blue Ham Ham) that roam the screen: walk the floor, climb the walls, cross the ceiling, fall under gravity. Right-click the background → 🐾 Summon a pet for a random one; drag-and-drop to pick one up and toss it. Frozen while the window is hidden. Desktop-only, feature-gated behind
pets - Screenshots (per-tab or full app) saved as BMP, plus a redacted mode that irreversibly blanks tab names before capture for safe sharing
Integration
- HTTP API (port 7890) + TLS variant (port 7891) with token auth and QR code for remote tab management from a phone
- Browser viewer — the daemon serves a per-tab xterm.js terminal view (share-link URLs,
noindex/X-Robots-Tagso a leaked link can't be crawled), with file up/download to each tab's sandboxedinbox//outbox/ tab-atelier set-statusCLI for in-tab tools (agents, hooks, scripts) to publish thinking/waiting/error state to the desktop LEDtab-atelier logs [--lines N] [--json]— tail the running daemon's recent log records (GET /logs). Loopback callers only: the route refuses anything that isn't 127.0.0.1, because the API binds0.0.0.0by default and the records name tabs, working directories and errors. Distinct fromtab-atelier log <filter>, which sets what the next start writes to a file.tab-atelier remote …— mirror tabs from another instance, attach a sidecar terminal, transfer files (sandboxed). See Remote tabs- Anthropic API relay — run Claude locally but route its API calls through a remote tab-atelier that reuses the remote's Claude login (no API key on the local box). See Anthropic API relay
- Per-tab / global env vars — inject env into tabs from the CLI (
tab-atelier env set …). See Environment variables - Headless variant ships as a separate
tab-atelier-headless.deb(no gpui / x11rb / qrcode deps, 7.9 MB vs 12 MB) for servers — same HTTP API, no display required - Wakatime time tracking (reads API key from Zed settings)
tab-atelier # normal mode
tab-atelier --read-only # second instance, no writesA normal launch acquires a single-instance lock on ~/.local/state/tab-atelier/tab-atelier.lock and exits if another normal instance is already running — concurrent writers would race each other and produce inconsistent state files.
--read-only skips the lock so any number of read-only instances can run alongside the primary one. In that mode tab-atelier never writes anything: no tabs.json rewrites, no per-tab output / uptime / energy files, no preference saves, no rename-time file moves. The preferences "Save" button is visually disabled. Useful for snapshotting the running workspace from a script or for poking around without disturbing live state.
tab-atelier --checkThe GUI resolves its libraries at run time, so a build can compile cleanly and still fail to start with nothing but a loader message about a missing .so. --check looks for each one, prints the apt install line for any that are absent, and reports the state and config dirs it will use. Run it first when a launch fails and nothing was written to the log.
It is a global flag, so tab-atelier --check works on the headless binary too — there it reports the dirs and the pty and leaves the GUI libraries alone, since that build never loads them.
For servers (no display, no gpui), install tab-atelier-headless.deb instead. Same HTTP API, same persistence files, same set-status / tabs / remote CLI subcommands — just no window. Ships with a systemd user unit:
CLI examples in this README all say
tab-atelier. On a headless install the identical verbs live ontab-atelier-headless— the two.debs conflict, so each ships only its own name onPATH. Substitute accordingly; nothing else differs.
systemctl --user enable --now tab-atelier-headlessThe headless binary holds the same tab-atelier.lock as the GUI, so the two CANNOT run concurrently for the same user (they'd race on tabs.json etc.).
Tab Atelier reads font configuration from your Zed editor settings:
File: $XDG_CONFIG_HOME/zed/settings.json (defaults to ~/.config/zed/settings.json)
| Setting | Description | Default |
|---|---|---|
ui_font_family |
Terminal font family | monospace |
ui_font_weight |
Font weight (100-900) | 400 |
ui_font_size |
Font size in pixels | 16 |
buffer_font_size |
Fallback if no ui_font_size | 16 |
scroll_sensitivity |
Scroll speed multiplier | 1.0 |
Font weight tip. When ui_font_weight is something other than a multiple of 100 that maps to a real static face (e.g. 250), use a variable font family — fontconfig otherwise picks the closest static face per glyph and rarely-used codepoints (€, —, …) can end up in a different face than the digits next to them, which reads as "uneven bold". scripts/install-monaspace.sh installs Monaspace v1.400 (variable build) into ~/.local/share/fonts/Monaspace/; pair it with "ui_font_family": "Monaspace Neon Var".
In-app preferences (theme, opacity, language, browser, code editor) are stored in:
File: $XDG_CONFIG_HOME/tab-atelier/preferences.json (defaults to ~/.config/tab-atelier/preferences.json)
Tab Atelier splits persisted state across four files to keep a bad write to any one piece from corrupting the rest. Each file is written atomically (.tmp + fsync + rename) with rotated backups (.bak, .bak.1, .bak.2).
| Data | Path | Notes |
|---|---|---|
| Tab list, working directories, active index | ~/.local/tab-atelier/tabs.json |
Tiny, rewritten on every persist tick (~2 s) |
| Per-tab terminal scrollback | ~/.local/state/tab-atelier/output_tab-<sanitized>-<crc32>.json |
One file per tab. Rewritten on every persist tick |
| Per-tab uptime (active seconds) | ~/.local/state/tab-atelier/uptime_tab-<sanitized>-<crc32>.json |
One file per tab. Throttled to once every 30 s; final value flushed on shutdown |
| Per-tab energy (Wh) | ~/.local/state/tab-atelier/power_tab-<sanitized>-<crc32>.json |
One file per tab. Throttled by delta (≥ 0.1 Wh consumed); final value flushed on shutdown |
| Single-instance lock | ~/.local/state/tab-atelier/tab-atelier.lock |
Empty file. Held via flock(2); released automatically by the kernel on process exit (including crashes), so no manual cleanup needed |
Tab filename = sanitized tab name (non-[A-Za-z0-9._-] → _) plus an 8-hex-digit CRC32 of the original name, so two tabs whose sanitized forms collide (e.g. foo/bar and foo_bar) still land in distinct files. Renaming a tab in the UI moves all four files (output, uptime, power, plus their .baks) to the new name's slot so history isn't orphaned.
On Intel systems with readable RAPL counters, each tab shows its estimated power usage in the right-click context menu. The estimate uses the same technique as wattaouille: per-tab watts = package watts * (tab CPU jiffies / total system jiffies). When RAPL is not available, only CPU percentage is shown.
Since CVE-2020-8694 (PLATYPUS side-channel) the kernel ships /sys/class/powercap/intel-rapl/intel-rapl:*/energy_uj as mode 400, owned by root, so a regular user — including the one running tab-atelier — gets Permission denied. Symptom: the watts column on every tab card is empty, the stats popover shows CPU% only, and ~/.local/state/tab-atelier/power_tab-*.json files never get created.
One-shot for the current boot:
sudo chmod -R g+r,o+r /sys/devices/virtual/powercap/intel-raplPersistent (every boot) — drop a udev rule:
echo 'SUBSYSTEM=="powercap", ACTION=="add", RUN+="/bin/chmod -R g+r,o+r /sys/devices/virtual/powercap/intel-rapl"' | sudo tee /etc/udev/rules.d/99-rapl.rules
sudo udevadm control --reload
sudo udevadm trigger --subsystem-match=powercapAfter either, restart tab-atelier. PowerSensor::detect runs once at startup, so a mid-session permission fix is only picked up by the next launch.
What are jiffies? Jiffies are the Linux kernel's internal time-keeping unit — a counter that increments at a fixed rate (typically 100, 250, or 1000 Hz depending on CONFIG_HZ). Each tick, the kernel records CPU time consumed by every process. Per-process jiffies are read from /proc/[pid]/stat and total system jiffies from /proc/stat. The ratio between them gives the fraction of CPU a tab's shell used, which is multiplied by package power (from Intel RAPL) to estimate per-tab wattage.
Tab Atelier exposes tab state on http://<local-ip>:7890 as JSON (and https://<local-ip>:7891 over TLS with a self-signed cert auto-generated under ~/.local/state/tab-atelier/tls.{crt,key}). Access requires a bearer token, shown via a QR code in the right-click menu ("Remote control"). The response includes tab names, working directories, active tab index, and per-tab power stats.
Selected routes:
| Method | Path | Purpose |
|---|---|---|
GET |
/tabs |
List tabs (cwd, name, preview, watts, cpu, uptime, agent state) |
GET |
/tabs/{idx}/output |
Tab scrollback (supports ?since=N&crc=… for delta patching) |
POST |
/tabs/{idx}/input |
Send raw bytes to the tab's PTY |
POST |
/tabs/{idx}/activate |
Switch to a tab |
POST |
/tabs/{idx}/rename |
Rename a tab |
POST |
/tabs/by-id/{tab_id}/status |
Publish agent state — see Agent state |
POST |
/tabs/by-id/{tab_id}/context |
Set/clear the tab's context label (PR/task) — see Tab context |
POST |
/tabs/{idx}/files?name=… |
Upload bytes into <cwd>/inbox/<name> (see Remote tabs) |
GET |
/tabs/{idx}/files?path=… |
Download from the tab's inbox/ or outbox/ sandbox |
DELETE |
/tabs/{idx} |
Close a tab |
POST |
/tabs/rotate-tokens |
Revoke all per-tab share tokens (share links 401) — master only |
POST |
/master-token/reset |
Hot-swap the master API token (old token 401s) — master only |
Bind addresses for both listeners are configurable in preferences (api_addr, api_tls_addr); pass --read-only to launch a second instance that serves the API but refuses every mutating verb.
Auth errors are content-negotiated: a browser (Accept: text/html) opening a revoked share link gets a self-contained 401 page (inline CSS + SVG, no external resources); everything else gets {"error":…} JSON.
An OpenAPI 3.1 spec describes the API: served live (public, no token) at GET /openapi.yaml and shipped in the .deb at /usr/share/doc/tab-atelier{,-headless}/openapi.yaml — point Swagger UI / openapi-generator at it.
So you can use the API and manage share access without hunting for the api.token state file:
tab-atelier token # print the master API token (stdout) + URL hint (stderr)
curl -s http://127.0.0.1:7890/tabs \
-H "Authorization: Bearer $(tab-atelier token)" # the token is stdout, so this is scriptable
tab-atelier rotate-tokens # revoke every tab's share tokens → existing share links 401
tab-atelier reset-master-token # rotate the master token live (no restart) → old token 401s/tabs also reports a viewers count per tab (how many web/remote viewers are attached); the headless tabs listing shows it as 👁 N, and the desktop LED treats a web-viewed tab as reviewed, so its "unreviewed work" (blue) flag is cleared while someone is watching.
tab-atelier can mirror tabs from another tab-atelier / tab-atelier-headless instance. Useful for keeping long-running agents on a server and driving them from the laptop, file transport included.
Endpoints persist in ~/.config/tab-atelier/preferences.json under remote_endpoints (each one has label, url, token, cert_sha256 for TOFU pinning, autoconnect).
# Add a remote — captures the TLS cert fingerprint via TOFU.
tab-atelier remote add --label colossus \
--url https://colossus.lan:7891 \
--token <bearer>
# List + remove
tab-atelier remote list
tab-atelier remote remove colossus
# One-shot tab list (handy for scripts)
tab-atelier remote test colossus
# Live "what's running over there" view
tab-atelier remote watch colossus
# Re-capture the cert after a renewal (Ctrl-C-safe)
tab-atelier remote re-pin colossus
# What a tab is for — the dashboard's band and `dispatch --to`'s role
tab-atelier set-assignment build/api
# Put a decision in front of a person (idempotent by id, safe from a cron)
tab-atelier decision open --id deploy-42 --title "Ship 0.5.0?"
# The fleet's distilled profiles — retire is a tombstone, not a delete
tab-atelier catalog listtab-atelier remote attach colossus build # or `#3`, or the UUIDThe local terminal switches to raw mode. Stdin bytes → RemoteCommand::SendInput. Remote scrollback deltas → stdout, with SGR escapes intact (the relay re-serialises alacritty's grid as ANSI). Ctrl-Q detaches; Ctrl-C is forwarded to the remote process. Polling cadence is ~250 ms — type-then-see is noticeably slower than a local shell but workable for cargo build / pytest / agent sessions.
# Upload — lands at <remote tab cwd>/inbox/<basename>
tab-atelier remote put colossus ./input.csv --tab build
# Download — remote path MUST start with inbox/ or outbox/
tab-atelier remote get colossus outbox/result.csv --tab build -o ./result.csvThe file routes are sandboxed. Even with a valid bearer token, a client can only:
- write to
<tab cwd>/inbox/<basename>(no.., no path separators in the name) - read from
<tab cwd>/inbox/…or<tab cwd>/outbox/…(no.., no absolute paths, symlinks pointing out are 403'd)
Anything else — your source tree, ~/.ssh/, /etc/passwd, .git/config — is unreachable through this API. If you need broader file access, set up a dedicated inbox/ symlink at agent-startup time and the agent can stash references inside it.
The relay rotates its self-signed cert ~30 days before expiry (see cert_needs_renewal in src/api.rs). On the next attach / put / get the CLI prints a warning when the live fingerprint no longer matches the pinned cert_sha256:
⚠ pinned cert fingerprint for colossus has changed.
pinned: <old hex>
live: <new hex>
run `tab-atelier remote re-pin colossus` to accept the new cert.
The connection is allowed through (Phase 2 uses disable_verification on the wire) — the warning is the opt-in signal for the user to ack the rotation. Phase 3 will store the cert DER and switch to strict pinning, at which point the warning becomes a hard refuse-until-repinned.
Run Claude Code + tab-atelier locally, but have every Claude → Anthropic API call travel through another tab-atelier (a remote tab-atelier-headless), which makes the real outbound call reusing the remote's own claude login (OAuth in ~/.claude). The local machine never holds an Anthropic credential and doesn't need to reach api.anthropic.com — only the remote does.
When relay mode is on, every claude tab is spawned with:
ANTHROPIC_BASE_URL = http://127.0.0.1:7890/relay/anthropic # the local relay route
ANTHROPIC_API_KEY = <the local instance's RELAY token> # a stand-in, validates the loopback
Claude POSTs to the loopback relay, which forwards to the remote's /relay/anthropic/* (bearer + optional CF-Access headers); the remote egress swaps the stand-in for its live Claude OAuth token (Authorization: Bearer … + anthropic-version/anthropic-beta) and streams the SSE response back end-to-end.
The stand-in is a relay-only token (<state>/relay.token, minted on first use, 0600), and it is the only credential the relay route accepts — the master token is refused there. An API key is a value tools copy into debug output, crash reports and shared transcripts; the master token administers every tab, so it must not be the thing sitting in a claude tab's environment. The relay token authenticates /relay/anthropic/* and nothing else — it cannot list tabs, read output, inject input or rotate anything. Print it with tab-atelier relay token.
One route is exempt from auth: HEAD/GET /relay/anthropic/api/hello, the reachability probe Claude Code sends before it has a credential to present. Answering it with 401 makes the client treat the whole relay as unusable, so it is served locally with a 200 — no upstream call, no token involved, and it reveals nothing that completing the TCP handshake hasn't already.
One endpoint entry, two credentials, neither of them the master token: --token is the peer's sidecar token (tab-atelier remote my-token over there), scoped to listing tabs, mirroring output, sending input and moving files; --relay-token is its relay token (tab-atelier relay token), which can do nothing but proxy. They don't substitute for each other — an entry with no --relay-token simply can't relay, and says so, rather than presenting the wrong credential and failing at the peer.
The master token is not accepted on either path. Re-add any endpoint configured with it:
tab-atelier remote my-tokenandtab-atelier relay tokenon the peer, thenremote add --token <sidecar> --relay-token <relay>here.
Setup
On the remote (already logged into claude, so ~/.claude/.credentials.json exists):
tab-atelier relay egress on # this host is the terminal hop → Anthropic
tab-atelier relay token # grab its RELAY token for the endpoint belowOn the local machine:
tab-atelier remote add --label box --url https://<remote>:7891 \
--token <remote-sidecar-token> --relay-token <remote-relay-token>
tab-atelier relay via box # relay through the `box` endpoint (by label or id)
tab-atelier relay on # enable; `--relay` at launch also works
tab-atelier relay status # {"mode":true,"egress":false,"target":"https://<remote>:7891"}New claude tabs now relay transparently. The full command:
| Command | Effect |
|---|---|
relay on / relay off |
toggle relay mode |
relay via <label|id> |
pick the remote to relay through (relay via "" clears) |
relay egress on|off |
mark this host as the terminal hop to Anthropic |
relay status |
print the live config (mode / egress / target) |
relay token |
print this instance's relay-only token (paste into the peer's remote add --token) |
All changes apply live (persisted + re-installed, no restart). The relay_egress role and relay_endpoint_id live in preferences.json but are set via the CLI above.
The relay token travels in either x-api-key (what a claude client sends) or Authorization: Bearer (what the forwarding hop sends); both are accepted whatever the instance's role, so a claude pointed straight at an egress box works the same as one going through a local hop.
Smoke test — verify the whole chain without launching Claude:
curl -N -H "x-api-key: $(tab-atelier relay token)" -H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-5","max_tokens":64,"stream":true,
"messages":[{"role":"user","content":"say hi"}]}' \
http://127.0.0.1:7890/relay/anthropic/v1/messages-N disables curl buffering so you see the SSE data: frames stream in. Errors map to a stage: 401 relay: unauthorized (wrong x-api-key), 502 relay not configured (no relay via), 502 egress oauth: … (remote can't read/refresh its Claude creds — re-/login there).
catbus-agent is a relay client like any other, not a second implementation of the login:
catbus-agent --relay-url https://proxy.example.org --relay-token tap_…With no flags it reads the relay endpoint from the same preferences.json, so on a machine that already runs tab-atelier a plain catbus-agent works. CATBUS_RELAY_URL, CATBUS_RELAY_TOKEN and CATBUS_PREFERENCES are the env equivalents.
The Claude login lives only on the relay. catbus-agent no longer reads ~/.claude/.credentials.json, no longer refreshes an OAuth token, and cannot be pointed at api.anthropic.com — with no relay configured it refuses to start rather than silently going direct. So an agent tab works on a box with no claude login at all (a CI runner, a container): it needs only a relay token, and there is no subscription credential in its environment or its crash reports. Reasoning blocks from a model the relay routes to are kept verbatim in the transcript the upstream requires back, but are never shown as the answer.
A catbus-agent session has several readers and only some have a terminal. The REPL paints to one; the transcript, the socket, and the tab-atelier chat view do not. So the agent is told to format for the sink that will actually render it:
| Session | Instruction | Reply |
|---|---|---|
| REPL, stdout a tty | ANSI SGR colour | keeps its colour |
--no-tui, or piped |
plain prose, no markdown, no escapes | stripped |
| the transcript on disk | — | always plain |
--ansi (or CATBUS_ANSI=true) forces escapes on for a pipe known to render them — a script capture, an xterm-backed panel. NO_COLOR (any non-empty value), CLICOLOR=0 and TERM=dumb force them off, and an explicit --ansi wins over all three. The transcript is plain regardless, because tab-atelier renders chat bubbles straight from it and has no terminal to interpret anything.
Precedence, most explicit first: --ansi/--ansi=false → NO_COLOR / CLICOLOR=0 / TERM=dumb → whether stdout is really a terminal.
All three opt-out signals are honoured because the app uses two mechanisms and a client does not get to choose which one it is handed:
NO_COLOR/CLICOLOR— whatnew_tab_envsets for the tabs an agent asked for, since those tabs' output is read by another program (peek,output, a--waitpoll).TERM=dumb— what the per-tab colors toggle sets (pty_env). It is a blunt instrument, and the app's own note says so:TERM=dumbclaims the terminal cannot render colour, which degrades every TUI in the tab rather than just asking for no colour. The tab's colours-off environment now also setsNO_COLOR=1andCLICOLOR=0, so the standard signal travels with the legacy one and both spawn paths (pty_env,minimal_pty_env) agree.TERMis still set todumbas before, because tools that read nothing else still rely on it — changing what every program in the tab sees is a separate decision.
This is belt-and-braces on purpose: the instruction is a request the model may ignore, and the transcript is written from the reply as the model sent it, so escape sequences are also filtered out on the way to any reader that will not interpret them. That filter lives in crates/catbus-agent/src/ansi.rs, on vte's parser via strip-ansi-escapes (already in the binary as a reedline dependency). tab-atelier peek applies the same rule for the same reason.
While a reply is downloading, the REPL repaints one line in place:
⠹ Thinking - ~1,339 tokens in
The spinner animates, the label is the agent's current activity (Thinking while waiting on the model, or the tool description it sets otherwise), and the count is an estimate — marked ~ because the Messages API reports usage only in its final response, so until then the only figure available is local arithmetic on the payload size (statusline::estimate_input_tokens, 4 bytes/token, deliberately crude). Before this, that field read 0 tokens in for the entire download, which is worse than no number at all: it looks like the request is stuck. When no request is in flight the count is omitted rather than shown as ~0, since ~0 claims a measurement of zero.
When the reply lands, a totals line is printed below it:
12,345 in - 6,789 out manual
Cumulative tokens on the left, the mode on the right, flush with the terminal edge — and right-aligned by padding, not a cursor escape, so the line stays correct in a log or a script capture. On a terminal narrower than the text it degrades to a single space and wraps, rather than truncating: a wrapped line is legible, a cut one misreports the numbers.
The right-hand field is auto when the judge is in the loop and manual otherwise — a two-state answer to "will something be consulted before a change?", which is the distinction worth showing at a glance. plan and open both read manual; the finer distinction is already visible in /help and the environment turn.
Both lines are built by crates/catbus-agent/src/statusline.rs as pure functions of numbers, so the layout edge cases are unit-tested without a terminal — which matters, because the REPL is only reachable through one.
FileTree lists a directory to a depth you choose: {"path": ".", "depth": 2}. Output is one relative path per line, sorted, directories suffixed /, symlinks shown as name -> target, then a --- line and totals:
/home/you/project
src/
src/deep/deeper/leaf.txt
src/lib.rs
src/main.rs
README.md
srclink -> src
---
2 directories, 4 files, 1 symlink
Relative, flat paths on purpose: every line can be passed straight back as the path of a Read or Write with no joining, and it is the shape sort and rg --files print. A path the model has to derive from indentation is a path it can get wrong, and a wrong path costs a whole failed round trip — more than the bytes an ancestor prefix repeats. depth is required and capped at 6 — a deeper request is refused rather than clamped, because silently showing less than asked for is how a model concludes a directory is empty when it is not. max_entries bounds the output and any truncation is reported.
What is skipped, and why it changed. Two things only, both reported:
- Version-control directories (
.git,.hg,.svn,.bzr)..gitis the one directory guaranteed to dwarf the project it sits in. - Whatever the project's own ignore rules exclude, via ripgrep's
ignorecrate —.gitignoreat any level,.git/info/exclude, and the operator's global ignore file. So a filerghides is a file this hides.
An earlier version carried a hard-coded noise list (node_modules, target, __pycache__, …). That was the wrong instrument: those directories are already gitignored in nearly every project, so the list was redundant where it was right and wrong where the project disagreed — a vendored target/ that is committed, or a tree with no node_modules at all. The project's own rules are correct by construction: they are its statement about what is not source. Dotfiles are not skipped (.github/, .env.example, .eslintrc are exactly what an agent gets asked about); VCS directories are the only name-based exception, and they are enumerated rather than pattern-matched.
Two flags widen the listing, both defaulting to false:
show_ignored: true— include ignored paths and descend into ignored directories, sonode_modules/shows its contents. Also the way to tell "missing" from "ignored".show_vcs: true— include version-control directories.
Both are gated on the walker rather than filtering entries afterwards, because an ignored directory has to be traversed to list what is inside it. Non-boolean values fall back to the quiet default: these flags widen output on purpose, and "false" read as truthy is the classic way show_vcs would dump a whole .git into the context window. When show_ignored is set the note says so, since an unqualified "ignore rules applied" would mislead.
The global ignore file is honoured, which is a real asymmetry. Unlike the project's rules, ~/.config/git/ignore (or core.excludesFile) is per-machine, so a pattern in your global ignore hides a file for your agent and not for a colleague's — the same asymmetry rg already has. Each call names the sources it consulted, so a file you expected but cannot see is explicable rather than mysterious. On a default XDG setup the file is read from $XDG_CONFIG_HOME/git/ignore.
FileTree exists so that Read and Write can be a whole tool set. Read shows a file but not what sits beside it, so without a listing tool an agent cannot discover a path the prompt never named — it guesses filenames with Read until something hits. Hence three tools and not two:
catbus-agent --tools-config minimal # == {"allow": ["Read","Write","FileTree"]}With no shell the agent cannot run the tests it writes, and Write is the only way it can save anything. What is absent cannot be reached even by a confused model: dispatch refuses any name the set does not offer, so emitting a tool_use for Bash on an agent that was never given one returns unknown tool: Bash instead of running.
A proxy-side tool policy whitelist fails closed for names it does not know, so a newer client tool is stripped before the model ever sees it. The symptom is quiet: nothing errors, the client puts three tools on the wire, the model is told it has two, and it reports the tool as unavailable — while the agent guesses filenames with Read and never says why. Add FileTree to the account's policy alongside Read/Write (see docs/proxy-tools.md) before expecting the minimal set to work through a relay. If an agent claims a tool is missing, check the policy before the client.
/clear in the REPL starts a fresh session in the same working directory. Nothing is deleted: the old transcript stays on disk and the reply names its id, so /resume <id> goes back. Forgetting the conversation rather than emptying the history keeps the old tokens/turns sidecars meaningful and leaves the operator an undo, which is what makes it safe to offer. The same thing is available over the socket as {"kind":"clear"}, so the GUI and a phone can offer it too.
Inject env vars into tabs' PTYs from the CLI — globally (all tabs) or per-tab:
tab-atelier env set FOO=bar --global # every tab
tab-atelier env set FOO=bar --tab 3 # one tab (index or UUID)
tab-atelier env unset FOO --global
tab-atelier env list --global # print the global mapPrecedence, lowest to highest: global user env < per-tab env < functional vars (_TAB_ID, TAB_ATELIER_API_*) < relay injection. Global vars persist in preferences.json (tab_env); per-tab vars persist per tab in tabs.json and are re-injected on respawn. Changes take effect on a tab's next (re)spawn.
Each tab carries an optional agent state rendered as a small colored LED to the left of the tab name. The colour answers "did an agent do work here, and have you seen it?":
The LED stays visible for the entire life of an attached session: once your shell sends a --session <uuid> it sticks until the session genuinely ends. While a background agent works its dot is green; when that turn finishes it goes blue ("you have output to look at here") and stays blue until you review the tab — then grey. "Review" means making the tab active or having its web viewer open. Claude Code's SessionEnd hook (or a manual tab-atelier set-status idle) wipes both the transient state and the durable session attachment, so the LED disappears.
State is stored in RAM only (the durable session id, agent kind, and plan-mode flag are persisted to tabs.json for auto-resume).
Every PTY tab-atelier spawns gets three env vars so in-tab tools can publish state without configuration:
| Variable | Value |
|---|---|
_TAB_ID |
Stable per-tab UUID. Survives renames. |
TAB_ATELIER_API_URL |
http://127.0.0.1:<api_port> |
TAB_ATELIER_API_TOKEN |
Same token shown by the "Remote control" QR code |
tab-atelier set-status <state> [--label <hint>] \
[--session <uuid>] \
[--kind <catbus|claude|…>] \
[--plan|--no-plan]
# state: idle | thinking | waiting | error
#
# `idle` takes the indicator down but keeps whatever --session / --kind it was
# given, so a wrapper with no hook of its own can say "not working any more" and
# "here is the session to resume" in one call. The durable attachment is dropped
# only by `--label __clear__`, which `claude-hook session-end` sends.The CLI silently exits 0 when _TAB_ID is unset (i.e. invoked outside a tab), so it is safe to call unconditionally from .bashrc snippets, agents, or build hooks.
catbus-agent calls this internally at each lifecycle point. To get the same LED behaviour out of an external Claude Code session, configure a hook in ~/.claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{ "matcher": "", "hooks": [{ "type": "command",
"command": "bash -c 'sid=$(jq -r .session_id); tab-atelier set-status thinking --kind claude --session \"$sid\"' >/dev/null 2>&1" }] }
],
"Stop": [
{ "matcher": "", "hooks": [{ "type": "command",
"command": "bash -c 'sid=$(jq -r .session_id); tab-atelier set-status waiting --kind claude --session \"$sid\"' >/dev/null 2>&1" }] }
],
"PermissionRequest": [
{ "matcher": "", "hooks": [{ "type": "command",
"command": "bash -c 'sid=$(jq -r .session_id); tab-atelier set-status waiting --label permission --kind claude --session \"$sid\"' >/dev/null 2>&1" }] }
],
"StopFailure": [
{ "matcher": "", "hooks": [{ "type": "command",
"command": "bash -c 'sid=$(jq -r .session_id); tab-atelier set-status error --kind claude --session \"$sid\"' >/dev/null 2>&1" }] }
],
"SessionEnd": [
{ "matcher": "", "hooks": [{ "type": "command",
"command": "bash -c 'sid=$(jq -r .session_id); tab-atelier set-status idle --kind claude --session \"$sid\"' >/dev/null 2>&1" }] }
]
}
}| Event | State | Why |
|---|---|---|
SessionStart |
waiting (label session) |
Claude attached to the tab — LED appears immediately, not only at first prompt |
UserPromptSubmit |
thinking |
You sent a prompt — work has started |
PreToolUse |
thinking (label tool) |
Refreshes the LED on every tool call so a long turn never gets reclaimed by the staleness sweep |
Stop |
waiting |
Claude finished its turn, awaiting next prompt |
PermissionRequest |
waiting (label permission) |
Claude paused for tool/file approval — needs you |
StopFailure |
error |
Turn aborted (API error, crash) |
SessionEnd |
idle |
Session terminated — clear the LED |
Each tab can carry a free-text context label — what the agent in it is working on (a PR, an issue, a task). Hover the tab name in the desktop GUI and the label shows as a tooltip; it's also surfaced on /tabs (omitted when unset). Handy when several tabs each run their own Claude and the tab names alone don't tell you which is on what.
An in-tab agent sets it from its own shell — no token needed, the endpoint is auto-discovered from the injected TAB_ATELIER_API_* env:
tab-atelier set-context "PR #3719: dompdf font reproduction"
tab-atelier set-context --tab <id> "labelling a worker I just spawned"
tab-atelier set-context --clearThe text is capped at 2000 chars; a whitespace-only value clears it. Like set-status, it's safe to call unconditionally — it's a silent no-op (exit 0) when the TAB_ATELIER_API_* env isn't present, so a shell rc file or Claude Code hook calling it outside a tab does nothing.
Both .debs wire this automatically: the shipped /etc/claude-code/managed-settings.json routes Claude Code's UserPromptSubmit through tab-atelier claude-hook user-prompt (desktop) / tab-atelier-headless claude-hook user-prompt (headless), which stamps the prompt as the tab context, and SessionEnd clears it — so any claude running in a tab labels its tab with no per-user setup. To do it yourself instead, point a UserPromptSubmit hook in ~/.claude/settings.json at set-context, or just ask Claude to run it when it picks up a task.
set-context is a note about what a tab is doing right now; it changes every few minutes. An
assignment is what the tab is for — its band and its role — and it changes when the work does. It
is the field the Kiosk's dashboard groups by, and the one dispatch --to <role> matches.
tab-atelier set-assignment build/api # the band "build", role "api"
tab-atelier set-assignment review/pr
tab-atelier set-assignment hotfix:build/api --tab <id> # narrowing the band, on someone else's tab
tab-atelier set-assignment --clearThe grammar is [override:]phase[/role]. The phase is the dashboard band; the override narrows it
(hotfix inside build); the role is free text, and may contain a slash (review/infra/db), because
a reviewer of infra/db is a real thing. A tab with no assignment lands in non assigné rather
than disappearing from the board — that is the whole reason it is a typed field and not a meta
label: a label nobody sets leaves every band empty, which is a page that renders and says nothing.
The Kiosk renders the decision journal, but the thing that needs a decision is usually a build hook, a dispatcher or an agent with no browser open. This is that path.
tab-atelier decision open --id deploy-42 --title "Ship 0.5.0?" --why "the tag is ready"
echo '{"id":"deploy-42","state":"open","title":"Ship 0.5.0?"}' | tab-atelier decision open -
tab-atelier decision list
tab-atelier decision list --all --id deploy-42Appending is idempotent by id, and the verb tells you which of four things happened:
| Answer | Meaning |
|---|---|
appended |
new — it is on the board |
already-present |
byte-identical re-post; nothing written |
already-ruled |
the id was settled; the verdict was left alone |
updated |
an open decision whose content changed, replaced rather than duplicated |
That is what makes it safe to call from a cron or a hook that may fire twice: a repeat cannot put two
cards in front of a person, and it can never silently overturn a ruling. decision list shows what is
still waiting; --all adds the archived ones (ruled decisions are always listed — they carry the
verdict, which is the record).
tab-atelier catalog list # what is on offer
tab-atelier catalog list --all # including retired
tab-atelier catalog retire swift-reviewer
tab-atelier catalog restore swift-reviewerretire is a tombstone, not a delete: the profile stops being offered, and the prompt it recorded
survives, because a later agent may need to know what the fleet had been told. It reads and writes the
catalogue file directly rather than going through the daemon — a skill is your notes about your own
fleet, not fleet state, so there is nothing to coordinate.
When a tab carries both agent_kind and agent_session_id in tabs.json, the restored tab — about 500 ms after its shell has come up — receives a Ctrl-U followed by the appropriate resume command:
agent_kind |
Injected command |
|---|---|
catbus |
catbus-agent --resume <uuid> (plus --plan if the agent was in plan mode at save time) |
claude |
claude --resume <uuid> |
codex |
codex resume <uuid> |
| anything else | no-op |
The shape differs per CLI: Claude takes a --resume flag, codex a resume
subcommand. Codex has no hook wired on this machine, so agent_session_id is
discovered by matching the tab's working directory against the session_meta
header of its rollouts (~/.codex/sessions/…/rollout-*.jsonl). Launch it through
scripts/codex-agent.sh to have that stamped, along with a state and a label.
See docs/agent-support-research.md.
If the agent CLI is no longer on PATH, the shell prints command not found and the tab is otherwise unaffected.
tab-atelier just types claude --resume <uuid> — the permission mode (manual, auto-accept, auto, plan) is Claude Code's own setting, not something tab-atelier overrides. To make sessions start in a given mode, use Claude Code's user-level default in ~/.claude/settings.json:
{ "permissions": { "defaultMode": "auto" } }Values: default (manual — prompts for everything), acceptEdits (auto-accept file edits), auto (classifier-based auto-approval — the "⏵⏵ auto mode on" indicator; requires account opt-in and must be set at the user level, project-level auto is silently ignored), plan, bypassPermissions. This sets the default for new sessions; --resume restores whatever mode a session was last in (v2.1.143+), so once a tab's session has run in auto it resumes in auto. To force a mode on resume regardless, you'd pass --permission-mode <mode> yourself.
Every tab can shell out to the local API — the tab-atelier CLI is on PATH and auto-discovers the API token the same way brain does — so an agent (a claude session) running in one tab can see, message, and hand files to the other tabs. These are client commands: they talk to the running instance over the API, so they work alongside the desktop GUI without tripping the single-instance lock. <tab> is a name, index, or UUID everywhere (an ambiguous name errors and lists the candidate indexes, so a message never hits the wrong twin).
See who's around:
tab-atelier tabs # every tab: idx uuid name
tab-atelier peers # just the Claude tabs — name · state · cwd · current task
tab-atelier peers --all # include non-agent tabs
tab-atelier peek <tab> # read a peer's screen (ANSI-stripped, last 40 lines; --lines N, --raw)Send work to another agent — dispatch (see also docs/teamwork.md):
# Fire-and-forget: type a prompt into another tab's agent.
tab-atelier dispatch --to <tab> "re-run the migration and report the diff"
# Ask-and-wait: send, poll until the target goes idle, then print its screen back.
tab-atelier dispatch --to <tab> --wait "is this schema change safe?"
# Spin up a FRESH agent tab and launch work in it.
tab-atelier dispatch --new --name worker --cmd claude "port the auth tests to vitest"--wait reports once the target's screen has been unchanged for --quiet seconds (default 8; --timeout bounds the total wait). The Enter that submits a prompt is sent as its own keystroke after a short delay, because Claude Code treats a prompt and a trailing newline arriving together as a paste.
Broadcast to everyone — an append-only shared blackboard at <state>/tab-atelier/blackboard.jsonl:
tab-atelier note --topic schema --from api "users.email is now NOT NULL"
tab-atelier notes --topic schema # read a channel
tab-atelier notes --since 42 # only entries after index 42 (incremental polling)Hand off a file — copy it into a peer tab's inbox/ (same place web uploads land):
tab-atelier handoff ./report.md db-expertEtiquette (and safety): only dispatch to a tab peers shows as idle/waiting, never mid-turn; a locked tab refuses input; and never --resume/--continue another tab's session — it rotates/strips the session id. Every Claude that starts inside a tab is told these verbs exist automatically: the system-wide SessionStart hook the deb installs injects a short brief (docs/agent-brief.md). Nothing to copy into ~/.claude/CLAUDE.md, and a Claude started outside a tab is told nothing about verbs it can't use. Override the text with ~/.config/tab-atelier/agent-brief.md, or opt out with TAB_ATELIER_NO_BRIEF=1.
Per-project briefs go in ~/.config/tab-atelier/briefs/*.md (or /etc/tab-atelier/briefs/ machine-wide), selected by working directory:
---
baseDir: /mnt/clients/ABCD
---
Client ABCD: PHP 7.4, no `composer update` without asking. Deploys are manual.Any agent starting inside that directory is told this on top of the standard brief; matches compose, narrowest last. tab-atelier brief --cwd <dir> prints exactly what a session there would receive, and --list shows which files matched — worth checking, because a typo'd baseDir fails silently.
Let the fleet divide the work itself — instead of deciding who does what, put the work on a shared board and let agents take it:
tab-atelier backlog --from './my-task-source.sh' # any source: `id<TAB>title` per line
tab-atelier backlog --lcov target/lcov.info # or the built-in coverage source
tab-atelier tasks # the board
tab-atelier take # lease the best open task for THIS agent
tab-atelier done cov:src/api.rs "40% -> 82%"
tab-atelier wait cov:src/api.rs && echo "…and now the follow-up"
tab-atelier fleet # who is working on what (--json for a graph)
tab-atelier gossip # converge boards with configured remotestake leases the task through the daemon, so two agents never pick up the same one, and a lease from a tab that dies simply expires. Nothing schedules: agents rank the board by a hash of (task, agent) and spread out without talking to each other. wait reports outcomes as exit codes (0 done, 1 failed, 3 running, 4 unknown) instead of holding a connection, so a supervisor can run many at once — or you can skip supervisors entirely, which is the point.
Across machines, a task's home is the host it was announced on, and taking it means claiming it there over the same remote endpoint the sidecar uses — so two machines can't hand out the same work. Everything else stays each host's own business.
The handbook — shipped in the deb at /usr/share/doc/tab-atelier/, so an agent on a machine with no checkout can still read them. The first two are written for whoever is using the agent, not for its developers:
docs/agent-guide.md |
using it: the commands, the three permission modes, every tool, how to answer a question it asks, and what its output means |
docs/config-guide.md |
configuring it: where its files live, the identity file, choosing the tool set, and how an internet-disabled tab still reaches the relay |
docs/self-organization.md |
the model: leases, contract net, gossip, the federation-vs-confederation distinction, and why this is CRDTs rather than Raft |
docs/fleet-playbook.md |
the operational side, written to be handed to an agent: fill a board, spawn workers, and — the part that matters — verify what they report |
docs/proxy.md |
tab-atelier-proxy: one Claude login shared by a team, with an account and a key per person instead of one token for everybody |
scripts/self-org-sandbox.sh runs the whole thing across two sandboxed daemons without touching anything of yours.
Wakatime integration is automatic if your Zed settings contain wakatime.settings.api-key. Heartbeats are sent with project detection (walks up to find .git).
Control log output with the RUST_LOG environment variable:
RUST_LOG=tab_atelier=info cargo run # Startup, restore, API
RUST_LOG=tab_atelier=debug cargo run # Verbosecargo test
cargo clippyAfter a fresh clone, opt-in to the repo's pre-commit hook so a commit that would fail CI fails in three seconds instead of fifteen minutes:
git config core.hooksPath .githooksThe hook runs the checks CI blocks on, but only against what you staged — a docs-only commit runs none of them, and a commit touching one crate lints that crate rather than the workspace:
- licence headers — every staged
.rs/.js/.sh/.py/.htmlfile must carry the SPDX line wherelicensechecklooks, sincedebian/copyrightis generated from it. - generated assets —
web/src/*.tsmust ship with a rebuiltassets/*.js; a stale bundle passes every test and serves the old UI. - rustfmt —
cargo fmt --all -- --check. - clippy —
-D warningson the crates you touched, using theRUST_VERSIONpinned in.github/workflows/build.yml. SetTA_PRECOMMIT_ALL_FEATURES=1to also run CI's headless feature pass (it invalidates the build cache, so it is off by default).
TA_PRECOMMIT_SKIP=licence,fmt,clippy,assets skips named checks;
git commit --no-verify skips all of them for a one-off WIP commit.
MPL-2.0
Terminal rendering patterns based on Zed (Apache-2.0 / GPL-3.0).






