Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Albert Alises <albert.alises@gmail.com> Co-authored-by: Jaakko Husso <jaakko@n8n.io> Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com> Co-authored-by: Elias Meire <elias@meire.dev> Co-authored-by: Dimitri Lavrenük <dimitri.lavrenuek@n8n.io> Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
13 KiB
Local Gateway — Backend Technical Specification
Feature behaviour is defined in local-gateway.md. This document covers the backend implementation in
packages/cli/src/modules/instance-ai.
Table of Contents
- Component Overview
- Authentication Model
- HTTP API
- Gateway Lifecycle
- Per-User Isolation
- Tool Call Dispatch
- Disconnect & Reconnect
- Module Settings
1. Component Overview
The local gateway involves three runtime processes:
- n8n server — hosts the REST/SSE endpoints and orchestrates the AI agent.
- fs-proxy daemon or local-gateway app — runs on the user's local machine; executes tool calls.
- Browser (frontend) — initiates the connection and displays gateway status.
graph LR
FE[Browser / Frontend]
SRV[n8n Server]
DAEMON[fs-proxy Daemon\nlocal machine]
FE -- "POST /gateway/create-link\n(user auth)" --> SRV
FE -- "GET /gateway/status\n(user auth)" --> SRV
SRV -- "SSE push: instanceAiGatewayStateChanged\n(per-user)" --> FE
DAEMON -- "POST /gateway/init ➊\n(x-gateway-key, on connect & reconnect)" --> SRV
DAEMON <-- "GET /gateway/events?apiKey=... ➋\n(persistent SSE, tool call requests)" --> SRV
DAEMON -- "POST /gateway/response/:id\n(x-gateway-key, per tool call)" --> SRV
DAEMON -- "POST /gateway/disconnect\n(x-gateway-key, on shutdown)" --> SRV
➊ → ➋ ordering: the daemon always calls
POST /gateway/initbefore opening the SSE stream. The numbers indicate startup sequence, not request direction.
Key classes
| Class | File | Responsibility |
|---|---|---|
LocalGatewayRegistry |
filesystem/local-gateway-registry.ts |
Per-user state: tokens, session keys, timers, gateway instances |
LocalGateway |
filesystem/local-gateway.ts |
Single-user MCP gateway: tool call dispatch, pending request tracking |
InstanceAiService |
instance-ai.service.ts |
Thin delegation layer; exposes registry methods to the controller |
InstanceAiController |
instance-ai.controller.ts |
HTTP endpoints; routes daemon requests to the correct user's gateway |
2. Authentication Model
The gateway uses two distinct authentication schemes for the two sides of the connection.
User-facing endpoints
Standard n8n session or API-key auth (@Authenticated / @GlobalScope).
The userId is taken from req.user.id.
Daemon-facing endpoints (skipAuth: true)
These endpoints are not protected by the standard auth middleware. Instead, they verify a gateway API key passed in one of two ways:
GET /gateway/events—?apiKey=<key>query parameter (required forEventSource, which cannot set headers).- All other daemon endpoints —
x-gateway-keyrequest header.
The key is resolved to a userId by validateGatewayApiKey() in the
controller:
1. If N8N_INSTANCE_AI_GATEWAY_API_KEY env var is set and matches → userId = 'env-gateway'
2. Otherwise look up the key in LocalGatewayRegistry.getUserIdForApiKey()
- Matches pairing tokens (TTL: 5 min, one-time use)
- Matches active session keys (persistent until explicit disconnect)
3. No match → ForbiddenError
Timing-safe comparison (crypto.timingSafeEqual) is used for the env-var
path to prevent timing attacks.
3. HTTP API
All paths are prefixed with /api/v1/instance-ai.
User-facing
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/gateway/create-link |
User | Generate a pairing token; returns { token, command } |
GET |
/gateway/status |
User | Returns { connected, connectedAt, directory } for the requesting user |
Daemon-facing (skipAuth)
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/gateway/events |
API key (?apiKey) |
SSE stream; emits tool call requests to the daemon |
POST |
/gateway/init |
API key (x-gateway-key) |
Daemon announces capabilities; swaps pairing token for session key |
POST |
/gateway/response/:requestId |
API key (x-gateway-key) |
Daemon delivers a tool call result or error |
POST |
/gateway/disconnect |
API key (x-gateway-key) |
Daemon gracefully terminates the connection |
POST /gateway/create-link — response
{
token: string; // gw_<nanoid(32)> — pairing token for /gateway/init
command: string; // "npx @n8n/fs-proxy <baseUrl> <token>"
}
GET /gateway/status — response
{
connected: boolean;
connectedAt: string | null; // ISO timestamp
directory: string | null; // rootPath advertised by daemon
}
POST /gateway/init — request body
// InstanceAiGatewayCapabilities
{
rootPath: string; // Filesystem root the daemon exposes
tools: McpTool[]; // MCP tool definitions the daemon supports
}
Response: { ok: true, sessionKey: string } on first connect.
Response: { ok: true } when reconnecting with an active session key.
POST /gateway/response/:requestId — request body
{
result?: {
content: Array<
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
>;
isError?: boolean;
};
error?: string;
}
4. Gateway Lifecycle
4.1 Initial connection
sequenceDiagram
participant FE as Browser
participant SRV as n8n Server
participant D as fs-proxy Daemon
FE->>SRV: POST /gateway/create-link (user auth)
SRV-->>FE: { token: "gw_...", command: "npx @n8n/fs-proxy ..." }
Note over FE: User runs the command on their machine
D->>SRV: POST /gateway/init (x-gateway-key: gw_...)
Note over D: uploadCapabilities() — resolves tool definitions,<br/>then POSTs rootPath + McpTool[]
Note over SRV: consumePairingToken(userId, token)<br/>Issues session key sess_...
SRV-->>D: { ok: true, sessionKey: "sess_..." }
Note over D: Stores session key, uses it for all<br/>subsequent requests instead of the pairing token
D->>SRV: GET /gateway/events?apiKey=sess_... (SSE, persistent)
Note over SRV: SSE connection held open,<br/>tool call requests streamed as events
SRV-->>FE: push: instanceAiGatewayStateChanged { connected: true, directory }
4.2 Reconnection with existing session key
After the initial handshake the daemon persists the session key in memory. On reconnect (e.g. after a transient network drop):
sequenceDiagram
participant D as fs-proxy Daemon
participant SRV as n8n Server
D->>SRV: POST /gateway/init (x-gateway-key: sess_...)
Note over SRV: Session key found → userId<br/>initGateway(userId, capabilities), no token consumed
SRV-->>D: { ok: true }
D->>SRV: GET /gateway/events?apiKey=sess_... (SSE, persistent)
Note over SRV: SSE connection re-established
generatePairingToken() also short-circuits: if an active session key
already exists for the user it is returned directly, so a new pairing token
is never issued while a session is live.
4.3 Token & key lifecycle
generatePairingToken(userId)
│ Existing session key? ──yes──▶ return session key
│ Valid pairing token? ──yes──▶ return existing token
│ Otherwise ──────▶ create gw_<nanoid>, register in reverse lookup
consumePairingToken(userId, token)
│ Validates token matches & is within TTL (5 min)
│ Deletes pairing token from reverse lookup
│ Creates sess_<nanoid>, registers in reverse lookup
└─▶ returns session key
clearActiveSessionKey(userId)
Deletes session key from reverse lookup
Nulls state (daemon must re-pair on next connect)
5. Per-User Isolation
All gateway state is held in LocalGatewayRegistry, which maintains two
maps:
userGateways: Map<userId, UserGatewayState>
apiKeyToUserId: Map<token|sessionKey, userId> ← reverse lookup
UserGatewayState contains:
interface UserGatewayState {
gateway: LocalGateway;
pairingToken: { token: string; createdAt: number } | null;
activeSessionKey: string | null;
disconnectTimer: ReturnType<typeof setTimeout> | null;
reconnectCount: number;
}
Isolation guarantees:
- Daemon endpoints resolve a
userIdfromvalidateGatewayApiKey()and operate exclusively on that user'sUserGatewayState. No endpoint accepts auserIdfrom the request body. getGateway(userId)creates state lazily;findGateway(userId)returnsundefinedif no state exists (used inexecuteRunto avoid allocating state for users who have never connected).- Pairing tokens and session keys are globally unique (
nanoid(32)) and never shared across users. disconnectAll()on shutdown iteratesuserGateways.values()and tears down every gateway in isolation.
6. Tool Call Dispatch
When the AI agent needs to invoke a local tool the call flows through
LocalGateway:
sequenceDiagram
participant A as AI Agent
participant GW as LocalGateway
participant SRV as Controller (SSE)
participant D as fs-proxy Daemon
A->>GW: callTool({ name, args })
GW->>GW: generate requestId, create Promise (30 s timeout)
GW->>SRV: emit "filesystem-request" via EventEmitter
SRV-->>D: SSE event: { type: "filesystem-request", payload: { requestId, toolCall } }
D->>D: execute tool locally
D->>SRV: POST /gateway/response/:requestId { result }
SRV->>GW: resolveRequest(userId, requestId, result)
GW->>GW: resolve Promise, clear timeout
GW-->>A: McpToolCallResult
If the daemon does not respond within 30 seconds the promise rejects and the agent receives a tool-error event.
If the gateway disconnects while requests are pending, LocalGateway.disconnect()
rejects all outstanding promises immediately with "Local gateway disconnected".
7. Disconnect & Reconnect
Explicit disconnect (user or daemon-initiated)
POST /gateway/disconnect:
clearDisconnectTimer(userId)— cancels any pending grace timer.disconnectGateway(userId)— marks gateway disconnected, rejects pending tool calls.clearActiveSessionKey(userId)— removes session key from reverse lookup. The daemon must re-pair on the next connect.- Push notification sent to user:
instanceAiGatewayStateChanged { connected: false }.
Unexpected SSE drop (daemon crash / network loss)
Both sides react independently when the SSE connection drops.
Daemon side (GatewayClient.connectSSE — onerror handler):
- Closes the broken
EventSource. - Classifies the error:
- Auth error (HTTP 403 / 500) → calls
reInitialize(): re-uploads capabilities viaPOST /gateway/init, then reopens SSE. This handles the case where the server restarted and lost the session key. After 5 consecutive auth failures the daemon gives up and callsonPersistentFailure(). - Any other error → reopens SSE directly (session key is still valid).
- Auth error (HTTP 403 / 500) → calls
- Applies exponential backoff before each retry:
1s → 2s → 4s → … → 30s (cap). - Backoff and auth retry counter reset to zero on the next successful
onopen.
Server side (startDisconnectTimer in LocalGatewayRegistry):
- Starts a grace period before marking the gateway disconnected:
- Grace period uses exponential backoff:
min(10s × 2^reconnectCount, 120s) reconnectCountincrements each time the grace period expires.
- Grace period uses exponential backoff:
- If the daemon reconnects within the grace period:
clearDisconnectTimer(userId)cancels the timer.initGateway(userId, capabilities)resetsreconnectCount = 0.
- If the grace period expires:
disconnectGateway(userId)marks the gateway disconnected and rejects pending tool calls.- The session key is kept — the daemon can still re-authenticate without re-pairing.
onDisconnectfires, sendinginstanceAiGatewayStateChanged { connected: false }.
Server grace period:
reconnectCount: 0 1 2 3 ... n
grace period: 10 s 20 s 40 s 80 s ... 120 s (cap)
Daemon retry delay:
retry: 1 2 3 4 ... n
delay: 1 s 2 s 4 s 8 s ... 30 s (cap)
8. Module Settings
InstanceAiModule.settings() returns global (non-user-specific) values to
the frontend. Gateway connection status is not included because it is
per-user.
{
enabled: boolean; // Model is configured and usable
localGateway: boolean; // Local filesystem path is configured
localGatewayDisabled: boolean; // Admin/user opt-out flag
localGatewayFallbackDirectory: string | null; // Configured fallback path
}
Per-user gateway state is delivered via two mechanisms:
- Initial load —
GET /gateway/status(called on page mount). - Live updates — targeted push notification
instanceAiGatewayStateChangedsent only to the affected user viapush.sendToUsers(..., [userId]).