Skill: jason-czar/remote-relay (version 1.0.1)

--- name: remote-relay description: Secure outbound-only relay for remote OpenClaw control — no exposed ports, no SSH, no Telegram. homepage: https://github.com/openclaw/remote-relay metadata: clawdbot: emoji: "📡" requires: env: ["RELAY_URL", "NODE_ID", "AUTH_TOKEN"] primaryEnv: "AUTH_TOKEN" files: ["relayClient.ts", "config.ts", "capabilities.ts", "index.ts"] --- # remote-relay Enables secure remote communication between an OpenClaw instance and a relay server without exposing ports, requiring SSH, or relying on Telegram/Discord. ## Description The `remote-relay` skill registers your local OpenClaw instance as a **managed remote node** on a relay network. Once connected, the node can receive prompts, execute workflows, report health, and be restarted — all through a secure, outbound-only WebSocket channel. This skill replaces external messaging-based control layers such as Telegram or Discord with a native, secure relay channel for OpenClaw interaction. ## Node Lifecycle When the skill is enabled, the OpenClaw instance registers as a remote-capable node with the relay and maintains an active session. The node can be in one of three states: | State | Description | |---|---| | **Online** | Authenticated and accepting relay commands | | **Reconnecting** | Connection lost; auto-reconnecting with exponential backoff | | **Offline** | Skill disabled or relay unreachable after max retries | Relay commands are only accepted while the node is **authenticated and online**. Commands received during reconnection are discarded by the relay. ## Capabilities | Capability | Description | |---|---| | `remote_chat` | Receive and execute prompts remotely, streaming tokens back in real time | | `remote_status` | Report node health: uptime, active tasks, last error, connection state | | `remote_restart` | Safely restart the OpenClaw process without manual intervention. Pending executions are cancelled and reported before restart occurs. | | `remote_trigger` | Execute OpenClaw workflows/tasks triggered remotely | Remote commands are limited to declared capabilities and cannot execute arbitrary system-level operations. ## Configuration | Key | Required | Description | |---|---|---| | `relay_url` | ✅ | WebSocket URL of the relay server | | `node_id` | ✅ | Unique identifier for this OpenClaw node | | `auth_token` | ✅ | Secret token for authenticating with the relay | ## Message Protocol ### Incoming (Relay → Node) | `type` | Action | |---|---| | `prompt` | Execute via OpenClaw prompt runner, stream response tokens back | | `status` | Return node health payload | | `restart` | Cancel pending tasks, report them, then gracefully restart | | `workflow` | Execute a named OpenClaw task/workflow | ### Outgoing (Node → Relay) - **Heartbeat** (every 15s): ```json { "node_id": "...", "uptime": 3600, "active_tasks": 2, "last_error": null, "connection_state": "online" } ``` - **Response stream**: `{ "type": "token", "request_id": "...", "content": "..." }` per token - **Response complete**: `{ "type": "done", "request_id": "..." }` - **Status**: Full heartbeat payload with `request_id` ## External Endpoints | Endpoint | Protocol | Data Sent | Data Received | |---|---|---|---| | `wss://<relay_url>/connect` | WebSocket (TLS) | `auth_token`, `node_id`, heartbeat payloads, prompt response tokens | Relay commands: `prompt`, `status`, `restart`, `workflow` | No other external endpoints are contacted. All network activity is limited to the configured `relay_url`. ## Security & Privacy ### What leaves your machine - **`auth_token`** — sent once during the WebSocket handshake to authenticate the node - **`node_id`** — sent with every heartbeat and response to identify the node - **Heartbeat data** — uptime (seconds), active task count, last error string, connection state - **Prompt response tokens** — streamed back to the relay in response to `prompt` commands - **Workflow completion status** — success/error for triggered workflows ### What stays on your machine - All local AI model execution and inference - Local file system contents — never read or transmitted - Environment variables (other than the three declared above) - System information, IP addresses, or hardware details — never collected ### Network posture - **Outbound only** — the skill never opens a listening port or accepts inbound connections - **TLS encrypted** — all WebSocket connections use `wss://` (TLS 1.2+) - **No data persistence** — the relay server does not store prompt content or response tokens; it forwards in real time ## Trust Statement > **By installing this skill, you are connecting your OpenClaw instance to an external relay server at the configured `relay_url`.** Prompt content and response tokens are transmitted through this relay in real time. Only install this skill if you trust the operator of the relay server. The default relay (`wss://relay-terminal-cloud.fly.dev`) is operated by the project maintainers. ## Operational Guarantees - **Local AI execution continues** even if the relay disconnects - **Relay does not expose the node** to inbound traffic - **Remote actions are capability-scoped** — only declared capabilities can be invoked - **Pending tasks are reported** before any restart occurs — no silent failures ## Installation **Easiest way — use the visual setup wizard:** 👉 Open your dashboard and go to **[/skill/remote-relay](/skill/remote-relay)** to configure everything through the UI — generate a node ID, test your connection, and export your config in one place. **Or configure manually:** 1. Add the skill to your OpenClaw instance 2. Configure `relay_url`, `node_id`, and `auth_token` 3. Start OpenClaw — the relay connection is established automatically ## Intended Use This skill replaces external messaging-based control layers such as Telegram or Discord with a native, secure relay channel for OpenClaw interaction. It is designed for teams and individuals who need reliable remote access to their OpenClaw nodes without exposing infrastructure.

path: clawhub.json
content: {
  "name": "Remote Relay",
  "tagline": "Control your OpenClaw node remotely through a secure outbound-only WebSocket relay",
  "description": "The remote-relay skill connects your OpenClaw instance to a relay server using an outbound-only WebSocket channel. Once connected, you can send prompts, trigger workflows, check health, and restart the node — all without exposing ports, configuring SSH, or relying on Telegram/Discord. Designed for developers and teams who need reliable remote access to headless or server-deployed OpenClaw nodes.",
  "category": "communication",
  "tags": ["relay", "remote", "websocket", "control", "headless", "server"],
  "version": "1.0.1",
  "license": "MIT",
  "pricing": "free",
  "support_url": "https://github.com/openclaw/remote-relay/issues",
  "homepage": "https://github.com/openclaw/remote-relay"
}


path: capabilities.ts
content: export interface IncomingMessage {
  type: "prompt" | "status" | "restart" | "workflow";
  request_id: string;
  data?: Record<string, unknown>;
}

export type ConnectionState = "online" | "reconnecting" | "offline";

export interface HeartbeatPayload {
  node_id: string;
  uptime: number;
  active_tasks: number;
  last_error: string | null;
  connection_state: ConnectionState;
}

export interface TokenChunk {
  type: "token";
  request_id: string;
  content: string;
}

export interface DoneMessage {
  type: "done";
  request_id: string;
}

export interface StatusResponse {
  type: "status";
  node_id: string;
  uptime: number;
  active_tasks: number;
  last_error: string | null;
  connection_state: ConnectionState;
}

export interface ErrorResponse {
  type: "error";
  request_id: string;
  message: string;
}

export type OutgoingMessage =
  | HeartbeatPayload
  | TokenChunk
  | DoneMessage
  | StatusResponse
  | ErrorResponse;

/**
 * Interface that the host OpenClaw runtime must implement
 * so the relay skill can execute prompts, tasks, and restarts.
 */
export interface OpenClawRuntime {
  /** Execute a prompt and yield streamed tokens via callback. */
  executePrompt(
    prompt: string,
    onToken: ([REDACTED_SECRET]) => void
  ): Promise<void>;

  /** Execute a named workflow/task. */
  executeWorkflow(
    workflowId: string,
    params: Record<string, unknown>
  ): Promise<void>;

  /** Return the count of currently running tasks. */
  getRunningTaskCount(): number;

  /** Return the last error string, or null. */
  getLastError(): string | null;

  /** Gracefully restart the OpenClaw process. */
  restart(): Promise<void>;
}


path: README.md
content: # 📡 remote-relay

[![ClawHub](https://img.shields.io/badge/ClawHub-remote--relay-blue)](https://clawhub.ai/skills/remote-relay)
[![Version](https://img.shields.io/badge/version-1.0.0-green)](https://github.com/openclaw/remote-relay)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

**Secure outbound-only relay for remote OpenClaw control — no exposed ports, no SSH, no Telegram.**

## What It Does

Connects your OpenClaw instance to a relay server over a single outbound WebSocket connection. Once connected, you can remotely:

- 💬 **Send prompts** and stream responses in real time
- 📊 **Check health** — uptime, active tasks, last error
- 🔄 **Restart** the OpenClaw process gracefully
- ⚡ **Trigger workflows** by name with parameters

All without opening ports, configuring firewalls, or setting up SSH tunnels.

## Installation

```bash
openclaw skill install remote-relay
```

Or install from ClawHub:

```bash
clawhub install remote-relay
```

## Configuration

Set these environment variables or add them to your OpenClaw config:

| Variable | Required | Description |
|---|---|---|
| `RELAY_URL` | ✅ | WebSocket URL of the relay server (e.g. `wss://relay-terminal-cloud.fly.dev`) |
| `NODE_ID` | ✅ | Unique identifier for this node (typically your device ID) |
| `AUTH_TOKEN` | ✅ | Secret token for relay authentication (your device token) |

### Example config

```json
{
  "relay_url": "wss://relay-terminal-cloud.fly.dev",
  "node_id": "my-server-01",
  "auth_token": "your-device-token-here"
}
```

## How It Works

```
┌──────────────┐         outbound WSS         ┌──────────────┐
│  Your Server │ ──────────────────────────▶  │ Relay Server │
│  (OpenClaw)  │ ◀────────────────────────── │              │
│              │    commands / responses       │              │
└──────────────┘                               └──────────────┘
        │                                             │
        │  local AI execution                         │  dashboard / API
        │  stays on your machine                      │  for remote control
        ▼                                             ▼
```

1. OpenClaw opens an **outbound** WebSocket to the relay
2. Authenticates with `auth_token` and `node_id`
3. Sends heartbeats every 15 seconds
4. Receives and executes relay commands (`prompt`, `status`, `restart`, `workflow`)
5. Auto-reconnects with exponential backoff on disconnection

## Capabilities

| Capability | Description |
|---|---|
| `remote_chat` | Execute prompts remotely, stream tokens back |
| `remote_status` | Report node health and connection state |
| `remote_restart` | Gracefully restart with pending task reporting |
| `remote_trigger` | Execute named workflows/tasks |

## Security

- **Outbound only** — never opens a listening port
- **TLS encrypted** — all connections use `wss://`
- **Capability-scoped** — only declared capabilities can be invoked remotely
- **No data persistence** — relay forwards in real time, does not store content
- **Local execution continues** during disconnection

See [SKILL.md](./SKILL.md) for the full security & privacy disclosure.

## Requirements

- OpenClaw v1.0+
- A running relay server (default: `wss://relay-terminal-cloud.fly.dev`)
- Network access to the relay URL (outbound port 443)

## Troubleshooting

| Problem | Solution |
|---|---|
| Connection refused | Verify `RELAY_URL` is correct and reachable |
| Auth failed | Check `AUTH_TOKEN` matches your paired device token |
| Keeps reconnecting | Ensure the relay server is running and your network allows outbound WSS |
| Commands not executing | Verify the node is in **Online** state (check heartbeat logs) |

## License

MIT


path: relayClient.ts
content: import { RemoteRelayConfig, validateConfig } from "./config";
import {
  IncomingMessage,
  HeartbeatPayload,
  ConnectionState,
  OpenClawRuntime,
} from "./capabilities";

const HEARTBEAT_INTERVAL = 15_000;
const MAX_BACKOFF = 30_000;
const INITIAL_BACKOFF = 1_000;

export class RelayClient {
  private config: RemoteRelayConfig;
  private runtime: OpenClawRuntime;
  private ws: WebSocket | null = null;
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  private backoff = INITIAL_BACKOFF;
  private startTime = Date.now();
  private intentionalClose = false;
  private connectionState: ConnectionState = "offline";

  constructor(rawConfig: Partial<RemoteRelayConfig>, runtime: OpenClawRuntime) {
    this.config = validateConfig(rawConfig);
    this.runtime = runtime;
  }

  /** Start the relay connection. */
  connect(): void {
    this.intentionalClose = false;
    this.openSocket();
  }

  /** Gracefully disconnect. */
  disconnect(): void {
    this.intentionalClose = true;
    this.connectionState = "offline";
    this.stopHeartbeat();
    if (this.ws) {
      this.ws.close(1000, "shutdown");
      this.ws = null;
    }
  }

  private openSocket(): void {
    const url = `${this.config.relay_url}/connect`;
    this.connectionState = "reconnecting";
    console.log(`[remote-relay] Connecting to ${url}`);

    this.ws = new WebSocket(url);

    this.ws.onopen = () => {
      console.log("[remote-relay] Connected, sending auth");
      this.send({
        type: "hello",
        data: {
          node_id: this.config.node_id,
          [REDACTED_SECRET],
          meta: { kind: "openclaw" },
        },
      });
    };

    this.ws.onmessage = (event) => {
      try {
        const msg = JSON.parse(String(event.data));
        this.handleMessage(msg);
      } catch {
        console.error("[remote-relay] Invalid message received");
      }
    };

    this.ws.onclose = () => {
      this.stopHeartbeat();
      if (!this.intentionalClose) {
        console.log(
          `[remote-relay] Disconnected, reconnecting in ${this.backoff}ms`
        );
        setTimeout(() => this.openSocket(), this.backoff);
        this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF);
      }
    };

    this.ws.onerror = (err) => {
      console.error("[remote-relay] WebSocket error", err);
    };
  }

  private handleMessage(msg: { type: string; data?: unknown }): void {
    switch (msg.type) {
      case "hello_ok":
        console.log("[remote-relay] Authenticated");
        this.backoff = INITIAL_BACKOFF;
        this.connectionState = "online";
        this.startHeartbeat();
        break;

      case "prompt":
      case "status":
      case "restart":
      case "workflow":
        this.dispatch(msg as { type: string; data: IncomingMessage });
        break;

      case "error":
        console.error("[remote-relay] Relay error:", msg.data);
        break;

      default:
        console.warn(`[remote-relay] Rejecting unknown type: ${msg.type}`);
        break;
    }
  }

  private async dispatch(envelope: {
    type: string;
    data: IncomingMessage;
  }): Promise<void> {
    const { data } = envelope;
    const requestId = data?.request_id ?? "unknown";

    try {
      switch (envelope.type) {
        case "prompt": {
          const prompt = (data.data as { prompt?: string })?.prompt ?? "";
          await this.runtime.executePrompt(prompt, (token) => {
            this.send({
              type: "response",
              data: { type: "token", request_id: requestId, content: token },
            });
          });
          this.send({
            type: "response",
            data: { type: "done", request_id: requestId },
          });
          break;
        }

        case "status":
          this.send({
            type: "response",
            data: {
              type: "status",
              request_id: requestId,
              ...this.buildHeartbeat(),
            },
          });
          break;

        case "restart":
          this.send({
            type: "response",
            data: {
              type: "done",
              request_id: requestId,
            },
          });
          await this.runtime.restart();
          break;

        case "workflow": {
          const workflowId =
            (data.data as { workflow_id?: string })?.workflow_id ?? "";
          const params =
            (data.data as { params?: Record<string, unknown> })?.params ?? {};
          await this.runtime.executeWorkflow(workflowId, params);
          this.send({
            type: "response",
            data: { type: "done", request_id: requestId },
          });
          break;
        }
      }
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      this.send({
        type: "response",
        data: { type: "error", request_id: requestId, message },
      });
    }
  }

  private buildHeartbeat(): HeartbeatPayload {
    return {
      node_id: this.config.node_id,
      uptime: Math.floor((Date.now() - this.startTime) / 1000),
      active_tasks: this.runtime.getRunningTaskCount(),
      last_error: this.runtime.getLastError(),
      connection_state: this.connectionState,
    };
  }

  private startHeartbeat(): void {
    this.stopHeartbeat();
    this.heartbeatTimer = setInterval(() => {
      this.send({ type: "heartbeat", data: this.buildHeartbeat() });
    }, HEARTBEAT_INTERVAL);
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  private send(msg: Record<string, unknown>): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(msg));
    }
  }
}


path: index.ts
content: export { RelayClient } from "./relayClient";
export { validateConfig } from "./config";
export type { RemoteRelayConfig } from "./config";
export type {
  IncomingMessage,
  HeartbeatPayload,
  TokenChunk,
  DoneMessage,
  StatusResponse,
  ErrorResponse,
  OutgoingMessage,
  OpenClawRuntime,
} from "./capabilities";

import { RelayClient } from "./relayClient";
import type { RemoteRelayConfig } from "./config";
import type { OpenClawRuntime } from "./capabilities";

let client: RelayClient | null = null;

/**
 * Initialize and connect the remote-relay skill.
 * Call this once from your OpenClaw boot sequence.
 */
export function init(
  config: Partial<RemoteRelayConfig>,
  runtime: OpenClawRuntime
): RelayClient {
  if (client) {
    client.disconnect();
  }
  client = new RelayClient(config, runtime);
  client.connect();
  return client;
}

/**
 * Disconnect and clean up the relay client.
 */
export function shutdown(): void {
  if (client) {
    client.disconnect();
    client = null;
  }
}


path: config.ts
content: export interface RemoteRelayConfig {
  relay_url: string;
  node_id: string;
  auth_token: string;
}

export function validateConfig(config: Partial<RemoteRelayConfig>): RemoteRelayConfig {
  if (!config.relay_url) throw new Error("remote-relay: relay_url is required");
  if (!config.node_id) throw new Error("remote-relay: node_id is required");
  if (!config.auth_token) throw new Error("remote-relay: auth_token is required");

  // Normalize URL: ensure wss:// for secure connections
  let url = config.relay_url.replace(/\/+$/, "");
  url = url.replace(/^https:\/\//, "wss://");
  url = url.replace(/^http:\/\//, "ws://");

  return {
    relay_url: url,
    node_id: config.node_id,
    auth_token: config.auth_token,
  };
}


path: config.test.ts
content: import { describe, it, expect } from "vitest";
import { validateConfig } from "./config";

describe("validateConfig", () => {
  const valid = { relay_url: "https://relay.example.com", node_id: "n1", auth_token: "secret" };

  it("returns validated config for valid input", () => {
    const result = validateConfig(valid);
    expect(result.relay_url).toBe("wss://relay.example.com");
    expect(result.node_id).toBe("n1");
    expect(result.auth_token).toBe("secret");
  });

  it("converts https to wss", () => {
    expect(validateConfig(valid).relay_url).toBe("wss://relay.example.com");
  });

  it("converts http to ws", () => {
    const result = validateConfig({ ...valid, relay_url: "http://localhost:8080" });
    expect(result.relay_url).toBe("ws://localhost:8080");
  });

  it("strips trailing slashes", () => {
    const result = validateConfig({ ...valid, relay_url: "https://relay.example.com///" });
    expect(result.relay_url).toBe("wss://relay.example.com");
  });

  it("preserves wss:// URLs", () => {
    const result = validateConfig({ ...valid, relay_url: "wss://relay.example.com" });
    expect(result.relay_url).toBe("wss://relay.example.com");
  });

  it("throws if relay_url is missing", () => {
    expect(() => validateConfig({ node_id: "n1", auth_token: "t" })).toThrow("relay_url is required");
  });

  it("throws if node_id is missing", () => {
    expect(() => validateConfig({ relay_url: "https://x.com", auth_token: "t" })).toThrow("node_id is required");
  });

  it("throws if auth_token is missing", () => {
    expect(() => validateConfig({ relay_url: "https://x.com", node_id: "n1" })).toThrow("auth_token is required");
  });

  it("throws for empty object", () => {
    expect(() => validateConfig({})).toThrow("relay_url is required");
  });
});


path: relayClient.test.ts
content: import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OpenClawRuntime } from "./capabilities";
import { RelayClient } from "./relayClient";

// Mock WebSocket
class MockWebSocket {
  static OPEN = 1;
  readyState = MockWebSocket.OPEN;
  onopen: (() => void) | null = null;
  onmessage: ((e: { data: string }) => void) | null = null;
  onclose: (() => void) | null = null;
  onerror: ((e: unknown) => void) | null = null;
  sent: string[] = [];

  send(data: string) { this.sent.push(data); }
  close() { this.readyState = 3; }

  // Test helpers
  simulateOpen() { this.onopen?.(); }
  simulateMessage(msg: Record<string, unknown>) {
    this.onmessage?.({ data: JSON.stringify(msg) });
  }
}

let mockWs: MockWebSocket;

vi.stubGlobal("WebSocket", class extends MockWebSocket {
  constructor() {
    super();
    mockWs = this;
  }
});

function createMockRuntime(): OpenClawRuntime {
  return {
    executePrompt: vi.fn(async (_prompt, onToken) => {
      onToken("Hello");
      onToken(" world");
    }),
    executeWorkflow: vi.fn(async () => {}),
    getRunningTaskCount: vi.fn(() => 2),
    getLastError: vi.fn(() => null),
    restart: vi.fn(async () => {}),
  };
}

describe("RelayClient message dispatch", () => {
  let client: RelayClient;
  let runtime: ReturnType<typeof createMockRuntime>;

  beforeEach(() => {
    vi.useFakeTimers();
    runtime = createMockRuntime();
    client = new RelayClient(
      { relay_url: "wss://relay.test", node_id: "node-1", auth_token: "tok" },
      runtime
    );
  });

  function connectAndAuth() {
    client.connect();
    mockWs.simulateOpen();
    // Verify hello sent
    const hello = JSON.parse(mockWs.sent[0]);
    expect(hello.type).toBe("hello");
    // Simulate auth ok
    mockWs.simulateMessage({ type: "hello_ok" });
  }

  it("sends hello on connect", () => {
    client.connect();
    mockWs.simulateOpen();
    const msg = JSON.parse(mockWs.sent[0]);
    expect(msg.type).toBe("hello");
    expect(msg.data.node_id).toBe("node-1");
    expect(msg.data.token).toBe("tok");
    client.disconnect();
  });

  it("dispatches prompt to runtime and streams tokens", async () => {
    connectAndAuth();
    mockWs.sent = [];

    mockWs.simulateMessage({
      type: "prompt",
      data: { request_id: "r1", data: { prompt: "Hi" } },
    });

    // Flush microtasks for async dispatch
    await vi.advanceTimersByTimeAsync(0);

    expect(runtime.executePrompt).toHaveBeenCalledWith("Hi", expect.any(Function));

    const responses = mockWs.sent.map(s => JSON.parse(s));
    const tokens = responses.filter(r => r.data?.type === "token");
    const done = responses.find(r => r.data?.type === "done");
    expect(tokens.length).toBe(2);
    expect(tokens[0].data.content).toBe("Hello");
    expect(done?.data.request_id).toBe("r1");

    client.disconnect();
  });

  it("dispatches status request", async () => {
    connectAndAuth();
    mockWs.sent = [];

    mockWs.simulateMessage({
      type: "status",
      data: { request_id: "r2" },
    });

    await vi.advanceTimersByTimeAsync(0);

    const responses = mockWs.sent.map(s => JSON.parse(s));
    const statusResp = responses.find(r => r.data?.type === "status");
    expect(statusResp).toBeDefined();
    expect(statusResp?.data.node_id).toBe("node-1");
    expect(statusResp?.data.active_tasks).toBe(2);

    client.disconnect();
  });

  it("dispatches restart", async () => {
    connectAndAuth();
    mockWs.sent = [];

    mockWs.simulateMessage({
      type: "restart",
      data: { request_id: "r3" },
    });

    await vi.advanceTimersByTimeAsync(0);

    expect(runtime.restart).toHaveBeenCalled();
    client.disconnect();
  });

  it("dispatches workflow", async () => {
    connectAndAuth();
    mockWs.sent = [];

    mockWs.simulateMessage({
      type: "workflow",
      data: { request_id: "r4", data: { workflow_id: "wf-1", params: { x: 1 } } },
    });

    await vi.advanceTimersByTimeAsync(0);

    expect(runtime.executeWorkflow).toHaveBeenCalledWith("wf-1", { x: 1 });
    client.disconnect();
  });

  it("rejects unknown message types silently", async () => {
    connectAndAuth();
    mockWs.sent = [];

    mockWs.simulateMessage({ type: "unknown_garbage", data: {} });
    await vi.advanceTimersByTimeAsync(0);

    expect(mockWs.sent.length).toBe(0);
    client.disconnect();
  });

  it("sends heartbeat after auth", () => {
    connectAndAuth();
    mockWs.sent = [];

    vi.advanceTimersByTime(15_000);

    const heartbeats = mockWs.sent.map(s => JSON.parse(s)).filter(m => m.type === "heartbeat");
    expect(heartbeats.length).toBe(1);
    expect(heartbeats[0].data.node_id).toBe("node-1");

    client.disconnect();
  });
});


path: skill.json
content: {
  "slug": "remote-relay",
  "display_name": "Remote Relay",
  "version": "1.0.1",
  "description": "Enables secure remote communication between OpenClaw and a relay server without exposing ports.",
  "tags": ["relay", "remote", "websocket", "control"],
  "capabilities": [
    "remote_chat",
    "remote_status",
    "remote_restart",
    "remote_trigger"
  ],
  "config": {
    "relay_url": {
      "type": "string",
      "required": true,
      "description": "WebSocket URL of the relay server"
    },
    "node_id": {
      "type": "string",
      "required": true,
      "description": "Unique identifier for this OpenClaw node"
    },
    "auth_token": {
      "type": "string",
      "required": true,
      "sensitive": true,
      "description": "Secret token for relay authentication"
    }
  },
  "entry": "relayClient.ts"
}


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal: clean | SkillSpector: clean (LOW)