Skill: michael-stajer/hotbutter (version 1.0.6)

# Hotbutter Voice Skill (Hosted Relay) **This skill routes data through hotbutter.ai.** Voice transcripts and agent responses are transmitted through the hosted relay server at `wss://hotbutter.ai`. This is a convenience skill — not a private or local-only solution. For a fully local alternative where no data leaves your machine, see [hotbutter-os](https://github.com/hotbutter-ai/hotbutter-os). For updates, follow [@DnuLkjkjh](https://x.com/DnuLkjkjh). ## Privacy & Data Flow **Data that transits through hotbutter.ai:** - Transcribed speech text (from browser speech-to-text) - Agent response text (stdout from your local `openclaw` binary) **Warning:** If your agent prints sensitive information (secrets, credentials, private data), that output will be sent through the relay. Avoid running agents that output secrets, or use `--relay-url` to point to a relay you control. **Data that stays local:** - Raw audio (processed in the browser, never transmitted) - The `openclaw` binary execution (runs on your machine) - Config file (`~/.hotbutter`) ## Required Dependencies - **`openclaw` CLI** — must be installed and on your PATH ## How It Works 1. This skill connects via WebSocket to `wss://hotbutter.ai` 2. A pairing code and URL (`https://hotbutter.ai/app?code=XXXXXX`) are printed 3. Open the URL in your browser to start a voice session 4. Browser speech-to-text converts your voice to text, sent through the relay to this skill 5. This skill executes `openclaw agent --session-id <id> -m <text>` on your machine 6. The agent's text response is sent back through the relay and spoken via browser TTS ## Usage ```bash # Start (connects to hotbutter.ai by default) voice-bridge start # Use your own relay for privacy voice-bridge start --relay-url wss://your-relay.example.com # Custom agent display name voice-bridge start --agent-name "My Agent" ``` ## Configuration | Option | Default | Description | |--------|---------|-------------| | `--relay-url` | `wss://hotbutter.ai` | Relay WebSocket URL. Change this to use your own relay. | | `--agent-name` | `Agent` | Display name shown in the voice client | ## First Run On first run, the skill will prompt for an optional email (stored locally in `~/.hotbutter`). Subsequent runs connect immediately.

path: index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hotbutter Voice Skill</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; max-width: 640px; margin: 0 auto; padding: 40px 24px; line-height: 1.6; }
    a { color: #e94560; }
    h1 { margin-bottom: 8px; }
    .subtitle { color: #aaa; margin-bottom: 32px; }
    code { background: #0d1117; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
    pre { background: #0d1117; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; overflow-x: auto; margin: 16px 0; }
    .btn { display: inline-block; background: #e94560; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin: 16px 8px 16px 0; }
    .btn:hover { opacity: 0.85; }
    h2 { color: #e94560; margin-top: 32px; }
    .files { list-style: none; padding: 0; }
    .files li { padding: 4px 0; }
    .files a { font-family: monospace; }
    .warning { background: #2d1b00; border: 1px solid #e9a045; border-radius: 8px; padding: 16px; margin: 16px 0; color: #f0c070; }
    .warning strong { color: #f5d090; }
  </style>
</head>
<body>
  <h1>Hotbutter Voice Skill (Hosted)</h1>
  <p class="subtitle">Voice chat skill for OpenClaw agents &mdash; uses the hosted relay at hotbutter.ai</p>

  <div class="warning">
    <strong>Data flows through hotbutter.ai:</strong> Voice transcripts and agent responses are transmitted through the hosted relay. For a fully private/local setup, use <a href="https://github.com/hotbutter-ai/hotbutter-os">hotbutter-os</a> instead.
  </div>

  <a href="/skill/download" class="btn">Download skill (.tar.gz)</a>
  <a href="https://hotbutter.ai" class="btn" style="background:transparent;border:2px solid #e94560;color:#e94560;">Back to hotbutter.ai</a>

  <h2>Requirements</h2>
  <p>The <code>openclaw</code> CLI must be installed and on your PATH. This skill executes <code>openclaw agent</code> locally for each voice message.</p>

  <h2>Install</h2>
  <pre>curl -sL https://hotbutter.ai/skill/download | tar -xzf -
cd skill-hotbutter &amp;&amp; npm install</pre>

  <h2>Usage</h2>
  <pre>node bin/voice-bridge.js start</pre>
  <p>On first run you'll be prompted for an optional email. Then a pairing code and link will be printed &mdash; open it in your browser to start talking.</p>

  <h2>How It Works</h2>
  <p>For each voice message, the skill runs:<br><code>openclaw agent --session-id &lt;id&gt; -m "&lt;transcribed speech&gt;"</code><br>and speaks the agent's response back to you via browser TTS.</p>
  <p>Voice transcripts and agent responses are relayed through <code>wss://hotbutter.ai</code>. Use <code>--relay-url</code> to point to your own relay for full privacy.</p>

  <h2>Files</h2>
  <ul class="files">
    <li><a href="package.json">package.json</a></li>
    <li><a href="bin/voice-bridge.js">bin/voice-bridge.js</a></li>
    <li><a href="lib/relay-client.js">lib/relay-client.js</a></li>
    <li><a href="lib/agent-bridge.js">lib/agent-bridge.js</a></li>
    <li><a href="SKILL.md">SKILL.md</a></li>
  </ul>
</body>
</html>


path: package.json
content: {
  "name": "@hotbutter/voice-skill-hotbutter",
  "version": "0.1.0",
  "private": true,
  "description": "OpenClaw skill — voice chat via HOSTED hotbutter.ai relay. Voice transcripts and agent responses transit through hotbutter.ai. Executes the local 'openclaw' CLI binary to process each agent turn. For a fully local/private setup, use hotbutter-os instead.",
  "bin": {
    "voice-bridge": "./bin/voice-bridge.js"
  },
  "scripts": {
    "start": "node bin/voice-bridge.js start"
  },
  "requiredBinaries": ["openclaw"],
  "openclaw": {
    "skill": true,
    "requires": {
      "binaries": ["openclaw"],
      "description": "Runs 'openclaw agent --session-id <id> -m <text>' for each voice message to get agent responses"
    }
  },
  "dependencies": {
    "ws": "^8.18.0"
  }
}


path: lib/relay-client.js
content: const WebSocket = require('ws');
const crypto = require('crypto');

const DEFAULT_RELAY = 'wss://hotbutter.ai';
const RECONNECT_DELAYS = [2000, 4000, 8000, 16000];

class RelayClient {
  constructor(options = {}) {
    this.relayUrl = options.relayUrl || DEFAULT_RELAY;
    this.agentId = options.agentId || crypto.randomUUID();
    this.agentName = options.agentName || 'Agent';
    this.ws = null;
    this.sessionId = null;
    this.pairingCode = null;
    this.handlers = {};
    this._reconnectAttempt = 0;
    this._shouldReconnect = true;
  }

  on(event, fn) {
    this.handlers[event] = fn;
  }

  _emit(event, data) {
    if (this.handlers[event]) this.handlers[event](data);
  }

  connect() {
    const wsUrl = this.relayUrl.replace(/^http/, 'ws') + '/ws/agent';
    this.ws = new WebSocket(wsUrl);

    this.ws.on('open', () => {
      this._reconnectAttempt = 0;
      this._send({
        type: 'agent:register',
        agentId: this.agentId,
        agentName: this.agentName,
      });
      this._emit('connected');
    });

    this.ws.on('message', (raw) => {
      let msg;
      try { msg = JSON.parse(raw); } catch { return; }

      switch (msg.type) {
        case 'relay:code':
          this.pairingCode = msg.code;
          this._emit('code', msg.code);
          break;
        case 'relay:paired':
          this.sessionId = msg.sessionId;
          this._emit('paired', { sessionId: msg.sessionId });
          break;
        case 'relay:message':
          this._emit('message', { sessionId: msg.sessionId, text: msg.text, timestamp: msg.timestamp });
          break;
        case 'relay:client-disconnected':
          this.sessionId = null;
          this._emit('client-disconnected', { sessionId: msg.sessionId });
          break;
        case 'relay:error':
          this._emit('error', { error: msg.error });
          break;
      }
    });

    this.ws.on('close', () => {
      this._emit('disconnected');
      this._tryReconnect();
    });

    this.ws.on('error', (err) => {
      this._emit('error', { error: err.message });
    });
  }

  sendMessage(text) {
    if (!this.sessionId) return;
    this._send({
      type: 'agent:message',
      sessionId: this.sessionId,
      text,
    });
  }

  sendTyping(active) {
    if (!this.sessionId) return;
    this._send({
      type: 'agent:typing',
      sessionId: this.sessionId,
      active,
    });
  }

  disconnect() {
    this._shouldReconnect = false;
    if (this.ws) this.ws.close();
  }

  _send(obj) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(obj));
    }
  }

  _tryReconnect() {
    if (!this._shouldReconnect) return;
    const delay = RECONNECT_DELAYS[this._reconnectAttempt] || RECONNECT_DELAYS[RECONNECT_DELAYS.length - 1];
    this._reconnectAttempt++;
    this._emit('reconnecting', { attempt: this._reconnectAttempt, delay });
    setTimeout(() => this.connect(), delay);
  }
}

module.exports = { RelayClient };


path: lib/agent-bridge.js
content: const { execFile } = require('child_process');

/**
 * Bridges incoming voice messages to the OpenClaw agent via `openclaw agent`.
 * Runs one agent turn and captures the response from stdout.
 */
class AgentBridge {
  constructor(options = {}) {
    this.agent = options.agent || undefined;
  }

  /**
   * Send a message to the agent and return the response.
   * @param {string} sessionId - session identifier (used as session-id)
   * @param {string} text - user's transcribed speech
   * @returns {Promise<string>} agent's response text
   */
  sendMessage(sessionId, text) {
    return new Promise((resolve, reject) => {
      const args = [
        'agent',
        '--session-id', sessionId,
        '-m', text,
      ];
      if (this.agent) {
        args.push('--agent', this.agent);
      }

      execFile('openclaw', args, { timeout: 120_000 }, (err, stdout, stderr) => {
        if (err) {
          if (err.code === 'ENOENT') {
            console.warn('[bridge] openclaw not found, echoing message back');
            return resolve(`[echo] ${text}`);
          }
          return reject(new Error(stderr || err.message));
        }
        resolve(stdout.trim());
      });
    });
  }
}

module.exports = { AgentBridge };


path: bin/voice-bridge.js
content: #!/usr/bin/env node

const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { RelayClient } = require('../lib/relay-client');
const { AgentBridge } = require('../lib/agent-bridge');

// --- Config ---

const CONFIG_PATH = path.join(require('os').homedir(), '.hotbutter');

function loadConfig() {
  try {
    return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
  } catch {
    return null;
  }
}

function saveConfig(config) {
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
}

function ask(question) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      rl.close();
      resolve(answer.trim());
    });
  });
}

// --- Parse args ---

const args = process.argv.slice(2);
const command = args[0];

function getFlag(name, fallback) {
  const idx = args.indexOf(name);
  if (idx === -1 || idx + 1 >= args.length) return fallback;
  return args[idx + 1];
}

if (command !== 'start') {
  console.log('Usage: voice-bridge start [--relay-url <url>] [--agent-name <name>]');
  process.exit(0);
}

const relayUrl = getFlag('--relay-url', 'wss://hotbutter.ai');
const agentName = getFlag('--agent-name', 'Agent');

// --- First-run setup ---

async function firstRunSetup() {
  let config = loadConfig();
  if (config) return config;

  console.log('');
  console.log('  Welcome to Hotbutter Voice!');
  console.log('');
  console.log('  Hotbutter gives your AI agent a voice interface.');
  console.log('  It connects your terminal agent to a browser-based');
  console.log('  voice chat at https://hotbutter.ai');
  console.log('');
  console.log('  Skill name: hotbutter');
  console.log('');

  const email = await ask('  Email (optional, press Enter to skip): ');

  config = { email: email || null, createdAt: new Date().toISOString() };
  saveConfig(config);

  console.log('');
  if (email) {
    console.log(`  Saved to ~/.hotbutter. Welcome, ${email}!`);
  } else {
    console.log('  Saved to ~/.hotbutter. You can add your email later.');
  }
  console.log('');

  return config;
}

// --- Start ---

async function main() {
  await firstRunSetup();

  const relay = new RelayClient({ relayUrl, agentName });
  const bridge = new AgentBridge();

  console.log(`[voice-bridge] Connecting to relay: ${relayUrl}`);

  relay.on('connected', () => {
    console.log('[voice-bridge] Connected to relay, waiting for pairing code...');
  });

  relay.on('code', (code) => {
    const appUrl = `https://hotbutter.ai/app?code=${code}`;
    console.log('');
    console.log('  ┌──────────────────────────────────────────────┐');
    console.log('  │                                                │');
    console.log(`  │  Pairing code:  ${code}                          │`);
    console.log('  │                                                │');
    console.log(`  │  Open in browser to start talking:             │`);
    console.log(`  │  ${appUrl}  │`);
    console.log('  │                                                │');
    console.log('  └──────────────────────────────────────────────┘');
    console.log('');
  });

  relay.on('paired', ({ sessionId }) => {
    console.log(`[voice-bridge] Client paired! Session: ${sessionId}`);
  });

  relay.on('message', async ({ sessionId, text }) => {
    console.log(`[voice-bridge] User said: "${text}"`);
    relay.sendTyping(true);

    try {
      const response = await bridge.sendMessage(sessionId, text);
      console.log(`[voice-bridge] Agent response: "${response}"`);
      relay.sendMessage(response);
      relay.sendTyping(false);
    } catch (err) {
      console.error(`[voice-bridge] Error sending to agent:`, err.message);
      relay.sendMessage('Sorry, I encountered an error processing your message.');
      relay.sendTyping(false);
    }
  });

  relay.on('client-disconnected', ({ sessionId }) => {
    console.log(`[voice-bridge] Client disconnected (session: ${sessionId})`);
  });

  relay.on('disconnected', () => {
    console.log('[voice-bridge] Disconnected from relay');
  });

  relay.on('reconnecting', ({ attempt, delay }) => {
    console.log(`[voice-bridge] Reconnecting (attempt ${attempt}, delay ${delay}ms)...`);
  });

  relay.on('error', ({ error }) => {
    console.error(`[voice-bridge] Error: ${error}`);
  });

  relay.connect();

  // Graceful shutdown
  process.on('SIGINT', () => {
    console.log('\n[voice-bridge] Shutting down...');
    relay.disconnect();
    process.exit(0);
  });

  process.on('SIGTERM', () => {
    relay.disconnect();
    process.exit(0);
  });
}

main().catch((err) => {
  console.error('[voice-bridge] Fatal error:', err);
  process.exit(1);
});


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