Skip to content

Tunnelled surfaces and shipped MCP

How a service you run on its own box (a container beside a GPU, a private network) appears inside Lowkey as a plugin surface and as agent tools — with no public address, port or certificate on your side. Your service dials out to Lowkey; Lowkey never dials in.

A direct surface (surface.url) still works for a service reachable from the Lowkey host (typically http://127.0.0.1:<port>). Anything else uses the tunnel.

"scopes": ["focus", "mcp"],
"surface": {
"slot": "focus",
"transport": "tunnel", // "direct" (default) | "tunnel"
"path": "/", // optional; absolute request path, default "/"
"identity": ["email"] // optional; opt in to the member's email
},
"mcp": { "command": "node", "args": ["./mcp/server.mjs"] }
  • With transport: "tunnel" the manifest has no url; surface.path is the request path sent to your service. path and identity are rejected on a direct surface, and url is rejected on a tunnel surface.
  • identity currently accepts only "email", with unique entries.
  • Install consent shows the transport, path and any identity field to the admin.

A connector secret (lkc_…, 32 random bytes) authenticates one installation’s connector. Lowkey stores only its SHA-256, in plugins/connectors.json under its home directory (mode 0600). There are two ways to set one up:

  • Pairing (recommended). Start the connector with a --secret-file path that does not exist yet. It generates its own secret, opens /plugin-tunnel with X-Lowkey-Connector: lkpair_<sha256 hex of the secret>, and prints a code such as ABCD-EFGH. An instance operator enters the code in the plugin’s settings (Service connector → Pair connector). Lowkey binds the hash to that installation, the connector writes the secret to the file (0600, never overwriting), and connects with it. The secret never leaves the connector’s host. Codes last 10 minutes and work once. The connector then asks for a new code. The secret file’s directory must be writable; the connector checks before asking for a code. If the connector disconnects before pairing finishes, the approval fails and the previous secret stays in place. Pending codes are never listed: approving one requires reading it off the connector. A pairing socket carries no tunnel authority, and a hash already bound to another installation is refused. At most 3 codes wait per network (IPv6 /64 or IPv4 /24). Pairing, minting and revoking are logged with the operator and connector address.
  • Minted secret. An instance operator creates one in the plugin’s settings (Service connector → Create connector secret). It is shown once. Put it in the connector’s secret file. Use this when the secret file must be read-only (a Docker secret).

For a connector the agent starts on this Lowkey host, it can request a human approval card. lowkey plugin connector ticket <id> returns a ten-minute, single-use ticket and the daemon’s loopback daemonUrl. Start the connector with that URL and LOWKEY_CONNECTOR_TICKET=<ticket>; its pair message and console output report accepted, rejected, or none. From the same chat and project, run lowkey plugin connector request-approval <id> --ticket <ticket> --code <CODE> using the code from that connector’s output. The daemon requires the code and ticket to match the same pending entry. A restarted connector needs a new ticket and card. Each chat may have at most three unexpired tickets across all installations, and each ticket allows one card request. A failed open also needs a fresh ticket.

The card’s recipient is one instance operator with project access, explicitly listed in Lowkey’s instanceOperators. Without that entry, request-approval returns 403; use the typed pairing path in Settings → Plugins. Only that person’s card click can pair; typed replies and CLI answers refuse. The daemon writes all card facts, hides the code and ticket, and recommends Don’t pair. At the tap it rechecks the recipient, plugin, pending hash, source address, ticket, and expiry. The card expires with the pairing. Only a loopback socket peer with an absent or loopback X-Real-IP qualifies as this host; remote connectors, including paired devices, still need the typed Settings path. A public proxy address or shared NAT is never a machine witness. Pair/mint/revoke still refuse CLI turn tokens. Direct Settings mutations require a browser login; operator API keys are refused, and OIDC sign-ins require auth.trustedProxySecret. A dev stack without a browser session cannot use these mutations. CLI pair and revoke only point to Settings.

An agent driving a browser signed in as the person is the person to the daemon, just as with vault confirm cards and decision buttons. This remains a known platform-wide limitation. Refusing credential-authority mutations sourced from the Lowkey host was considered and rejected for now: it would break human takeover through the live view and needs a platform-wide decision.

Pairing or creating a secret replaces the old one and disconnects the live connector; revoking also disconnects it.

Run the reference connector next to your service (examples/plugin-connector/connector.mjs: one file, Node ≥ 20, no dependencies):

Terminal window
node connector.mjs \
--lowkey https://lowkey.example \
--secret-file /run/secrets/lowkey-connector \
--upstream http://studio:8000

It opens a WebSocket to /plugin-tunnel with the secret in X-Lowkey-Connector, reconnects with jittered backoff (500 ms – 30 s) and re-reads the secret file before each reconnect (rotation = replace the file). The upstream authority is fixed by --upstream; request paths cannot change it, and the connector attaches no cookies or ambient credentials. Remote --lowkey URLs must use HTTPS/WSS. Plain HTTP/WS is accepted only for loopback hosts, or with explicit --insecure (which sends the connector credential in plaintext); TLS certificate validation remains enabled.

Docker sidecar example (the service stays on a private network):

services:
studio:
image: example/studio
expose: ["8000"]
lowkey-connector:
image: node:22-alpine
command: ["node", "/app/connector.mjs",
"--lowkey", "https://lowkey.example",
"--secret-file", "/run/secrets/lowkey-connector",
"--upstream", "http://studio:8000"]
volumes: ["./connector.mjs:/app/connector.mjs:ro"]
secrets: [lowkey-connector]
restart: unless-stopped
secrets:
lowkey-connector:
file: ./lowkey-connector.secret # contains the lkc_ value only

Rules enforced by the daemon:

  • Only the installation’s own lkc_ secret opens its tunnel. The plugin token (lkp_), an MCP credential (lkm_), a wrong secret and another installation’s secret all get the same 401; failures are rate-limited per source address.
  • A new authenticated connection replaces the old one (so reconnect after a network partition works). A replacement within 10 s of the previous handshake is refused unless the old socket has missed a ping. Replacements are logged and the settings panel shows the last replacement’s time and source address.
  • Disabling the plugin or revoking the secret drops the live tunnel. There is no uninstall endpoint yet; removing package files blocks new streams and handshakes, while an existing idle socket remains until disconnect or timeout.
  • Pings go every 25 s; a connector silent for 60 s is dropped. In-flight requests fail with 502 when the connector is lost.
  • With no live connector the surface shows a 503 “Service not connected” page. Settings show connected-since / last-seen / never.
  • Limits: 64 concurrent streams per installation and 16 per member; a stream with no byte progress for 60 s is cancelled; send an SSE keepalive comment at least every 30 s, even when there are no events; the per-stream window is 1 MiB. HTTP methods, streamed request bodies and streamed responses (including SSE) work. WebSocket upgrades are not proxied through the tunnel.

The web app asks the daemon for a surface grant (POST /api/plugins/surface-grant, session-authenticated, project access and tunnel transport required). The grant is an HMAC token valid for at most one hour; the app re-mints it on mount. The frame loads

/api/plugins/surface/<installationId>/<grant>/<path>

and the grant stays in the path, so relative URLs and rewritten root-relative URLs (/app.js → …/<grant>/app.js) inherit it. On every request the daemon verifies the grant and re-checks that the installation is enabled and the member still has project access; otherwise 401.

  • The frame is sandboxed with allow-scripts allow-forms allow-modals allow-popups allow-downloads and allow="clipboard-read; clipboard-write; fullscreen". There is no allow-same-origin and no allow-popups-to-escape-sandbox: the frame has an opaque origin, popups stay sandboxed, and downloads are marked as coming from Lowkey.
  • No cookies and no web storage. Treat localStorage, sessionStorage and document.cookie as throwing. Set-Cookie from your service is stripped. Identity comes from the headers below, not a login on the surface.
  • CORS. Because the origin is opaque, responses carry Access-Control-Allow-Origin: * (never Allow-Credentials), and OPTIONS preflights are answered for requests that use only forwarded header names.
  • Every response — including 503s, errors and redirects — carries Content-Security-Policy: sandbox <the same flags>; frame-ancestors 'self', Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff and Cache-Control: no-store. Your own CSP, X-Frame-Options, Service-Worker-Allowed and similar headers are dropped. Service-worker fetches (Sec-Fetch-Dest: serviceworker) are refused.
  • Top-level interstitial. A document load outside the Lowkey app (a pasted link, Sec-Fetch-Dest: document) gets a Lowkey page — “This plugin renders inside Lowkey” — never your HTML. “Open in browser” is hidden for tunnel surfaces.
  • A Location redirect that leaves the surface root is refused (502). HTML, CSS and JavaScript responses up to 2 MiB (8 MiB buffered per installation) have root-relative URLs rewritten; larger or encoded responses pass through unrewritten, so use relative URLs in big bundles.

Only these client headers are forwarded: content-type, content-length, accept, accept-language, range, if-none-match, if-modified-since, cache-control, user-agent, x-requested-with. Never forwarded: Cookie, Authorization, X-Forwarded-*, X-Real-IP, Referer, Origin, Sec-*, proxy headers, and any client-sent X-Lowkey-*.

The daemon adds identity headers. The connection is authenticated, so you may trust them:

Header Value
X-Lowkey-Plugin-Installation The installation id (64 hex chars).
X-Lowkey-Plugin-Project The project’s display name (its folder name, never the path).
X-Lowkey-Plugin-Actor user:<participantId> for a browser request; plugin-mcp for an MCP request.
X-Lowkey-Plugin-Actor-Email The member’s email — only when the manifest declares surface.identity: ["email"], and never on MCP requests.
X-Lowkey-Plugin-On-Behalf-Of MCP requests only: user:<participantId> of the launching turn, when known.

The web installer admits an mcp/ tree alongside manifest.json, main.ts/main.js and ui/. Uploaded files are never made executable, so launch through an interpreter:

"mcp": { "command": "node", "args": ["./mcp/server.mjs"] }
  • command and args entries starting ./ resolve against the installed plugin directory at registration; the real path must stay inside it (a .. segment or a symlink out of the tree refuses the server). Resolved paths are absolute.
  • mcp.command must be absolute, a bare program name without /, or ./-prefixed. Arguments containing / must be absolute, ./-prefixed, flags starting - (such as --file=value/path), or URLs beginning with a scheme and ://. Other relative forms such as mcp/server.mjs are refused, including after configuration substitution. Parent traversal is refused even in flags and URLs.
  • LOWKEY_PLUGIN_DIR supplies the installed directory when environment handoff is available. Claude’s stdio config has no working directory; Codex’s does, and is given the plugin directory.
  • Only enabled plugins in the launch’s project that declare the mcp scope contribute a server.

Credentials reach the server only as environment

Section titled “Credentials reach the server only as environment”

Launch credentials and mcp.env values (including ${config.*} substitutions) use the provider environment on Claude and Codex, keeping them out of MCP config values and argv. Claude PTY writes references to mcp.json; the installed Claude Agent SDK passes mcpServers as --mcp-config JSON in argv, so it uses those same namespaced ${VAR} references. Configured Claude args/env values containing ${ are refused with a clear registration error to prevent a second expansion.

These provider variables are visible to the agent’s own shell (for example via env). That shell belongs to the same launching agent and is inside this trust boundary; these credentials are not hidden from the agent. Keep service secrets in the service when the agent must not have them.

On Codex every MCP env entry uses env_vars passthrough, with no env map in config overrides. This requires exactly one eligible plugin MCP server and a provider-environment handoff; with multiple plugin servers all plugin env values (including LOWKEY_PLUGIN_DIR) and credentials are omitted with a warning. Runtime support:

Runtime LOWKEY_PLUGIN_* credentials
Claude Yes, one set per installation (namespaced provider variables expanded into each server’s own env), so each server is configured with its own values.
Codex Only when exactly one plugin MCP server is registered (name-only passthrough has no per-server mapping). With several, credentials are omitted and a warning is logged.
Any other runtime Omitted until a verified per-install path exists.

Where granted: LOWKEY_PLUGIN_TOKEN (the lkp_ scoped token, for callbacks). For tunnel surfaces also LOWKEY_PLUGIN_SURFACE_URL (the daemon URL of the surface root, …/api/plugins/surface/<installationId>/mcp/) and LOWKEY_PLUGIN_SURFACE_TOKEN (an lkm_… credential minted for this launch and dropped when it ends). Call the service with Authorization: Bearer $LOWKEY_PLUGIN_SURFACE_TOKEN against the surface URL; the request reaches your upstream with X-Lowkey-Plugin-Actor: plugin-mcp. The surface route never accepts lkp_. Manifest env entries for these reserved names are ignored. A server must tolerate the variables being absent (unsupported runtime or inspection without a launch).

Per-install configuration: ${config.<field>}

Section titled “Per-install configuration: ${config.<field>}”

Fields come from the plugin’s configSchema and are filled at install or in settings.

  • Allowed in exactly three places: the path and query of a direct surface.url, mcp.args entries and mcp.env values. Substitution into a URL is percent-encoded.
  • Everywhere else — mcp.command, a URL’s origin or fragment, a tunnel surface.path, property names, any other field — a placeholder fails manifest validation, as does a reference to a field that is not in configSchema.fields. After substitution a direct URL must keep its literal origin, and a configured mcp.args entry cannot contain a .. segment.
  • A missing value leaves the surface unmounted (the settings page stays usable so you can repair the configuration).

A direct service holding the lkp_ token can ask who it is:

POST /api/plugins/token/introspect
Authorization: Bearer lkp_…
→ 200 { "pluginId", "installationId", "project", "scopes", "enabled": true }

Any missing, unknown, rejected or disabled token returns the same 401. The route is rate-limited per source (60 requests a minute) and needs no session.

deploy.sh does not ship nginx. A host proxying Lowkey needs three locations (see provision/nginx-lowkey.conf.template):

Location Settings
= /plugin-tunnel auth_request off, WebSocket upgrade, proxy_pass_request_headers off, forwarding only X-Lowkey-Connector (plus the handshake headers and X-Real-IP), no proxy-token include.
^~ /api/plugins/surface/ auth_request off (the grant or lkm_ is the auth), no request or response buffering, long read timeout, large body limit.
= /api/plugins/token/introspect auth_request off, POST only, 1 MB body cap.

Without the first the connector cannot connect; without the second the frame’s cookie-less subrequests are rejected by the session gate.