@voiceclaw/voiceclaw-plugin 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @voiceclaw/voiceclaw-plugin
2
+
3
+ OpenClaw channel plugin for **VoiceClaw** — control your AI agent with Siri on iOS & macOS.
4
+
5
+ ## What is VoiceClaw?
6
+
7
+ [VoiceClaw](https://voiceclaw.techartisan.site) is a native iOS & macOS app that lets you talk to your OpenClaw agent via Siri or a lightweight chat interface. Install this plugin to connect your OpenClaw instance to the app.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ openclaw plugins install @voiceclaw/voiceclaw-plugin
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Add the VoiceClaw channel to your `openclaw.config.json`:
18
+
19
+ ```json
20
+ {
21
+ "channels": {
22
+ "voiceclaw": {
23
+ "accounts": {
24
+ "default": {
25
+ "pairingCode": "<6-digit code>"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ > **Note:** The pairing code is only used for the initial connection. After that, a session token is saved locally and the code is no longer needed.
34
+
35
+ ## Pairing
36
+
37
+ 1. Open the **VoiceClaw** app on your iPhone or Mac
38
+ 2. Copy the 6-digit pairing code
39
+ 3. Paste it as `pairingCode` in your OpenClaw config
40
+ 4. Restart OpenClaw
41
+ 5. Tap **"I've Configured It"** in the app to verify the connection
42
+
43
+ ## License
44
+
45
+ MIT
@@ -0,0 +1,13 @@
1
+ interface PluginApi {
2
+ config: Record<string, any>;
3
+ logger: {
4
+ info: (msg: string) => void;
5
+ error: (msg: string) => void;
6
+ warn: (msg: string) => void;
7
+ };
8
+ registerChannel: (opts: {
9
+ plugin: any;
10
+ }) => void;
11
+ }
12
+ export default function register(api: PluginApi): void;
13
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { RelayWsClient } from './ws-client';
5
+ import { DEFAULT_WORKER_URL } from './types';
6
+ const clients = new Map();
7
+ let handleInbound = null;
8
+ // ── Token persistence ──────────────────────────────────────────
9
+ const DATA_DIR = join(homedir(), '.voiceclaw');
10
+ function tokenPath(accountId) {
11
+ return join(DATA_DIR, `session-${accountId}.token`);
12
+ }
13
+ function loadSavedToken(accountId) {
14
+ const p = tokenPath(accountId);
15
+ if (existsSync(p)) {
16
+ const token = readFileSync(p, 'utf-8').trim();
17
+ if (token.length > 0)
18
+ return token;
19
+ }
20
+ return null;
21
+ }
22
+ function saveToken(accountId, token) {
23
+ if (!existsSync(DATA_DIR)) {
24
+ mkdirSync(DATA_DIR, { recursive: true });
25
+ }
26
+ writeFileSync(tokenPath(accountId), token, 'utf-8');
27
+ }
28
+ // ── Pairing code exchange ──────────────────────────────────────
29
+ async function exchangePairingCode(code, workerUrl, logger) {
30
+ logger.info(`VoiceClaw: Exchanging pairing code for session token...`);
31
+ const url = `${workerUrl}/pair?code=${encodeURIComponent(code)}`;
32
+ const resp = await fetch(url, { method: 'POST' });
33
+ if (!resp.ok) {
34
+ const body = await resp.text();
35
+ throw new Error(`Pairing failed (${resp.status}): ${body}`);
36
+ }
37
+ const data = (await resp.json());
38
+ if (!data.sessionToken) {
39
+ throw new Error('Pairing response missing sessionToken');
40
+ }
41
+ logger.info('VoiceClaw: Pairing successful, session token saved');
42
+ return data.sessionToken;
43
+ }
44
+ /**
45
+ * Resolve the session token for an account:
46
+ * 1. If a saved session token exists on disk, use it.
47
+ * 2. Otherwise, exchange the pairing code and save the result.
48
+ */
49
+ async function resolveToken(accountId, account, logger) {
50
+ // Check for a previously saved session token.
51
+ const saved = loadSavedToken(accountId);
52
+ if (saved) {
53
+ logger.info(`VoiceClaw: Using saved session token for "${accountId}"`);
54
+ return saved;
55
+ }
56
+ // No saved token — exchange the pairing code.
57
+ if (!account.pairingCode) {
58
+ throw new Error(`No pairing code configured for "${accountId}"`);
59
+ }
60
+ const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
61
+ const sessionToken = await exchangePairingCode(account.pairingCode, workerUrl, logger);
62
+ saveToken(accountId, sessionToken);
63
+ return sessionToken;
64
+ }
65
+ // ── Plugin registration ────────────────────────────────────────
66
+ export default function register(api) {
67
+ const channelId = 'voiceclaw';
68
+ const voiceClawChannel = {
69
+ id: channelId,
70
+ meta: {
71
+ id: channelId,
72
+ label: 'VoiceClaw',
73
+ selectionLabel: 'VoiceClaw (Siri / iOS / macOS)',
74
+ docsPath: '/channels/voiceclaw',
75
+ blurb: 'Voice entry for OpenClaw via Siri & native iOS/macOS app.',
76
+ aliases: ['vc', 'siri'],
77
+ },
78
+ capabilities: { chatTypes: ['direct'] },
79
+ config: {
80
+ listAccountIds: (cfg) => Object.keys(cfg.channels?.voiceclaw?.accounts ?? {}),
81
+ resolveAccount: (cfg, accountId) => {
82
+ const accounts = cfg.channels?.voiceclaw?.accounts ?? {};
83
+ return accounts[accountId ?? 'default'] ?? {};
84
+ },
85
+ },
86
+ outbound: {
87
+ deliveryMode: 'direct',
88
+ sendText: async ({ text, accountId, }) => {
89
+ const id = accountId ?? 'default';
90
+ const client = clients.get(id);
91
+ if (!client || client.connectionState !== 'connected') {
92
+ api.logger.warn(`VoiceClaw: No connection for "${id}", reply dropped`);
93
+ return { ok: false };
94
+ }
95
+ client.send({
96
+ type: 'agent_reply',
97
+ id: crypto.randomUUID(),
98
+ replyTo: '',
99
+ text,
100
+ ts: new Date().toISOString(),
101
+ });
102
+ return { ok: true };
103
+ },
104
+ },
105
+ gateway: {
106
+ start: async (ctx) => {
107
+ handleInbound = ctx.processInbound;
108
+ const accounts = ctx.config.channels?.voiceclaw?.accounts ?? {};
109
+ for (const [accountId, account] of Object.entries(accounts)) {
110
+ if (account.enabled === false)
111
+ continue;
112
+ try {
113
+ const sessionToken = await resolveToken(accountId, account, api.logger);
114
+ const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
115
+ const client = new RelayWsClient({
116
+ token: sessionToken,
117
+ workerUrl,
118
+ onUserMessage: (msg) => {
119
+ api.logger.info(`VoiceClaw: User message (${msg.id}): ${msg.text.slice(0, 50)}...`);
120
+ handleInbound?.(msg.text, accountId);
121
+ },
122
+ onStateChange: (state) => {
123
+ api.logger.info(`VoiceClaw: [${accountId}] ${state}`);
124
+ },
125
+ logger: api.logger,
126
+ });
127
+ client.start();
128
+ clients.set(accountId, client);
129
+ api.logger.info(`VoiceClaw: Started relay for "${accountId}"`);
130
+ }
131
+ catch (err) {
132
+ api.logger.error(`VoiceClaw: Failed to start "${accountId}": ${err}`);
133
+ }
134
+ }
135
+ },
136
+ stop: () => {
137
+ for (const [accountId, client] of clients) {
138
+ client.stop();
139
+ api.logger.info(`VoiceClaw: Stopped "${accountId}"`);
140
+ }
141
+ clients.clear();
142
+ handleInbound = null;
143
+ },
144
+ },
145
+ };
146
+ api.registerChannel({ plugin: voiceClawChannel });
147
+ api.logger.info('VoiceClaw channel plugin registered');
148
+ }
@@ -0,0 +1,45 @@
1
+ /** Role of the WebSocket client. */
2
+ export type ClientRole = 'app' | 'plugin';
3
+ /** Auth success (sent by server on connect). */
4
+ export interface AuthOkMessage {
5
+ type: 'auth_ok';
6
+ }
7
+ /** Auth failure. */
8
+ export interface AuthErrorMessage {
9
+ type: 'auth_error';
10
+ reason: string;
11
+ }
12
+ /** User message from app → plugin. */
13
+ export interface UserMessage {
14
+ type: 'user_message';
15
+ id: string;
16
+ text: string;
17
+ ts: string;
18
+ }
19
+ /** Agent reply from plugin → app. */
20
+ export interface AgentReplyMessage {
21
+ type: 'agent_reply';
22
+ id: string;
23
+ replyTo: string;
24
+ text: string;
25
+ ts: string;
26
+ }
27
+ /** Heartbeat. */
28
+ export interface PingMessage {
29
+ type: 'ping';
30
+ }
31
+ export interface PongMessage {
32
+ type: 'pong';
33
+ }
34
+ /** Plugin config from channels.voiceclaw.accounts.<id> */
35
+ export interface VoiceClawAccountConfig {
36
+ pairingCode: string;
37
+ workerUrl?: string;
38
+ enabled?: boolean;
39
+ }
40
+ /** Default relay server URL. */
41
+ export declare const DEFAULT_WORKER_URL = "https://voiceclaw-api.techartisan.site";
42
+ /** Messages the plugin receives from the relay. */
43
+ export type InboundRelayMessage = AuthOkMessage | AuthErrorMessage | UserMessage | PongMessage;
44
+ /** Messages the plugin sends to the relay. */
45
+ export type OutboundRelayMessage = AgentReplyMessage | PingMessage;
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ // ── Shared message protocol types ──────────────────────────────
2
+ // Mirrors workers/src/protocol.ts for the plugin side.
3
+ /** Default relay server URL. */
4
+ export const DEFAULT_WORKER_URL = 'https://voiceclaw-api.techartisan.site';
@@ -0,0 +1,46 @@
1
+ import type { OutboundRelayMessage, UserMessage } from './types';
2
+ export type WsClientState = 'disconnected' | 'connecting' | 'connected';
3
+ export interface WsClientOptions {
4
+ token: string;
5
+ workerUrl?: string;
6
+ onUserMessage: (msg: UserMessage) => void;
7
+ onStateChange?: (state: WsClientState) => void;
8
+ logger?: {
9
+ info: (msg: string) => void;
10
+ error: (msg: string) => void;
11
+ };
12
+ }
13
+ /**
14
+ * Persistent WebSocket client that connects to the Cloudflare Workers
15
+ * relay as the "plugin" role. Auth is implicit (the token is the room
16
+ * key). Handles heartbeat and automatic reconnection with exponential
17
+ * backoff.
18
+ */
19
+ export declare class RelayWsClient {
20
+ private ws;
21
+ private opts;
22
+ private state;
23
+ private reconnectTimer;
24
+ private heartbeatTimer;
25
+ private backoffMs;
26
+ private stopped;
27
+ private static readonly MAX_BACKOFF_MS;
28
+ private static readonly HEARTBEAT_MS;
29
+ constructor(opts: WsClientOptions);
30
+ /** Start the WebSocket connection. */
31
+ start(): void;
32
+ /** Stop and close the WebSocket; no reconnect. */
33
+ stop(): void;
34
+ /** Send a message to the relay. */
35
+ send(msg: OutboundRelayMessage): void;
36
+ /** Current connection state. */
37
+ get connectionState(): WsClientState;
38
+ private connect;
39
+ private handleMessage;
40
+ private startHeartbeat;
41
+ private stopHeartbeat;
42
+ private scheduleReconnect;
43
+ private cleanup;
44
+ private setState;
45
+ private log;
46
+ }
@@ -0,0 +1,146 @@
1
+ import WebSocket from 'ws';
2
+ import { DEFAULT_WORKER_URL } from './types';
3
+ /**
4
+ * Persistent WebSocket client that connects to the Cloudflare Workers
5
+ * relay as the "plugin" role. Auth is implicit (the token is the room
6
+ * key). Handles heartbeat and automatic reconnection with exponential
7
+ * backoff.
8
+ */
9
+ export class RelayWsClient {
10
+ ws = null;
11
+ opts;
12
+ state = 'disconnected';
13
+ reconnectTimer = null;
14
+ heartbeatTimer = null;
15
+ backoffMs = 1000;
16
+ stopped = false;
17
+ static MAX_BACKOFF_MS = 30_000;
18
+ static HEARTBEAT_MS = 30_000;
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ }
22
+ /** Start the WebSocket connection. */
23
+ start() {
24
+ this.stopped = false;
25
+ this.connect();
26
+ }
27
+ /** Stop and close the WebSocket; no reconnect. */
28
+ stop() {
29
+ this.stopped = true;
30
+ this.cleanup();
31
+ this.setState('disconnected');
32
+ }
33
+ /** Send a message to the relay. */
34
+ send(msg) {
35
+ if (this.ws?.readyState === WebSocket.OPEN) {
36
+ this.ws.send(JSON.stringify(msg));
37
+ }
38
+ }
39
+ /** Current connection state. */
40
+ get connectionState() {
41
+ return this.state;
42
+ }
43
+ // ── Private ──────────────────────────────────────────────────
44
+ connect() {
45
+ if (this.stopped)
46
+ return;
47
+ this.cleanup();
48
+ this.setState('connecting');
49
+ const base = (this.opts.workerUrl ?? DEFAULT_WORKER_URL).replace(/\/$/, '');
50
+ const wsUrl = base
51
+ .replace(/^https:\/\//, 'wss://')
52
+ .replace(/^http:\/\//, 'ws://');
53
+ const url = `${wsUrl}/ws?token=${encodeURIComponent(this.opts.token)}&role=plugin`;
54
+ this.log('info', `Connecting to relay...`);
55
+ const ws = new WebSocket(url);
56
+ ws.on('open', () => {
57
+ this.log('info', 'WebSocket open');
58
+ });
59
+ ws.on('message', (data) => {
60
+ try {
61
+ const raw = typeof data === 'string' ? data : data.toString();
62
+ const msg = JSON.parse(raw);
63
+ this.handleMessage(msg);
64
+ }
65
+ catch {
66
+ this.log('error', `Failed to parse message: ${data}`);
67
+ }
68
+ });
69
+ ws.on('close', () => {
70
+ this.log('info', 'WebSocket closed');
71
+ this.setState('disconnected');
72
+ this.scheduleReconnect();
73
+ });
74
+ ws.on('error', (err) => {
75
+ this.log('error', `WebSocket error: ${err.message}`);
76
+ });
77
+ this.ws = ws;
78
+ }
79
+ handleMessage(msg) {
80
+ switch (msg.type) {
81
+ case 'auth_ok':
82
+ this.log('info', 'Connected and authenticated');
83
+ this.setState('connected');
84
+ this.backoffMs = 1000;
85
+ this.startHeartbeat();
86
+ break;
87
+ case 'auth_error':
88
+ this.log('error', `Auth failed: ${msg.reason}`);
89
+ this.setState('disconnected');
90
+ this.stopped = true;
91
+ break;
92
+ case 'user_message':
93
+ this.opts.onUserMessage(msg);
94
+ break;
95
+ case 'pong':
96
+ break;
97
+ }
98
+ }
99
+ startHeartbeat() {
100
+ this.stopHeartbeat();
101
+ this.heartbeatTimer = setInterval(() => {
102
+ if (this.ws?.readyState === WebSocket.OPEN) {
103
+ this.ws.send(JSON.stringify({ type: 'ping' }));
104
+ }
105
+ }, RelayWsClient.HEARTBEAT_MS);
106
+ }
107
+ stopHeartbeat() {
108
+ if (this.heartbeatTimer) {
109
+ clearInterval(this.heartbeatTimer);
110
+ this.heartbeatTimer = null;
111
+ }
112
+ }
113
+ scheduleReconnect() {
114
+ if (this.stopped)
115
+ return;
116
+ this.log('info', `Reconnecting in ${this.backoffMs}ms...`);
117
+ this.reconnectTimer = setTimeout(() => {
118
+ this.connect();
119
+ }, this.backoffMs);
120
+ this.backoffMs = Math.min(this.backoffMs * 2, RelayWsClient.MAX_BACKOFF_MS);
121
+ }
122
+ cleanup() {
123
+ this.stopHeartbeat();
124
+ if (this.reconnectTimer) {
125
+ clearTimeout(this.reconnectTimer);
126
+ this.reconnectTimer = null;
127
+ }
128
+ if (this.ws) {
129
+ this.ws.removeAllListeners();
130
+ if (this.ws.readyState === WebSocket.OPEN ||
131
+ this.ws.readyState === WebSocket.CONNECTING) {
132
+ this.ws.close();
133
+ }
134
+ this.ws = null;
135
+ }
136
+ }
137
+ setState(s) {
138
+ if (this.state === s)
139
+ return;
140
+ this.state = s;
141
+ this.opts.onStateChange?.(s);
142
+ }
143
+ log(level, msg) {
144
+ this.opts.logger?.[level](`[VoiceClaw WS] ${msg}`);
145
+ }
146
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "id": "voiceclaw-plugin",
3
+ "name": "VoiceClaw",
4
+ "version": "1.0.0",
5
+ "description": "VoiceClaw channel plugin — Siri & iOS/macOS voice entry for OpenClaw",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "pairingCode": {
11
+ "type": "string",
12
+ "description": "6-digit pairing code from the VoiceClaw app (only needed for initial setup)"
13
+ }
14
+ },
15
+ "required": []
16
+ },
17
+ "uiHints": {
18
+ "pairingCode": {
19
+ "label": "Pairing Code",
20
+ "placeholder": "The 6-digit code shown in the VoiceClaw app"
21
+ }
22
+ }
23
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@voiceclaw/voiceclaw-plugin",
3
+ "version": "1.0.1",
4
+ "description": "OpenClaw channel plugin for VoiceClaw — relay messages between your AI agent and the VoiceClaw iOS/macOS app via Siri",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/anthropics/voiceclaw"
11
+ },
12
+ "homepage": "https://voiceclaw.techartisan.site",
13
+ "keywords": [
14
+ "openclaw",
15
+ "openclaw-plugin",
16
+ "openclaw-channel",
17
+ "siri",
18
+ "voiceclaw",
19
+ "ios",
20
+ "macos",
21
+ "voice"
22
+ ],
23
+ "files": [
24
+ "dist",
25
+ "openclaw.plugin.json",
26
+ "README.md"
27
+ ],
28
+ "openclaw": {
29
+ "extensions": [
30
+ "dist/index.js"
31
+ ]
32
+ },
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "dev": "tsc --watch",
36
+ "typecheck": "tsc --noEmit",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "@types/ws": "^8.18.1",
42
+ "typescript": "^5.7.0"
43
+ },
44
+ "dependencies": {
45
+ "ws": "^8.18.0"
46
+ }
47
+ }