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.
Declare it
Section titled “Declare it”"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 nourl;surface.pathis the request path sent to your service.pathandidentityare rejected on a direct surface, andurlis rejected on a tunnel surface. identitycurrently accepts only"email", with unique entries.- Install consent shows the transport, path and any identity field to the admin.
The connector
Section titled “The connector”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-filepath that does not exist yet. It generates its own secret, opens/plugin-tunnelwithX-Lowkey-Connector: lkpair_<sha256 hex of the secret>, and prints a code such asABCD-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):
node connector.mjs \ --lowkey https://lowkey.example \ --secret-file /run/secrets/lowkey-connector \ --upstream http://studio:8000It 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-stoppedsecrets: lowkey-connector: file: ./lowkey-connector.secret # contains the lkc_ value onlyRules 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 same401; 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
502when 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.
How the browser reaches your service
Section titled “How the browser reaches your service”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.
What the frame can and cannot do
Section titled “What the frame can and cannot do”- The frame is sandboxed with
allow-scripts allow-forms allow-modals allow-popups allow-downloadsandallow="clipboard-read; clipboard-write; fullscreen". There is noallow-same-originand noallow-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,sessionStorageanddocument.cookieas throwing.Set-Cookiefrom 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: *(neverAllow-Credentials), andOPTIONSpreflights are answered for requests that use only forwarded header names. - Every response — including
503s, errors and redirects — carriesContent-Security-Policy: sandbox <the same flags>; frame-ancestors 'self',Referrer-Policy: no-referrer,X-Content-Type-Options: nosniffandCache-Control: no-store. Your own CSP,X-Frame-Options,Service-Worker-Allowedand 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
Locationredirect 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.
Headers your service receives
Section titled “Headers your service receives”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. |
MCP tools shipped in the package
Section titled “MCP tools shipped in the package”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"] }commandandargsentries 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.commandmust 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 asmcp/server.mjsare refused, including after configuration substitution. Parent traversal is refused even in flags and URLs.LOWKEY_PLUGIN_DIRsupplies 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
mcpscope 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.argsentries andmcp.envvalues. Substitution into a URL is percent-encoded. - Everywhere else —
mcp.command, a URL’s origin or fragment, a tunnelsurface.path, property names, any other field — a placeholder fails manifest validation, as does a reference to a field that is not inconfigSchema.fields. After substitution a direct URL must keep its literal origin, and a configuredmcp.argsentry cannot contain a..segment. - A missing value leaves the surface unmounted (the settings page stays usable so you can repair the configuration).
Introspection for direct services
Section titled “Introspection for direct services”A direct service holding the lkp_ token can ask who it is:
POST /api/plugins/token/introspectAuthorization: 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.
Operator: nginx locations
Section titled “Operator: nginx locations”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.