loom Docs Remote access
Remote access
Reach your fleet from another device — without opening it up.
By default the Loom daemon binds loopback only
(127.0.0.1:4317): nothing outside your machine can reach
it. That's the right default, but sometimes you want to open the
cockpit from a laptop, a phone, or another box. There are three ways to
do that, and only the first gives you the full cockpit.
SSH port-forward
The daemon stays loopback-only. SSH authenticates and encrypts the transport, so Loom opens no new listening surface of its own — nothing to mint, nothing to expose. One token step per device and you have the full cockpit.
Direct authenticated bind
Loom itself binds a non-loopback interface, gated by a gateway token and — off a tailnet — mandatory TLS. It serves an authenticated API, not the cockpit: the web app's own routes stay loopback-only.
Reverse proxy (Tailscale Serve)
A proxy on the same host forwards to a third, loopback-only listener. A read-and-steer cockpit behind a gateway token — off by default, and not yet tried against a live Tailscale node.
Recommended
An SSH port-forward.
This is the path we recommend, and today it's the one that works end
to end. You leave Loom's remoteAccess config untouched
(still disabled, still loopback), and put the authentication in front
of it. Forward a local port over SSH and browse it as if it were
local — SSH authenticates and encrypts the whole hop, the request
arrives on the loopback interface Loom already trusts, and you add
reach without adding an exposed port.
# from your laptop: forward local 4317 → the daemon host's loopback
$ ssh -N -L 4317:127.0.0.1:4317 you@daemon-host
# then open the cockpit locally
› http://127.0.0.1:4317/
One extra step on each new device
Loom's loopback API requires a local access credential
on every write and on the terminal socket, even over a tunnel. On the
host, loom open appends it for you; a tunnelled browser
has never seen it — so you get a writes-locked state
rather than a broken one. The cockpit loads and reads normally, and
the moment the daemon turns down a write or a socket it switches to
that state: a banner saying writes are locked, with a field to paste
the credential into. A terminal pane also prints its own reason line
in place of staying blank. Paste the credential and writes work
again — nothing is replayed, so redo whatever was refused; terminals
re-attach by themselves. Paste a wrong or stale one and the banner
tells you that credential was refused, and keeps the field so you can
try the right one.
The credential is the contents of
gateway-loopback.key under LOOM_HOME
(~/.loom/gateway-loopback.key by default), read off the
host — the machine running Loom, not the one you're
sitting at. Paste it once per browser, or supply it once in the URL
instead:
http://127.0.0.1:4317/?token=<credential>
To have Loom print that URL, run this on the host
(loom start and loom status deliberately
never print it):
loom open --print-url --port 4317
--port (and optional --host) are the address
you'll browse at on the far device — your tunnel's local end.
--host accepts only 127.0.0.1 (the default)
or localhost, the two names the daemon serves the cockpit
on. They are different browser origins, each with its own token, so
stick to one. The URL
goes to stdout and a live-credential warning to stderr; it's read from
the same key file, so it adds no network surface. Don't paste it into
chat or tickets.
Either way the browser keeps it and strips it from the address bar. It's stored per browser origin, so each URL you reach the cockpit by needs it once of its own. Treat it like a password: anything holding it can drive the full loopback API.
Why this is the default recommendation
With an SSH forward, the daemon's listening surface never changes — it
stays bound to 127.0.0.1. There's no gateway token to
mint, no TLS material to manage inside Loom, and no interface exposed
for anyone to probe. The transport's own identity layer (your SSH
keys) does the authenticating.
Alternative · unverified on a live Tailscale node
Behind a reverse proxy (Tailscale Serve).
tailscale serve — or another same-host reverse proxy —
in front of the loopback daemon works through a
trusted-proxy listener: a third port, bound to
127.0.0.1 only, that the proxy forwards to. It is
off by default, it gives a
read-and-steer cockpit rather than the full local
one, and it has not been run against a live Tailscale
node (see the last paragraph). Every request on
that port is remote-class, whatever its Host:
it needs a gateway token, reaches only the Tier-1 surface (reads,
answering and steering, view-only terminals) and can never
change configuration, reach a human-only writer,
/internal/* or the local access credential. (Tier 1
does include reads such as profiles and the companion's
configuration.) The cockpit's static files are served without a
token — a browser must load the app before it can present one —
and everything else needs it.
Host must be one of trustedProxyOrigins,
and any Origin must be that same entry — exact scheme,
host and port, no wildcards, https unless it is a
.ts.net name. The proxy must preserve the
original Host (nginx:
proxy_set_header Host $host;) and, for the live panes,
pass WebSocket upgrades; otherwise the Host no longer
matches and every request is refused 403.
Two things never to do
Never point a proxy at the daemon's own port (4317 by
default). On that port the class still follows the peer
address: a request carrying
X-Forwarded-*, Forwarded, Via,
X-Real-IP or Tailscale-* headers is treated as remote
whether or not proxy mode is configured. With no remote access
configured, or on a route only local callers may use, it gets a
403 with code: "proxy-shaped-header" naming
the header; with remote access configured it is simply a remote
caller and needs the gateway token. But a proxy
that rewrites Host to 127.0.0.1:4317 and
adds none of them (nginx's default proxy_pass does
exactly this) is indistinguishable from a local client and is
served as loopback — the full local read surface,
with no token. Point the proxy at proxyPort only.
If you see a 403 with code: "proxy-shaped-header"
(or, in every case, the daemon log's proxy-shaped header
line), a proxy added one of those headers to a request aimed at
the daemon's own port: point a reverse proxy at proxyPort,
or exclude 127.0.0.1 and localhost via
NO_PROXY if a local HTTP proxy is relaying a local client.
Sessions Loom itself spawns already exclude 127.0.0.1,
localhost and ::1 from HTTP proxies
automatically, so that advice applies only to your own tools and
browser outside Loom.
Never use Tailscale Funnel. A request carrying
Tailscale-Funnel-Request is refused on every route of
the proxy listener, but a Funnel would still put this listener on
the public internet.
# 1. a gateway token (shown once) — the same call as the direct bind below
$ curl -X POST http://127.0.0.1:4317/api/gateway-tokens … -d '{"name":"my-phone"}'
# 2. proxy mode: the port Serve targets + the exact origin your browser uses
# this PATCH merges into any existing remoteAccess block, so a direct bind's fields are kept.
# Note: a trusted origin's host must NOT be a direct-bind bindHost/allowedHosts entry, or the proxy listener is refused.
$ curl -X PATCH http://127.0.0.1:4317/api/platform/config … -d '{"remoteAccess":{"enabled":true,"proxyPort":4319,"trustedProxyOrigins":["https://your-machine.your-tailnet.ts.net:8443"]}}'
# 3. restart (config is read at boot) — detached, so the next step can run
# (a `loom service` install restarts via its service manager; from source, re-run your daemon command)
$ loom restart --detach
# 4. point Serve at the proxy port (--https needs HTTPS certificates enabled on your tailnet)
$ tailscale serve --bg --https=8443 http://127.0.0.1:4319
Open https://your-machine.your-tailnet.ts.net:8443/?gwtoken=<token>
once: the browser checks the token with the daemon, stores it for that
origin only if it is accepted (a refused link never overwrites a
token you already hold), and strips it from the address bar (or paste
it into the banner). The daemon still boots without a gateway
token but does not open the proxy port (it logs a warning), so mint the
first token before the restart. proxyPort has no default. Use a port dedicated to Loom:
any other page on the same origin can read the stored token.
Serve's --https needs HTTPS certificates enabled on your
tailnet (Tailscale's docs).
Unverified on a live Tailscale node. This was built
from Tailscale's source (serve.go preserves
Host, leaves Origin alone, and adds
X-Forwarded-* and Tailscale-* headers) and
tested against a minimal in-test Node reverse proxy over plain HTTP
— https through a proxy, nginx and Caddy were not tested — not a live
tailnet. Whether Serve relays WebSocket upgrades unchanged on your
version is the first thing to check (live panes stay empty if not),
and the tailscale serve flags vary by version. On the
proxy listener the trust decision never depends on what Serve does
with Host.
Alternative
Direct authenticated bind.
When an SSH forward isn't an option — say you want the daemon to answer directly on a LAN interface, or on a host that's already reachable — Loom can bind a non-loopback interface itself. This opens a real listening socket, so Loom gates it hard: a gateway token is required on every remote request, and off a tailnet, valid TLS is mandatory or the daemon refuses to open the interface at all.
Know what you're getting, though. What this serves remotely is an authenticated API, not the cockpit: the route allowlist covers reads and the answer/steer actions, and the web app's own static routes aren't on it, so a remote browser is refused the page itself. Reach for this when something on the other end speaks HTTP — a script, an integration, your own client. Reach for the SSH forward when you want to look at Loom.
Three things to know before you turn it on
The remote bind is a second listener on its own port —
loopback never changes. The daemon keeps answering on
http://127.0.0.1:4317, plain HTTP, whatever you
configure, so loom status, loom stop,
loom open, every agent's tool connection and the
http://127.0.0.1:4317 admin calls on this page keep
working. The remote listener is separate: it binds
remoteAccess.bindHost on remoteAccess.port
(default: the daemon port + 1, so 4318), terminates TLS
when remoteAccess.tls is set, and must use a different
port from the daemon's. Remote clients dial
<scheme>://<host>:<remote port>, where
<host> is the bindHost (for a wildcard
bind, any allowedHosts entry) and the scheme is
https whenever remoteAccess.tls is
configured — plain http only for a .ts.net
tailnet bind with no TLS configured. If the
remote listener can't open — no gateway token yet, TLS material that
won't load, a wildcard bind without allowedHosts, a port
clash, or a bind failure — the daemon logs exactly why and carries on
loopback-only.
A wildcard bind needs allowedHosts.
bindHost: "0.0.0.0" (or ::) listens on every
interface, but a real client's Host is the address or
name it dialled — never the literal 0.0.0.0 — and the
DNS-rebinding guard matches Host exactly. List every
hostname or IP your clients will use in
remoteAccess.allowedHosts; a wildcard bind with an empty
list does not open at all. It also works alongside
a specific bindHost (a tailnet client may reach both the
MagicDNS name and the 100.x address). Matching is exact
and case-insensitive — no wildcards or suffixes — and
0.0.0.0, :: and loopback addresses are
rejected as entries.
A browser's Origin must be the full remote
origin — <scheme>://<allowed host>:<remote port>,
the same scheme and port the client dialled —
and only a request that actually arrives from a non-loopback address
may present one. A loopback caller keeps the loopback-only rule.
-
Mint a gateway token
The gateway token is the daemon-global credential that authorizes a remote request. Mint one over the loopback API. The
plaintextsecret is returned once — store it now; it's hashed at rest and never recoverable again.Note the
Authorizationheader: this is a write, and every UI write on the loopback API needs the local access credential — the contents of~/.loom/gateway-loopback.keyunderLOOM_HOME. That's a different secret from the gateway token you're minting here, and it's required on every mutating call on this page. Without it the call comes back401, with a body pointing you atloom openfor how to obtain the credential.$ curl -X POST http://127.0.0.1:4317/api/gateway-tokens \ -H "Authorization: Bearer $(cat ~/.loom/gateway-loopback.key)" \ -H 'content-type: application/json' -d '{"name":"my-laptop"}'# → { "token": { "id": "…", "name": "my-laptop", … }, "plaintext": "<the secret — shown once>" }
-
Point Loom at your TLS cert and key
Off a tailnet, a non-loopback bind must be HTTPS. Provide a certificate and private key — from your own CA, an internal PKI, or a tool like mkcert for a LAN. You'll pass their paths as
remoteAccess.tls.certPathandkeyPathin the next step. -
Enable the remote bind
Set the daemon-global
remoteAccessconfig over the loopback API: turn it on, choose the interface to bind, supply the TLS paths, and (for a wildcard bind) list the hostnames clients will use.remoteAccessis daemon-global (not per-project) and human-only, so this is a one-time setting.$ curl -X PATCH http://127.0.0.1:4317/api/platform/config \ -H "Authorization: Bearer $(cat ~/.loom/gateway-loopback.key)" \ -H 'content-type: application/json' \ -d '{"config":{"remoteAccess":{ "enabled": true, "bindHost": "0.0.0.0", "allowedHosts": ["192.168.1.10"], "port": 4318, "tls": { "certPath": "/etc/loom/cert.pem", "keyPath": "/etc/loom/key.pem" } }}}'# a specific bindHost also works (allowedHosts is then optional); port defaults to the daemon port + 1
-
Restart the daemon
remoteAccessis read once at boot, so the change takes effect on the next restart. On boot Loom runs a fail-closed check: it only opens the remote interface when it's enabled, thebindHostis non-loopback, a gateway token exists, the TLS mandate is satisfied, and (for a wildcard bind)allowedHostsis set. If any of those is missing it logs why and stays loopback-only — it never opens a public interface as plain HTTP.loom restartworks as usual: it waits for the daemon onhttp://127.0.0.1:4317, which stays plain HTTP whatever you bound. The daemon's own log names the remote listener's address and port once it is open.$ loom restart
Using the token
Present the token from your client.
Once the remote interface is live, every remote request carries the
gateway token. Standard REST calls send it as a bearer token; the
live terminal WebSockets — which can't set an
Authorization header on the upgrade — accept it as a
WebSocket subprotocol or a ?token= query parameter.
Dial <scheme>://<host>:<remote port> (https
whenever you configured TLS) using a
host you configured — the bindHost itself (unless it's a
wildcard) or an allowedHosts entry. The DNS-rebinding
guard matches the Host header against that list
exactly (ignoring letter case, with no wildcards or suffixes), so an alias, a different DNS name, or a bare
IP that isn't listed is refused with 403.
# REST: Authorization: Bearer <plaintext>
$ curl https://192.168.1.10:4318/api/projects \
-H 'Authorization: Bearer <plaintext>'
# WebSocket terminals: token via the Sec-WebSocket-Protocol subprotocol, or ?token=<plaintext>
What a remote token can reach
A remote request is deliberately limited to a read-and-steer surface: the read-only views, the answer/steer actions on an already-running session (answer a question, send input, stop, resume, end), and the live session sockets — the terminal stream (view-only to a remote peer: nothing but a repaint goes down it, steering goes through the governed REST input route, and a host shell is never reachable remotely, on that socket or through the REST input and stop routes), the companion chat stream, and a fleet-status feed. Everything else — the web app's own routes, vault and git writers, the internal control endpoints, the MCP mounts, and even the gateway-token admin itself — stays loopback-only by construction. A valid remote token can never mint, rotate, or revoke tokens over the wire; only the human at the local machine can. Remote requests are also rate-limited per IP and per token; repeated wrong-token attempts from one IP are throttled, never hard-locked, so a valid token is never blocked by someone else's failures on a shared IP; loopback traffic is exempt.
The companion chat stream is the one deliberate exception to "view-only": a remote token may send chat and audio on it, because that arrives through the companion's own governed inbound path rather than as a raw write to a terminal. Whether it should stay that way is an open question we haven't settled yet.
TLS off the tailnet
TLS is mandatory for a non-tailnet bind.
A direct bind to any non-loopback, non-tailnet interface carries
traffic — including the gateway token — over an untrusted network, so
Loom requires it to be encrypted. If remoteAccess.tls is
missing, or the cert/key can't actually be read and loaded as valid
TLS material, the boot-time check fails closed: the daemon
refuses to open the remote interface and falls back to
loopback, logging exactly why. It will never bind a public
interface believing it's HTTPS when it's really plain HTTP.
A Tailscale .ts.net address is the one exception: the
tailnet already encrypts the link end to end, so a bind whose
bindHost is a .ts.net name skips the TLS
requirement. This is the direct-bind-to-a-tailnet-address
case — a real listener, on that address, matching that
Host. It is not the same thing as
tailscale serve proxying to a loopback daemon, which
needs no bind at all and goes through the trusted-proxy listener; see the reverse-proxy section above.
Managing tokens
Rotate, pause, revoke.
All token management is loopback-only and human-only. List shows metadata only — never the secret. Every call that changes something carries the local access credential as a bearer token, the same as the mint call above; the plain list read is a GET and doesn't need it.
# list tokens (metadata only — no secret)
$ curl http://127.0.0.1:4317/api/gateway-tokens
# rotate a token's secret — old plaintext dies, new one returned once
$ curl -X POST http://127.0.0.1:4317/api/gateway-tokens/<id>/rotate \
-H "Authorization: Bearer $(cat ~/.loom/gateway-loopback.key)"
# pause or revoke (soft, reversible → active|paused|revoked)
$ curl -X POST http://127.0.0.1:4317/api/gateway-tokens/<id> \
-H "Authorization: Bearer $(cat ~/.loom/gateway-loopback.key)" \
-H 'content-type: application/json' -d '{"status":"revoked"}'
# delete permanently
$ curl -X DELETE http://127.0.0.1:4317/api/gateway-tokens/<id> \
-H "Authorization: Bearer $(cat ~/.loom/gateway-loopback.key)"
Back to the overview.
Remote access is optional — Loom runs perfectly as a local-only instrument. When you do open it up, keep it on the transport you trust.