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 two supported ways to do that, and they trade off differently.

Recommended

Tailscale Serve / SSH

The daemon stays loopback-only. Tailscale (or an SSH tunnel) authenticates and encrypts the transport, so Loom opens no new listening surface of its own — nothing to mint, nothing to expose.

Alternative

Direct authenticated bind

Loom itself binds a non-loopback interface, gated by a gateway token and — off a tailnet — mandatory TLS. Reach for this when you can't run Tailscale or SSH on both ends.

Tailscale Serve, or an SSH tunnel.

This is the path we recommend for almost everyone. You leave Loom's remoteAccess config untouched (still disabled, still loopback), and put the authentication in front of it. Because the proxy connects to the daemon over 127.0.0.1, the request arrives on the loopback interface Loom already trusts — you add reach without adding an exposed port.

Tailscale Serve

If your machines are on a Tailscale tailnet, tailscale serve publishes an HTTPS endpoint on your node — with a certificate Tailscale provisions for you — and proxies it to the daemon on loopback. Only devices on your tailnet can reach it.

tailscale · serve

# proxy an HTTPS tailnet endpoint to the loopback daemon

$ tailscale serve --bg 4317

# then open the cockpit from any device on your tailnet

https://your-machine.your-tailnet.ts.net/

# stop serving when you're done

$ tailscale serve --bg 4317 off

SSH tunnel

No tailnet? Any SSH connection to the daemon's host works just as well. Forward a local port over SSH and browse it as if it were local — SSH authenticates and encrypts the whole hop, and the daemon still only ever sees a loopback connection.

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/

Why this is the default recommendation

With Serve or SSH, 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 tailnet, or your SSH keys) does the authenticating.

Direct authenticated bind.

When Tailscale and SSH aren't options — 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.

It's a co-equal, fully supported path — just one that puts the authentication and the certificate in your hands instead of the transport's. Prefer it over Serve/SSH only when you specifically need Loom to be the thing listening.

  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.

    POST /api/gateway-tokens
    $ curl -X POST http://127.0.0.1:4317/api/gateway-tokens \ -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, and supply the TLS paths. remoteAccess is daemon-global (not per-project), so this is a one-time setting.

    PATCH /api/platform/config
    $ curl -X PATCH http://127.0.0.1:4317/api/platform/config \ -H 'content-type: application/json' \ -d '{"config":{"remoteAccess":{ "enabled": true, "bindHost": "0.0.0.0", "tls": { "certPath": "/etc/loom/cert.pem", "keyPath": "/etc/loom/key.pem" } }}}'

    # bindHost can be a specific address (e.g. 192.168.1.10) or 0.0.0.0 for all interfaces

  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, and the TLS mandate is satisfied. If any of those is missing it logs why and stays on loopback — it never opens a public interface as plain HTTP.

    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.

authenticated remote request

# REST: Authorization: Bearer <plaintext>

$ curl https://your-host:4317/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 two read-only terminal WebSockets. Everything else — 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, with an automatic lockout after repeated auth failures; loopback traffic is exempt.

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. (Note this is the direct-bind-to-a-tailnet-address case — distinct from the recommended Tailscale Serve path above, which needs no bind at all.)

Rotate, pause, revoke.

All token management is loopback-only and human-only. List shows metadata only — never the secret.

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

# pause or revoke (soft, reversible → active|paused|revoked)

$ curl -X POST http://127.0.0.1:4317/api/gateway-tokens/<id> \ -H 'content-type: application/json' -d '{"status":"revoked"}'

# delete permanently

$ curl -X DELETE http://127.0.0.1:4317/api/gateway-tokens/<id>

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