loom Docs 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.

Recommended

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.

Alternative

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.

Alternative · unverified

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.

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.

ssh · port-forward

# 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.

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.

trusted proxy · setup

# 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.

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.

  1. Mint a gateway token

    The gateway token is the daemon-global credential that authorizes a remote request. Mint one over the loopback API. The plaintext secret is returned once — store it now; it's hashed at rest and never recoverable again.

    Note the Authorization header: this is a write, and every UI write on the loopback API needs the local access credential — the contents of ~/.loom/gateway-loopback.key under LOOM_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 back 401, with a body pointing you at loom open for how to obtain the credential.

    POST /api/gateway-tokens
    $ 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>" }

  2. 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.certPath and keyPath in the next step.

  3. Enable the remote bind

    Set the daemon-global remoteAccess config 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. remoteAccess is daemon-global (not per-project) and human-only, so this is a one-time setting.

    PATCH /api/platform/config
    $ 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

  4. Restart the daemon

    remoteAccess is 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, the bindHost is non-loopback, a gateway token exists, the TLS mandate is satisfied, and (for a wildcard bind) allowedHosts is set. If any of those is missing it logs why and stays loopback-only — it never opens a public interface as plain HTTP.

    loom restart works as usual: it waits for the daemon on http://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
    $ loom restart

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.

authenticated remote request

# 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 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.

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.

gateway-token admin (loopback only)

# 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.

Back to home