Mirra
Tools & Features

Tunnels

Connect local services to Mirra via secure WebSocket tunnels

Mirra Tunnels provide secure WebSocket connections that let you expose local services (like Claude Code, custom APIs, or development servers) to Mirra without making them publicly accessible.

What you can do with tunnels

  • Control Claude Code remotely — The Claude Code Bridge uses tunnels to receive commands from your phone
  • Connect local AI services — Route requests to local LLMs, coding assistants, or custom tools
  • Development testing — Expose local APIs to Mirra Flows during development
  • Multiple services — Run different tunnels for different local services on the same machine

How tunnels work

┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│  Mirra Flow     │────────►│  Mirra Cloud    │────────►│  Your Machine   │
│  or API call    │         │  (Tunnel Proxy) │         │  (Tunnel Client)│
└─────────────────┘         └─────────────────┘         └─────────────────┘
                                    │                            │
                              WebSocket                    Local Server
                              Connection                   (port 3847)
  1. Tunnel client connects to Mirra via WebSocket and authenticates with your API key
  2. HTTP requests to your tunnel URL are forwarded through the WebSocket
  3. Tunnel client proxies requests to your local service and returns responses
  4. No port forwarding or public IPs needed — everything goes through Mirra's servers

Quick start with Claude Code Bridge

The easiest way to set up a tunnel is with the Claude Code Bridge:

npm install -g mirra-cc-bridge
mirra-cc-bridge start

This starts a tunnel that:

  • Connects to Mirra using your API key
  • Runs a local server on port 3847
  • Handles Claude Code session management

See Claude Code Bridge for the full setup guide.

Prerequisites

To set up a custom tunnel, you need:

RequirementNotes
Node.js 18+For running the tunnel client
Mirra API keyFrom your Mirra account or the mobile app
Local serviceThe HTTP server you want to expose

Setting up a custom tunnel

1. Get your API key

You can get an API key from:

  • Mirra mobile app → Settings → API Keys
  • Browser auth during mirra-cc-bridge configure
  • Mirra Store → Developer Settings

2. Create a tunnel client

Here's a minimal tunnel client in Node.js:

const WebSocket = require('ws');
const http = require('http');
 
const API_KEY = 'mirra_your_api_key_here';
const TUNNEL_NAME = 'my-service';  // Optional, defaults to 'default'
const LOCAL_PORT = 8080;           // Your local service port
 
class TunnelClient {
  constructor() {
    this.ws = null;
    this.tunnelUrl = null;
  }
 
  async connect() {
    return new Promise((resolve, reject) => {
      const wsUrl = 'wss://api.fxn.world/tunnel/ws';
      this.ws = new WebSocket(wsUrl);
 
      // Connection timeout
      const timeout = setTimeout(() => {
        this.ws.close();
        reject(new Error('Connection timeout'));
      }, 30000);
 
      this.ws.on('open', () => {
        console.log('Connected to Mirra, authenticating...');
 
        // Send authentication message
        this.ws.send(JSON.stringify({
          type: 'tunnel:connect',
          timestamp: Date.now(),
          apiKey: API_KEY,
          tunnelName: TUNNEL_NAME,
          metadata: {
            hostname: require('os').hostname(),
            clientVersion: '1.0.0'
          }
        }));
      });
 
      this.ws.on('message', async (data) => {
        const msg = JSON.parse(data.toString());
 
        switch (msg.type) {
          case 'tunnel:connected':
            clearTimeout(timeout);
            this.tunnelUrl = msg.tunnelUrl;
            console.log(`Tunnel connected: ${this.tunnelUrl}`);
            resolve(this.tunnelUrl);
            break;
 
          case 'tunnel:ping':
            // Respond to keepalive
            this.ws.send(JSON.stringify({
              type: 'tunnel:pong',
              timestamp: Date.now()
            }));
            break;
 
          case 'tunnel:http_request':
            // Forward request to local service
            const response = await this.handleRequest(msg);
            this.ws.send(JSON.stringify(response));
            break;
 
          case 'tunnel:error':
            console.error('Tunnel error:', msg.message);
            if (msg.code === 'AUTH_FAILED') {
              reject(new Error('Authentication failed - check your API key'));
            }
            break;
        }
      });
 
      this.ws.on('close', (code) => {
        console.log(`Tunnel disconnected (code: ${code})`);
        if (code !== 1000) {
          // Reconnect after 5 seconds
          setTimeout(() => this.connect(), 5000);
        }
      });
 
      this.ws.on('error', (err) => {
        clearTimeout(timeout);
        reject(err);
      });
    });
  }
 
  async handleRequest(request) {
    return new Promise((resolve) => {
      const options = {
        hostname: 'localhost',
        port: LOCAL_PORT,
        path: request.path,
        method: request.method,
        headers: request.headers
      };
 
      const req = http.request(options, (res) => {
        let body = '';
        res.on('data', chunk => body += chunk);
        res.on('end', () => {
          resolve({
            type: 'tunnel:http_response',
            timestamp: Date.now(),
            requestId: request.requestId,
            statusCode: res.statusCode,
            headers: Object.fromEntries(
              Object.entries(res.headers)
                .filter(([_, v]) => typeof v === 'string')
            ),
            body
          });
        });
      });
 
      req.on('error', (err) => {
        resolve({
          type: 'tunnel:http_response',
          timestamp: Date.now(),
          requestId: request.requestId,
          statusCode: 502,
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ error: err.message })
        });
      });
 
      req.setTimeout(30000, () => {
        req.destroy();
        resolve({
          type: 'tunnel:http_response',
          timestamp: Date.now(),
          requestId: request.requestId,
          statusCode: 504,
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ error: 'Gateway timeout' })
        });
      });
 
      if (request.body) {
        req.write(request.body);
      }
      req.end();
    });
  }
}
 
// Start the tunnel
const client = new TunnelClient();
client.connect()
  .then(url => console.log('Ready to receive requests at:', url))
  .catch(err => console.error('Failed to connect:', err.message));

3. Start your local service

Make sure your local service is running on the configured port:

# Example: Start a simple HTTP server
node your-service.js  # Listening on port 8080

4. Start the tunnel

node tunnel-client.js

You should see:

Connected to Mirra, authenticating...
Tunnel connected: https://api.fxn.world/tunnel/user123/my-service
Ready to receive requests at: https://api.fxn.world/tunnel/user123/my-service

Named tunnels

You can run multiple tunnels for different services by using different tunnel names:

// Tunnel for Claude Code
{ tunnelName: 'default' }  // or omit for default
 
// Tunnel for a custom AI service
{ tunnelName: 'local-llm' }
 
// Tunnel for a development API
{ tunnelName: 'dev-api' }

Each tunnel gets its own URL:

  • Default: https://api.fxn.world/tunnel/{userId}
  • Named: https://api.fxn.world/tunnel/{userId}/{tunnelName}

Accessing tunnels from Flows

Use the tunnel adapter in your Mirra Flows:

// Call the default tunnel
const result = await adapters.tunnel.call({
  path: '/api/query',
  method: 'POST',
  body: { prompt: 'Hello from Mirra!' }
});
 
console.log(result.data.body);  // Response from local service
 
// Call a named tunnel
const llmResult = await adapters.tunnel.call({
  tunnel: 'local-llm',
  path: '/generate',
  method: 'POST',
  body: { prompt: 'Write a haiku' }
});
 
// Check if a tunnel is connected
const status = await adapters.tunnel.status({ tunnel: 'dev-api' });
if (status.data.connected) {
  // Make request
}
 
// List all connected tunnels
const tunnels = await adapters.tunnel.list({});
console.log(`${tunnels.data.count} tunnels connected`);

Protocol reference

Message types

TypeDirectionPurpose
tunnel:connectClient → ServerAuthenticate and establish tunnel
tunnel:connectedServer → ClientConfirm connection with tunnel URL
tunnel:pingServer → ClientKeepalive check (every 30s)
tunnel:pongClient → ServerKeepalive response
tunnel:http_requestServer → ClientForward HTTP request
tunnel:http_responseClient → ServerReturn HTTP response
tunnel:errorEitherError notification

Connect message

{
  "type": "tunnel:connect",
  "timestamp": 1704067200000,
  "apiKey": "mirra_abc123...",
  "tunnelName": "my-service",
  "metadata": {
    "hostname": "my-laptop",
    "clientVersion": "1.0.0"
  }
}

HTTP request message

{
  "type": "tunnel:http_request",
  "timestamp": 1704067200000,
  "requestId": "uuid-xxx-xxx",
  "method": "POST",
  "path": "/api/query",
  "headers": {
    "content-type": "application/json"
  },
  "body": "{\"prompt\":\"Hello\"}"
}

HTTP response message

{
  "type": "tunnel:http_response",
  "timestamp": 1704067200000,
  "requestId": "uuid-xxx-xxx",
  "statusCode": 200,
  "headers": {
    "content-type": "application/json"
  },
  "body": "{\"response\":\"World\"}"
}

The requestId in the response must match the request. Mirra uses this to correlate responses with pending HTTP requests.

Keepalive

The server sends tunnel:ping every 30 seconds. Your client must respond with tunnel:pong within 10 seconds or the connection will be terminated.

ws.on('message', (data) => {
  const msg = JSON.parse(data);
  if (msg.type === 'tunnel:ping') {
    ws.send(JSON.stringify({
      type: 'tunnel:pong',
      timestamp: Date.now()
    }));
  }
});

Error handling

Connection errors

Error codeMeaningAction
AUTH_REQUIREDNo API key providedInclude apiKey in connect message
AUTH_FAILEDInvalid API keyCheck your API key is valid
Close code 4001Authentication rejectedRe-authenticate or get new API key

Reconnection

On unexpected disconnection, wait 5 seconds and reconnect:

ws.on('close', (code) => {
  if (code !== 1000) {  // Not intentional close
    setTimeout(() => connect(), 5000);
  }
});

Don't reconnect on AUTH_FAILED — the API key is invalid and reconnecting won't help. Get a new API key instead.

Security

Authentication

  • Tunnels authenticate using your Mirra API key
  • API keys are hashed before storage — we never see your raw key
  • Invalid keys are rejected immediately

Transport security

  • All connections use WSS (WebSocket Secure)
  • HTTP requests through the tunnel use HTTPS endpoints
  • No plaintext data transmission

Access control

  • Only requests authenticated to your Mirra account can use your tunnel
  • Internal calls from Mirra Flows use signed headers
  • Each tunnel is isolated to your user ID

Anyone with access to your Mirra account can send requests through your tunnel. Treat tunnel access like SSH access to your machine.

Troubleshooting

"Authentication failed"

Your API key is invalid or expired. Get a new one from:

  • Mirra mobile app → Settings → API Keys
  • Run mirra-cc-bridge configure to re-authenticate

Tunnel keeps disconnecting

  • Check your internet connection stability
  • Ensure you're responding to tunnel:ping with tunnel:pong
  • Look for error messages before disconnect

Requests timing out

  • Verify your local service is running
  • Check the correct port is configured
  • Ensure your service responds within 30 seconds

"No tunnel connection" errors

  • The tunnel client isn't running
  • Authentication failed silently — check logs
  • Network blocked WebSocket connections

Configuration defaults

SettingValue
WebSocket URLwss://api.fxn.world/tunnel/ws
Request timeout30 seconds
Ping interval30 seconds
Pong timeout10 seconds
Default tunnel namedefault
Default local port3847 (Claude Code Bridge)

Next steps

  • Claude Code Bridge — Full guide for controlling Claude Code remotely
  • Flows — Build automations that use your tunnel
  • API Reference — Create resources that proxy through tunnels