work
A self-hosted web GUI for agentic dev workspaces — on desktop and as a mobile PWA. Spin up multi-repo worktree workspaces, run coding agents (Claude Code, Codex, Copilot, Cursor, Grok) or plain shells in real terminals, review diffs and send feedback to agents, and watch PR status — from any device.
- Workspaces — named directories of git worktrees (one per GitHub repo, cloned from a shared bare-repo cache), all on a generated branch. Create, add/remove repos, archive/restore, delete.
- Sessions — real CLIs in tmux, mirrored to the browser over WebSocket (xterm.js). Sessions survive server restarts and phone locks; agent hooks surface working / waiting / idle state at a glance.
- Review — a fast diff view across every repo in the workspace, with line comments you can send straight into an agent session.
- Mobile-first PWA — installable, with a touch-tuned terminal (sticky keyboard toggle, d-pad for arrows/tab/ctrl-c, momentum scrolling).
Install (bare machine)
One command on a fresh Linux or macOS box — installs the system tools (git, tmux, a compiler for node-pty), mise + node, the published package, and the background service (systemd/launchd):
curl -fsSL https://cdn.jsdelivr.net/npm/@dylanpiercey/work/install.sh | bash
Already have node (and git/tmux)? npm i -g @dylanpiercey/work && work setup
does the same minus the system tools. Update later with work update (or
re-run the installer). Under a version manager (mise, as the installer
sets up; nvm, asdf, fnm) work is installed inside that one Node version's
directory, so upgrading or removing that Node removes work with it: with
the new Node active, install it again the same way (work doctor says
when this applies).
Develop
Requires Linux or macOS with git, tmux, gh (authenticated), and Node 24+.
The bootstrap script installs the tools via mise
and runs pnpm install; GitHub sign-in and the agent CLIs are the app's
own Settings:
./scripts/bootstrap.sh
Run
pnpm dev # http://localhost:7171
# While hacking on work itself, point the background service at a checkout
# (and back at the npm install when done):
cp scripts/work-source.sh ~/.local/bin/work-source && chmod +x ~/.local/bin/work-source
work-source local # this tree (or: work-source local /path/to/work)
work-source rebuild # rebuild + restart without rewriting the unit
work-source prod # global @dylanpiercey/work again
work-source # status
From the command line (and agents)
Everything the app's pages do has a work command too, so an agent — or a
script — can use and set up work from a terminal: work help lists them.
Inside a workspace, work branches, work session, work topic,
work review, work notify and work open (agents delegate through
their own CLI's subagents, not through work); to change things,
work workspace create, work repo add, work config get|set (checked
against what each setting takes; work config schema lists them),
work agent|skill|lsp|host and work gh login; work status sums the
machine up, and work procs prints each workspace port's link as the
user's browser would open it. Every command takes --json (JSON alone on
stdout) and never prompts under it or --non-interactive.
Access
work has no login of its own — it runs agents and terminals as you, so it
answers this machine only by default (it listens on 127.0.0.1). To
reach it from other devices, put it behind something that terminates TLS
and authenticates — a reverse proxy with a login, a Cloudflare Tunnel with
Access, tailscale serve — and tell work about it:
WORK_ALLOWED_HOSTS=work.example.com— the name(s) the proxy serves (their workspace-port subdomains are included). work refuses any other name, which is what stops a web page elsewhere from pointing its own name at your machine (DNS rebinding).- If the proxy runs on another machine, work has to listen on the
network for it:
WORK_LISTEN=0.0.0.0andWORK_TRUSTED_PROXIES=<proxy ip>(IPs or CIDRs, comma-separated). work then answers that proxy and this machine, and refuses everyone else on the network, who would otherwise walk around the proxy's login.WORK_TRUSTED_PROXIES=*answers any machine — only on a network you trust as it is.
Set them when installing (WORK_ALLOWED_HOSTS=… WORK_TRUSTED_PROXIES=… WORK_LISTEN=0.0.0.0 work setup); work setup keeps them in the service
from then on. A refused request gets a page saying which setting it needs.
Everything (pages, API, and the /term websockets) serves from the single
app port, so route the whole hostname to it.
Tailscale is the zero-config way. On a machine logged in to Tailscale
(1.52 or newer), work setup offers to run tailscale serve --bg <port>,
which puts work at https://<machine>.<tailnet>.ts.net/ for your devices on
the tailnet alone (never Funnel, which would publish it to the internet),
and adds that name to WORK_ALLOWED_HOSTS: open it on a phone signed in to
the same tailnet. work setup --tailscale does it without asking and
--no-tailscale skips the question; --yes alone leaves Tailscale's config
as it is. A serve config already pointing at work only has its name
allowed, and work doctor says which of these holds.
Direct connections. Behind a proxy, a tunnel or a relay, terminals
move to a direct WebRTC connection to this machine when it is measurably
faster than the path through the proxy (Settings → Terminal → Direct
connection: Auto by default, Always, or Off, one setting for every device). Pages,
the API and sign-in still go through your proxy; the direct path is set up
over the app's authenticated events socket, carries terminal traffic
alone, encrypted (DTLS), to UDP port WORK_RTC_PORT (by default the app's
port number), and ends when that login does. That port is held by a helper
process of work's own, only while direct connections are in use: it
starts at the first one and exits ten minutes after the last. Proxy-side inspection or
logging does not see terminal traffic that goes direct: turn the feature
off (per device in Settings, or WORK_RTC=off for the machine) where it
must. On the LAN the direct path needs nothing more; from another network
(a phone on cellular) the UDP port has to be reachable through your router.
Settings → Terminal can allow work to open it on the router when needed
(UPnP, off by default): only after a device failed to connect without it,
leased, and given back once unused. A host firewall can keep a device
out too, though usually it does not (ICE gets through a stateful one from
most networks), so work says nothing of it until a device has failed to
connect directly while nothing has come in through the port. Then work doctor says it drops the port, or may (where only root can read its
rules, it judges from the defaults and gives the command that shows
them), with the command that admits it, and work setup offers to add
that rule, named work so it is removed as one (--firewall adds it
without asking, --no-firewall skips the question).
macOS's firewall admits programs rather than ports, so there it is the
node the service runs that work doctor checks and work setup lets in. WORK_RTC_STUN names the STUN servers that find this machine's
public address (Cloudflare's by default; empty for none).
A faster git status. Listing a large repo's untracked files is most
of what git status costs, and work asks it of every checkout and topic
branch's worktree. So the service turns on git's untracked cache
(core.untrackedCache) in the repos work keeps (<work root>/.cache, whose
worktrees are every workspace's checkouts), after which git reads again
only the directories that changed; your agents' own git status gets
faster too. It relies on directory modification times, so it is only
turned on where the work root is a local filesystem with fine timestamps
(not NFS, SMB, FUSE mounts, HFS+ or FAT) and git's own check passes there.
A repo set core.untrackedCache=false is left alone, and
git config --global core.untrackedCache false keeps it off everywhere;
work doctor says which it is.
Opening workspace ports remotely. The port chips in the sidebar link to
http://<host>:<port>, which only works where the machine is reachable
directly — under a tailnet name too, since tailscale serve has no
wildcard names: there a chip reaches a dev server that listens beyond
loopback (--host), and one on loopback says so. Through a tunnel, route
a wildcard hostname to the app port as well and work proxies it — HTTP and
websockets, so HMR works — to localhost:<port>. WORK_PORT_HOSTS picks
the hostname shape the chips link to; sub is always accepted, dash only
when picked, and only for a name of three labels or more:
sub(default):https://3000.work.example.com. Needs a wildcard*.work.example.comroute and a certificate that covers it (your own reverse proxy with a DNS-01 wildcard; Cloudflare only with Advanced Certificate Manager, since Universal SSL covers one label).dash:https://3000-work.example.com. One label under the zone, so a Cloudflare Tunnel works on the free plan. DNS wildcards match a whole label, so the tunnel's public hostname is*.example.compointing at the app port, with an Access application on the same wildcard so the ports sit behind the same login as the app. Work ignores hostnames that do not start with a port, so other names under the zone are unaffected.
Only loopback ports on the work machine are reachable, behind the same
auth as the app. API writes and the terminal websockets refuse a foreign
Origin, so a page a workspace serves cannot drive work with the shared
login cookie.
Configuration
| Variable | Default | Purpose |
|---|---|---|
WORK_ROOT | ~/work | Workspaces root (+ .cache bare repos) |
WORK_DATA_DIR | ~/.local/share/work | sqlite db and server state |
WORK_APP_PORT | 7171 | HTTP server (app, assets, and /term websockets) |
WORK_ALLOWED_HOSTS | — | Hostnames a proxy serves it under (see Access) |
WORK_LISTEN | 127.0.0.1 | Listen address (0.0.0.0 for every interface) |
WORK_TRUSTED_PROXIES | — | Proxy IPs/CIDRs allowed in from the network (*) |
WORK_PORT_HOSTS | sub | Port-chip hostname shape: sub / dash (see above) |
WORK_USER / WORK_HOST | current user/host | Branch-name prefix parts |
WORK_BRANCH_PREFIX | <user>-<host> | Whole branch-name prefix, overriding the parts |
WORK_RTC | on | off turns direct connections off (see Access) |
WORK_RTC_PORT | the app port | UDP port of direct connections |
WORK_RTC_STUN | Cloudflare's STUN | Their STUN servers, comma-separated (empty: none) |
The usage panel (and work usage) reads plan/quota windows straight from each
agent CLI's own credentials on disk — Claude, Codex, Cursor, Grok, and Copilot
(via gh's token). Providers you are not signed into simply say so. A script
that waits for room in a window reads work usage --json, whose shape (and
what each field means when a reading is stale) work help usage spells out.
Running more than one work (a laptop and a desk box, say)? Settings →
Appearance gives each a name and an accent colour: the tab title, home-screen
icon, notifications, and the app's chrome all take them, so two windows or
two installed apps stop looking identical.
PR chips refresh by polling GitHub (faster for a few minutes after a
local push via repo-pushed). No inbound GitHub webhooks are required.
Layout on disk
~/work/<slug>-<id>/— a workspace: one worktree per repo + generatedAGENTS.md(andCLAUDE.mdsymlink) encoding the conventions~/work/.cache/github.com/<owner>/<repo>.git— shared bare-repo cache~/.local/share/work/work.db— sqlite database (authoritative state)
Terminals live in a dedicated tmux server (tmux -L work), kept alive by a
transient systemd unit so app restarts never kill your sessions.