A phone web UI for your Herdr agent herd, served over Tailscale. Open a URL, see which agent needs you, and reply with your phone's keyboard. The reply box is a plain text field, so your phone's own voice dictation (Android & iOS) works in it for free — Collie doesn't ship any voice support of its own. Each agent gets a colored terminal mirror, a slash-command palette, and a special-keys pad.
A Herdr plugin (thin launcher) plus a Bun/TypeScript bridge running as a systemd --user service,
serving a Vite + React + shadcn PWA.
I wanted to check on my agents from my phone. The usual route is Termux — SSH
in, attach to the terminal — but driving a TUI through its on-screen controls is miserable: the
special keys are fiddly, Ctrl/Esc/arrows are buried behind chords, and every reply is a fight
with the keyboard. I wanted something that feels like an app, not a terminal squeezed onto a
touchscreen: tap the agent that needs you, type with your real keyboard, fire Esc or Ctrl+C with
one thumb. Collie is that.
You, if you run Herdr agents on a machine and want to resume a session from
your phone — read what an agent is asking, type a reply, fire a special key — without SSHing in and
wrestling a TUI. It assumes a Tailscale tailnet (mesh) setup: your
phone and the host are on the same tailnet, and tailscale serve is the only way in. It's
deliberately single-user: one operator, one tailnet, no multi-tenant auth. If that's your setup,
Collie fits. If you need shared or public access, it isn't built for that — and see the security
note below before you run it.
Collie is remote shell access to your machine, by design. One bridge call types arbitrary keystrokes into a live terminal pane, so anyone who can reach the URL can read every pane (source, secrets, env, agent output) and run any command as your user. No sandbox, no command allow-list (that would defeat the purpose). Treat the URL like a root login.
Two sharp edges:
- It acts as you, with your full privileges —
~/.ssh,git push --force,rm -rf,sudo. - Access is device-level, not person-level. Tailscale proves the device, not who's holding it.
No password, no session — an unlocked or stolen phone (or anyone else on your tailnet) is an open
shell. The idle-lock is UX, not auth. Every write action (replies, keys, uploads, pane/tab
create/close) is appended to
<state-dir>/audit.log, so there is at least a trail — but a trail is not a gate.
It's built single-user and tailnet-only. The defenses:
- Loopback bind only (
127.0.0.1) — never0.0.0.0. tailscale serveis the sole ingress — terminates TLS, injects the identity header.- Optional identity gate — set
COLLIE_TRUSTED_USERto reject anyone but you. - Optional per-device gate — behind a proxy that injects a device-identity header, set
COLLIE_DEVICE_HEADER+COLLIE_DEVICE_ALLOWLISTso only allowlisted devices can drive agents; any other device is read-only. Off by default; revoke a device by dropping it from the list. See Deployment variants for the proxy this requires. - Same-origin gate + strict CSP; pane output renders as React text nodes, never
innerHTML. - Optional Host allowlist — set
COLLIE_PUBLIC_HOSTSto the exact host(s) you serve on (e.g. your MagicDNS name) and the bridge rejects any request addressed to another Host before the origin logic runs. Strongly recommended, and effectively mandatory withCOLLIE_SERVE_MODE=http— without TLS, DNS rebinding can otherwise make a hostile page same-origin with the bridge.
🚫 Never
tailscale funnelthis — funnel exposes it to the public internet;servekeeps it tailnet-only. There is no scenario where funneling Collie is correct.
Narrow the blast radius with Tailscale ACLs and COLLIE_TRUSTED_USER. Provided as-is, no warranty.
On the host (the tailnet node your agents run on):
| Tool | Why |
|---|---|
| Bun | Runs the bridge and builds the web UI — the only hard dependency. |
| Herdr ≥ 0.7.0 | The herd Collie mirrors; its CLI registers the plugin. |
| Tailscale | Sole ingress (tailscale serve); without it, the bridge is 127.0.0.1-only. |
| git | Clone, and the update command. |
Soft dependencies: Node.js (the control script uses it to extract your MagicDNS name from
tailscale status --json; without it the banner falls back to the loopback URL) and systemd --user (supervises the service; falls back to a nohup process without it). You never install JS
deps by hand — the build runs bun install for you; the backend imports only Bun + node:*.
web-push is optional and lazy (see Web
Push).
On the host, not your phone. Two ways in.
From GitHub (turnkey) — Herdr clones and builds for you:
herdr plugin install AltanS/collie
herdr plugin action invoke start --plugin herdr.collieFrom a local clone (for development) — registered by path:
git clone https://github.com/AltanS/collie.git && cd collie
herdr plugin link "$(pwd)"
herdr plugin action invoke start --plugin herdr.collieThey differ only in when the UI builds: a GitHub install builds at install time (the manifest's
[[build]] step); a linked clone builds on first start. Either way, start does four things:
- builds
web/distif it's missing (typechecked, staged, swapped in atomically), - starts the bridge as the
systemd --userservicecollie(nohupfallback without systemd), - publishes it on the tailnet — literally
tailscale serve --bg 8787: HTTPS on the host's MagicDNS name,:443 → 127.0.0.1:8787, tailnet-only, - prints the banner with the URL to open — walked through line by line in First run.
No Herdr? Run
scripts/collie-ctl.sh startdirectly — same effect (config then lives in~/.config/collie/.env).
The transcripts below are the control script's inline output. Through invoke start you get
Herdr's JSON envelope instead — the same text is the action's captured stdout, read with
herdr plugin log list --plugin herdr.collie.
$ scripts/collie-ctl.sh start
building web UI (first run)… # linked clone only; a GitHub install already built
…bun install · typecheck · vite build output…
bridge started (systemd --user: collie)
tailscale serve (https) → tailnet :443 -> 127.0.0.1:8787
✓ Collie is running · v0.9.0+debcff9
service systemd --user (collie) · active
local http://127.0.0.1:8787
tailnet https://myhost.tail1234.ts.netThe ✓ is a real probe — the script connected to the bridge's port and got an answer, not just
"the unit is active". If you get ⚠ Collie isn't answering on :8787 yet instead, see
Troubleshooting.
start left three durable things on the host:
web/dist— the built UI. The bridge serves it from disk at request time, so later UI rebuilds go live without a restart.- A
systemd --userservice namedcollie— unit file written to~/.config/systemd/user/collie.service, enabled and started, auto-restarting on failure. Inspect it withsystemctl --user status collie. (No usable systemd? Anohupprocess with a pidfile in the config dir instead.) - A tailnet-only
tailscale servemapping — the script rantailscale serve --bg 8787: HTTPS on the host's MagicDNS name,:443 → 127.0.0.1:8787. Tailscale terminates TLS (managed cert, nothing to obtain or renew) and injects the identity header the bridge checks. Inspect withtailscale serve status; remove just this mapping withscripts/collie-ctl.sh unserve.
stop merely pauses the service; uninstall reverses 2 + 3 and keeps your .env and the checkout.
The URL is the banner's tailnet line (print it again anytime with scripts/collie-ctl.sh url).
It resolves for any device on your tailnet — so the phone needs the Tailscale app installed and
connected to the same tailnet as the host.
Then install it as an app: iOS — Safari → share sheet → Add to Home Screen. Android —
Chrome → ⋮ menu → Add to Home screen (or Install app). Installing (and Web Push) needs the
HTTPS origin the default serve mode already provides; over COLLIE_SERVE_MODE=http the page works,
but service worker and install silently no-op.
A sixty-second check, host side then phone side:
$ scripts/collie-ctl.sh status
✓ Collie is running · v0.9.0+debcff9
service systemd --user (collie) · active
local http://127.0.0.1:8787
tailnet https://myhost.tail1234.ts.net
serve config:
https://myhost.tail1234.ts.net (tailnet only)
|-- / proxy http://127.0.0.1:8787$ scripts/collie-ctl.sh logs # journal timestamps trimmed here
[push] disabled (no VAPID keys configured)
[bridge] listening on http://127.0.0.1:8787 (poll 1500ms)
[bridge] WARNING: COLLIE_TRUSTED_USER is empty — any tailnet device/user that reaches the bridge gets full write access. Set it to your tailnet login (see README → Variant A).
[bridge] WARNING: COLLIE_PUBLIC_HOSTS is empty — Host-header validation is OFF (DNS rebinding not blocked). Set it to your MagicDNS name, especially under COLLIE_SERVE_MODE=http.Both WARNINGs are expected on a fresh install — that's the bridge telling you it's running
open-by-default on your tailnet. Configure closes both. (The loopback URL in the log
is also correct: the bridge itself only ever binds 127.0.0.1 — tailscale serve is what makes it
reachable.)
On the phone: your agents are listed, and the footer build stamp (v0.9.0 · debcff9 · …) matches
scripts/collie-ctl.sh version. If the page loads but stays empty, that's the same-origin gate —
see Troubleshooting.
A systemd --user service only runs while you have a login session. On a host that should serve
Collie unattended, enable lingering once:
loginctl enable-linger $USERThe unit is enabled, so with lingering it starts at boot with your user manager; the
tailscale serve mapping is persistent (--bg) and comes back on its own.
Out of the box Collie runs open single-user: anyone on your tailnet who can reach the URL has full control — that's exactly what the two startup WARNINGs are about. Close both in one sitting:
# in your .env
COLLIE_TRUSTED_USER=you@example.com # your tailnet login — the bridge rejects anyone else
COLLIE_PUBLIC_HOSTS=myhost.tail1234.ts.net # exact host(s) you serve on — blocks DNS rebindingConfig is a .env in the plugin's config dir — find it with
herdr plugin config-dir herdr.collie (typically ~/.config/herdr/plugins/config/herdr.collie;
without Herdr, ~/.config/collie). collie-ctl.sh resolves this same dir whether you run it
directly or via a Herdr action:
cp .env.example "$(herdr plugin config-dir herdr.collie)/.env"The bridge reads .env only at startup — after any edit, scripts/collie-ctl.sh restart. See
.env.example for the full option list — commonly COLLIE_PORT, or
COLLIE_SERVE_MODE=http (Headscale / .internal domains; read by the control script when it runs
tailscale serve).
Custom domain or reverse proxy? Collie is same-origin only. A plain tailscale serve on your
MagicDNS name works as-is, but a different hostname or TLS terminator makes API calls fail with 403 cross-origin (page loads, stays empty). Allow the exact origin:
COLLIE_ALLOWED_ORIGINS=https://collie.example.comEvery command works two ways: the control script on the host (scripts/collie-ctl.sh <cmd>) or
the equivalent Herdr action (herdr plugin action invoke <cmd> --plugin herdr.collie, written
below as invoke <cmd>). The ones you'll actually use:
| Action | Control script | Herdr action |
|---|---|---|
| Start — build if needed, serve, print the URL | collie-ctl.sh start |
invoke start |
| Stop — pause the bridge; removes nothing | collie-ctl.sh stop |
invoke stop |
| Restart | collie-ctl.sh restart |
invoke restart |
| Status — the Collie is running banner + URLs | collie-ctl.sh status |
invoke status |
| URL — print the tailnet URL | collie-ctl.sh url |
invoke url |
Version — the running version (0.x.y+sha) |
collie-ctl.sh version |
invoke version |
Update — git pull + rebuild + restart |
collie-ctl.sh update |
invoke update |
Uninstall — remove the service; keep .env + checkout |
collie-ctl.sh uninstall |
invoke uninstall |
| Logs — tail the journal / log file | collie-ctl.sh logs |
— (script only) |
start and status end with the Collie is running banner — annotated line by line in
First run. Its version comes from the served bundle stamp, so it's
the authoritative "what's running" — note herdr plugin list --json shows a different value cached
at plugin link time; update re-links automatically so that self-heals (to force it:
herdr plugin link "$(pwd)"). Through a Herdr action you get Herdr's JSON envelope, not the
banner — the human-readable output is the action's captured stdout, read with
herdr plugin log list --plugin herdr.collie (or run the control script directly to see it inline).
build · serve · unserve are script-only too.
Collie registers these actions in herdr-plugin.toml; invoke any with
herdr plugin action invoke <id> --plugin herdr.collie (list them live with
herdr plugin action list --plugin herdr.collie):
<id> |
Title | What it does |
|---|---|---|
start |
Start web bridge | Build if needed, start the service, tailscale serve, print URL + banner |
stop |
Stop web bridge | Pause the bridge; removes nothing |
restart |
Restart web bridge | stop + start |
status |
Bridge status | The Collie is running banner — readiness ✓/⚠, version, URLs |
url |
Show bridge URL | Print the tailnet URL |
version |
Show version | Print the running version (0.x.y+sha) |
update |
Update plugin | git pull --ff-only + rebuild + restart |
uninstall |
Uninstall web bridge (remove service) | Tear down the service (keeps .env + checkout) |
Stop or uninstall. Pause the bridge without removing anything (a later start brings it right
back):
scripts/collie-ctl.sh stop # or: herdr plugin action invoke stop --plugin herdr.collieTo tear the service down completely — stop + disable it, remove the systemd --user unit, and remove
Collie's own tailscale serve mapping (port-scoped, so other tailnet mappings on the host survive) —
use uninstall. It leaves your .env and the checkout untouched:
scripts/collie-ctl.sh uninstall # or: herdr plugin action invoke uninstall --plugin herdr.collieThen herdr plugin uninstall herdr.collie (or, for a linked clone, just deleting the directory)
removes the plugin registration itself.
Update to a new release. Collie is link-mode — the checkout is the plugin, and there's no
herdr plugin update. One command does the lot:
scripts/collie-ctl.sh update # or: herdr plugin action invoke update --plugin herdr.collieIt git pull --ff-onlys, rebuilds the UI, restarts the bridge (re-execing itself, so it's safe even
when the pull rewrites the script), and re-links the plugin so Herdr picks up any new actions and
the new version (older releases skipped this, which is why a freshly added action could return
plugin_action_not_found until a manual re-link). Confirm via the footer build stamp.
By hand: frontend (web/) → collie-ctl.sh build (live, no restart — served from disk); backend
(bridge/) → systemctl --user restart collie. Run scripts/install-hooks.sh once to enable the
repo's pre-commit / pre-push checks.
The bridge always binds loopback only; what changes between deployments is what sits in front of it and how a request proves who it is. There are two supported shapes — they gate access at different levels (person vs device). Pick one.
The happy path from Install. tailscale serve terminates TLS on your MagicDNS name and
injects Tailscale-User-Login; set COLLIE_TRUSTED_USER to your tailnet login and the bridge
rejects anyone else.
# in your .env
COLLIE_TRUSTED_USER=you@example.com- Granularity: the tailnet person, not the device.
- Why it's safe on bare
tailscale serve: serve is the trusted injector ofTailscale-User-Login— it sets that header itself and a client can't forge it through the proxy. - Nothing else to configure; origins match automatically on the MagicDNS name.
This is the right choice unless you specifically need per-device control.
Use this when some devices should drive agents and others should be read-only — e.g. your
phone can reply, but a shared/less-trusted device can only watch. Collie reads an opaque device id
from a request header (COLLIE_DEVICE_HEADER) and checks it against COLLIE_DEVICE_ALLOWLIST:
allow-listed → full access, any other id → read-only, header absent → treated as the on-host
operator (full access).
That last rule is the catch: device-auth only works behind a reverse proxy that authenticates the device and injects the header. It is not a standalone flag.
⚠️ Do not enableCOLLIE_DEVICE_HEADERon plaintailscale serve. An absent header means full access, andtailscale serveinjects only its ownTailscale-*headers — it forwards an arbitraryX-Device-Iduntouched. So a remote request with no header gets full access (the gate is a no-op), and a client that setsX-Device-Id: my-phoneitself is trusted (spoofable). Sound only behind a proxy that does both things below.
Your fronting proxy must:
- Authenticate the device by some means it controls — mTLS client certs, an SSO/forward-auth layer (oauth2-proxy, Pomerium, Cloudflare Access), Tailscale node identity, etc. How you derive a stable per-device id is up to you; Collie treats it as opaque.
- Set (override) the device header on every upstream request — never merely add it, so any client-supplied copy is discarded. This override is what makes the header trustworthy.
- Proxy to the bridge on loopback (
127.0.0.1:$COLLIE_PORT). The loopback bind is the trust anchor — nothing but the proxy can reach the bridge to set the header. - Satisfy the same-origin gate. Collie accepts a request when the browser's
Originhost equals theHostthe bridge receives. So either forward the publicHostunchanged, or — if your proxy rewrites Host — list the exact public origin inCOLLIE_ALLOWED_ORIGINS. Otherwise every API call 403scross-origin rejected(the page loads but stays empty).
Collie side (.env):
COLLIE_HOST=127.0.0.1 # keep loopback (default)
COLLIE_DEVICE_HEADER=X-Device-Id # the header your proxy injects
COLLIE_DEVICE_ALLOWLIST=my-phone,my-laptop # ids allowed to drive agents; others → read-only
# COLLIE_ALLOWED_ORIGINS=https://collie.example.com # only if the proxy does NOT forward the public Host
# COLLIE_TRUSTED_USER still composes on top if your ingress also injects Tailscale-User-LoginIllustrative nginx — the auth layer is yours; the load-bearing lines are the override and the
loopback proxy_pass:
location / {
# $device_id comes from your auth (client-cert CN, auth_request, SSO header, …).
# SETTING it replaces any client-supplied X-Device-Id — that's what kills spoofing.
proxy_set_header X-Device-Id $device_id;
proxy_set_header Host $host; # forward the public Host → same-origin gate passes
proxy_pass http://127.0.0.1:8787;
}Revoke a device by dropping its id from COLLIE_DEVICE_ALLOWLIST and
systemctl --user restart collie. With the header set but the allowlist empty, every device is
read-only (fail-closed).
Off unless you opt in:
bun add web-push
bunx web-push generate-vapid-keys
# set COLLIE_VAPID_PUBLIC / _PRIVATE / _SUBJECT in your .env, then restartPush needs HTTPS — the default tailscale serve already provides it (Tailscale manages the MagicDNS
cert; nothing to obtain or renew). COLLIE_SERVE_MODE=http is not a secure context, so push
silently won't fire there — Settings flags it insecure.
Collie pushes when an agent goes blocked or done, with the agent's message in the body; tapping it opens Collie at that agent. Test it without waiting for an agent to block:
bash scripts/collie-ctl.sh push-test # or: push-test "Title" "Body"herdr plugin … fails with Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }. This is not a Collie problem — it means the Herdr server isn't running, so its
CLI can't reach the control socket (~/.config/herdr/herdr.sock). The tell is the raw Os {…}
error: a reachable server answers path/manifest problems with structured JSON (e.g.
plugin_manifest_not_found), so a bare Os { NotFound } is a failed socket connect, before Collie
or your path is ever examined. It hits link, install, action invoke — every subcommand that
talks to the server — while herdr plugin --help still works (it never opens the socket). Fix: start
Herdr first (herdr server &, or just launch the Herdr TUI — it boots the server), confirm
ls ~/.config/herdr/herdr.sock now exists, then retry the install. herdr plugin list is a quick
probe: if it throws the same error, the server is down.
start prints note: tailscale serve failed. The bridge itself is fine (still up on
127.0.0.1) — only the tailnet ingress didn't come up, and the script prints tailscale's own error
right below the note. Usual causes: your user isn't the Tailscale operator
(sudo tailscale set --operator=$USER), the node is logged out (tailscale up), or — on
Headscale / .internal tailnet domains — HTTPS certs aren't available, which is exactly what
COLLIE_SERVE_MODE=http is for: set it in .env, then scripts/collie-ctl.sh restart. Verify with
tailscale serve status.
Banner shows ⚠ Collie isn't answering on :8787 yet. The service was started but the HTTP
server isn't answering the probe. scripts/collie-ctl.sh logs (or journalctl --user -u collie -f
to watch live) says why — most commonly the port is already taken (set COLLIE_PORT in .env, then
scripts/collie-ctl.sh restart, which also re-runs tailscale serve against the new port) or the
first build failed (the log says so; fix and run scripts/collie-ctl.sh build). The unit
auto-restarts every 5 s, so once the cause is fixed it usually comes back on its own.
Phone can't open the tailnet URL. Work down the list: (1) the phone runs the Tailscale app and
is connected to the same tailnet as the host; (2) you're opening the banner's tailnet URL
(scripts/collie-ctl.sh url), not the local one — http://127.0.0.1:8787 only works on the host
itself; (3) MagicDNS is enabled in your tailnet's DNS settings (the URL is a MagicDNS name); (4) the
host is online — check tailscale status on the host, or ping the host from the phone's Tailscale
app.
Page loads but stays empty; API calls fail 403 cross-origin rejected. You're reaching Collie
through an origin the bridge doesn't expect — a custom domain, or a proxy that rewrites Host.
Allow the exact public origin with COLLIE_ALLOWED_ORIGINS (see Configure), or make
the proxy forward Host unchanged (Variant B, rule 4).
Collie is gone after a reboot. A systemd --user unit only runs while you have a session — on a
headless host enable lingering once (loginctl enable-linger $USER) and the collie unit (already
enabled) starts at boot with your user manager. The tailscale serve mapping persists on its own
(--bg), so lingering is usually the whole fix.
Phone shows a stale UI after a rebuild. A PWA's service-worker cache is per-origin, so reaching
Collie at two origins (a custom domain and the raw host:8787) gives you two installs, each
caching its own bundle. The footer build stamp (vX.Y.Z · sha · time) shows the bundle you're
running; the bridge reports what it serves via the X-Collie-Build header and /api/config. On a
mismatch, the footer offers "new build — tap to update." Otherwise reopen the PWA a couple times
(the SW auto-updates) or clear that origin's site data. Best practice: pick one HTTPS origin and
stick to it. (Over plain HTTP the SW can't register — always fresh, but no PWA features.)
A small Bun process sits between your phone and Herdr — the browser never touches the socket.
phone (PWA)
│ HTTPS over the tailnet
▼
tailscale serve terminates TLS, injects the identity header
│ 127.0.0.1:PORT (the bridge binds loopback only)
▼
Collie bridge (Bun) serves the UI + a small JSON API; polls Herdr
│ one-shot JSON-RPC over a Unix socket
▼
Herdr server owns the panes, agents and terminal state
- One module touches the socket (
bridge/herdr-client.ts); everything else speaks the bridge's HTTP API. - Polling is still the model — the bridge polls Herdr (via
session.snapshot, one RPC per tick) and the browser polls/api/snapshot; a long-lived Herdr event stream only pokes the bridge's poll to go faster, it never replaces it. No resync logic. - Actions are plain HTTP — a reply or key
POSTs to/api/pane/:id/{reply,keys}→ Herdrpane.send_keys, which types into a real terminal (hence the security posture). - The UI is a static PWA — Vite builds
web/dist, served from disk, so a rebuild is live with no restart.
Full design rationale in ARCHITECTURE.md.
- Design & rationale —
ARCHITECTURE.md - Verified Herdr socket API —
HERDR_API.md - Herdr background notes —
HERDR_NOTES.md - Ops, versioning & conventions —
CLAUDE.md - Changes —
CHANGELOG.md
