@vauxr/openclaw 2026.4.1-2.3

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.
@@ -0,0 +1,89 @@
1
+ name: Publish to npm + ClawHub
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ release:
7
+ types: [published]
8
+
9
+ jobs:
10
+ # On push to main: compute version and create a draft release
11
+ draft-release:
12
+ if: github.event_name == 'push'
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ contents: write
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Compute date-based version
21
+ id: version
22
+ env:
23
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24
+ run: |
25
+ TODAY=$(date -u +"%Y.%m.%d")
26
+
27
+ EXISTING=$(gh api repos/${{ github.repository }}/git/refs/tags \
28
+ --paginate --jq '.[].ref' 2>/dev/null \
29
+ | grep "^refs/tags/v${TODAY}\." || true)
30
+
31
+ if [ -z "$EXISTING" ]; then
32
+ N=0
33
+ else
34
+ MAX_N=$(echo "$EXISTING" | sed "s|refs/tags/v${TODAY}\.||" | sort -n | tail -1)
35
+ N=$((MAX_N + 1))
36
+ fi
37
+
38
+ VERSION="${TODAY}.${N}"
39
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
40
+ echo "Computed version: $VERSION"
41
+
42
+ - name: Create draft release
43
+ env:
44
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ run: |
46
+ gh release create "v${{ steps.version.outputs.version }}" \
47
+ --title "v${{ steps.version.outputs.version }}" \
48
+ --draft \
49
+ --notes "## What's Changed\n\n<!-- Add release notes here before publishing -->"
50
+
51
+ # On release published: publish to npm
52
+ publish:
53
+ if: github.event_name == 'release'
54
+ runs-on: ubuntu-latest
55
+ environment: Production
56
+ permissions:
57
+ contents: write
58
+
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+
62
+ - uses: actions/setup-node@v4
63
+ with:
64
+ node-version: "20"
65
+ registry-url: "https://registry.npmjs.org"
66
+
67
+ - name: Extract version from tag
68
+ id: version
69
+ run: |
70
+ VERSION="${{ github.event.release.tag_name }}"
71
+ VERSION="${VERSION#v}"
72
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
73
+
74
+ - name: Update package.json version
75
+ run: npm version ${{ steps.version.outputs.version }} --no-git-tag-version
76
+
77
+ - name: Install dependencies
78
+ run: npm ci
79
+
80
+ - name: Publish to npm
81
+ run: npm publish --access public
82
+ env:
83
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
84
+
85
+ - name: Publish to ClawHub
86
+ run: |
87
+ npm i -g clawhub
88
+ clawhub login --token ${{ secrets.CLAWHUB_TOKEN }} --no-browser
89
+ clawhub package publish ${{ github.repository }}@${{ github.event.release.tag_name }} --json
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lillian Mikus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # vauxr-openclaw
2
+
3
+ An OpenClaw channel plugin that bridges Vauxr voice devices into the OpenClaw agent loop. It connects to [Vauxr](https://github.com/vauxr-ai/vauxr) over WebSocket, dispatches inbound transcripts to the agent, and streams response deltas back for TTS playback.
4
+
5
+ It also registers three agent tools for direct device control from any session.
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ ---
10
+
11
+ ## How it works
12
+
13
+ ### Channel Plugin Bridge (recommended)
14
+
15
+ ```
16
+ Vauxr <──WS (Vauxr protocol)──> vauxr-openclaw plugin <──> OpenClaw agent loop
17
+ ```
18
+
19
+ - The plugin opens an outbound WS connection to Vauxr on startup
20
+ - Inbound transcripts from devices are dispatched into the agent loop as `vauxr:{device_id}` sessions
21
+ - Agent response deltas stream back to Vauxr in real time for TTS playback
22
+ - A `before_prompt_build` hook injects a voice-optimized system prompt for all vauxr sessions
23
+
24
+ ### Fallback: Direct Operator WS
25
+
26
+ ```
27
+ Vauxr <──WS (OpenClaw protocol)──> OpenClaw gateway
28
+ ```
29
+
30
+ If installing the plugin is undesirable, Vauxr can connect directly to the OpenClaw gateway as an operator. This still works but is limited:
31
+
32
+ - No voice system prompt injection
33
+ - No session detection for vauxr-specific behavior
34
+ - No plugin-side control over prompt or session routing
35
+
36
+ To use fallback mode, configure Vauxr with `OPENCLAW_URL` and `OPENCLAW_TOKEN` environment variables and do not install this plugin.
37
+
38
+ ---
39
+
40
+ ## Tools
41
+
42
+ | Tool | What it does |
43
+ |---|---|
44
+ | `vauxr_devices` | Lists all Vauxr devices connected to Vauxr, with their IDs, names, and connection state |
45
+ | `vauxr_announce` | Synthesizes text via Piper TTS and plays it through a device's speaker |
46
+ | `vauxr_control` | Sends a control command to a device (`set_volume`, `mute`, `unmute`, `reboot`) |
47
+
48
+ These tools use the Vauxr REST API and work in any session, not just vauxr voice sessions.
49
+
50
+ ---
51
+
52
+ ## Requirements
53
+
54
+ - OpenClaw gateway
55
+ - [Vauxr](https://github.com/vauxr-ai/vauxr) running and reachable
56
+ - At least one paired Vauxr device connected to Vauxr
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ Install from the repo directly:
63
+
64
+ ```bash
65
+ openclaw plugins install path:/path/to/vauxr-openclaw
66
+ ```
67
+
68
+ Then configure in your OpenClaw config:
69
+
70
+ ```json
71
+ {
72
+ "channels": {
73
+ "vauxr": {
74
+ "url": "http://vauxr:8765",
75
+ "token": "your-channel-token",
76
+ "voiceSystemPrompt": "You are responding to a voice device. Use plain speech only — no emojis, no markdown, no code blocks. Keep replies concise."
77
+ }
78
+ },
79
+ "plugins": {
80
+ "entries": {
81
+ "vauxr": {
82
+ "enabled": true,
83
+ "hooks": {
84
+ "allowPromptInjection": true
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ - `url` — Vauxr base URL (HTTP)
93
+ - `token` — channel token generated in the Vauxr portal
94
+ - `voiceSystemPrompt` — optional, appended to the system prompt for all vauxr sessions
95
+
96
+ The `allowPromptInjection` hook permission is required for the voice system prompt to take effect.
97
+
98
+ ---
99
+
100
+ ## Usage
101
+
102
+ Once installed, the plugin connects to Vauxr automatically. Voice turns from any device are routed through the plugin into the agent loop, and responses stream back for TTS playback.
103
+
104
+ The agent tools are available in all sessions:
105
+
106
+ **Announce something:**
107
+ > "Announce through the living room speaker that dinner is ready."
108
+
109
+ **Device control:**
110
+ > "Mute the bedroom speaker."
111
+ > "Turn the volume up on the kitchen device."
112
+
113
+ ---
114
+
115
+ ## Architecture
116
+
117
+ ```
118
+ Vauxr device (mic)
119
+
120
+ │ voice.start / audio / voice.end
121
+
122
+ Vauxr (STT: Whisper)
123
+
124
+ │ channel.transcript (WS)
125
+
126
+ vauxr-openclaw plugin
127
+
128
+ │ subagent.run(sessionKey: "vauxr:{device_id}")
129
+
130
+ OpenClaw agent loop
131
+
132
+ │ agent event deltas
133
+
134
+ vauxr-openclaw plugin
135
+
136
+ │ channel.response.delta (WS)
137
+
138
+ Vauxr (TTS: Piper)
139
+
140
+ │ 0x02 audio frames
141
+
142
+ Vauxr device (speaker)
143
+ ```
144
+
145
+ ---
146
+
147
+ ## License
148
+
149
+ Vauxr OpenClaw is licensed under the [MIT License](LICENSE).
package/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
+ import { vauxrPlugin } from "./src/channel.js";
4
+ import { VauxrAPIClient } from "./src/api-client.js";
5
+ import { registerTools } from "./src/tools.js";
6
+ import { VauxrBridge } from "./src/bridge.js";
7
+ import { DEFAULT_VOICE_SYSTEM_PROMPT } from "./src/defaults.js";
8
+
9
+ interface VauxrConfig {
10
+ url: string;
11
+ httpUrl?: string;
12
+ token?: string;
13
+ voiceSystemPrompt?: string;
14
+ }
15
+
16
+ function resolveConfig(api: OpenClawPluginApi): VauxrConfig {
17
+ if (api.pluginConfig && typeof api.pluginConfig === "object" && "url" in api.pluginConfig) {
18
+ return api.pluginConfig as unknown as VauxrConfig;
19
+ }
20
+ const cfg = api.config as Record<string, unknown>;
21
+ const channels = cfg.channels as Record<string, unknown> | undefined;
22
+ return (channels?.vauxr ?? {}) as VauxrConfig;
23
+ }
24
+
25
+ const entry = defineChannelPluginEntry({
26
+ id: "vauxr",
27
+ name: "Vauxr",
28
+ description: "Vauxr voice device channel plugin for OpenClaw",
29
+ plugin: vauxrPlugin,
30
+ registerFull(api) {
31
+ const config = resolveConfig(api);
32
+
33
+ // REST tools — use explicit httpUrl if set, otherwise derive from ws url
34
+ // (vauxr WS is on :8765, HTTP API is on :8080)
35
+ const httpBase = config.httpUrl ?? (config.url ? config.url.replace(/:8765(\/?$)/, ":8080") : "");
36
+
37
+ if (!httpBase) {
38
+ // config.url not available yet (early registration) — skip bridge/tools
39
+ return;
40
+ }
41
+
42
+ const client = new VauxrAPIClient(httpBase, config.token ?? "");
43
+ registerTools(api, client);
44
+
45
+ // WS bridge to vauxr — guard against double-registration.
46
+ // OpenClaw invokes registerFull from multiple subsystems in the same
47
+ // process; without this flag, both bridges would contend for the
48
+ // single active channel slot in vauxr and flap continuously.
49
+ const g = globalThis as { __vauxrBridgeStarted?: boolean };
50
+ if (!g.__vauxrBridgeStarted) {
51
+ g.__vauxrBridgeStarted = true;
52
+ const bridge = new VauxrBridge(api, config);
53
+ bridge.start();
54
+ }
55
+
56
+ // Voice system prompt injection for vauxr sessions
57
+ api.on("before_prompt_build", (_event, ctx) => {
58
+ if (ctx.sessionKey?.startsWith("vauxr:")) {
59
+ return {
60
+ appendSystemContext: config.voiceSystemPrompt ?? DEFAULT_VOICE_SYSTEM_PROMPT,
61
+ };
62
+ }
63
+ return undefined;
64
+ });
65
+ },
66
+ });
67
+
68
+ export default entry;
@@ -0,0 +1,57 @@
1
+ {
2
+ "id": "vauxr",
3
+ "kind": "channel",
4
+ "channels": ["vauxr"],
5
+ "name": "Vauxr",
6
+ "description": "Vauxr voice device channel plugin for OpenClaw",
7
+ "channelConfigs": {
8
+ "vauxr": {
9
+ "label": "Vauxr",
10
+ "description": "Vauxr voice device channel",
11
+ "schema": {
12
+ "type": "object",
13
+ "additionalProperties": false,
14
+ "required": ["url", "token"],
15
+ "properties": {
16
+ "url": {
17
+ "type": "string",
18
+ "description": "Vauxr base URL (e.g. http://192.168.1.100:8765)"
19
+ },
20
+ "token": {
21
+ "type": "string",
22
+ "description": "Channel token issued by Vauxr"
23
+ },
24
+ "voiceSystemPrompt": {
25
+ "type": "string",
26
+ "description": "System prompt appended to vauxr voice sessions"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ },
32
+ "configSchema": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "vauxr": {
37
+ "type": "object",
38
+ "required": ["url"],
39
+ "additionalProperties": false,
40
+ "properties": {
41
+ "url": {
42
+ "type": "string",
43
+ "description": "Vauxr base URL (e.g. http://vauxr:8765)"
44
+ },
45
+ "token": {
46
+ "type": "string",
47
+ "description": "Operator token for Vauxr API auth"
48
+ },
49
+ "voiceSystemPrompt": {
50
+ "type": "string",
51
+ "description": "Text appended to the system prompt for all vauxr sessions. Defaults to a voice-optimized instruction set."
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@vauxr/openclaw",
3
+ "version": "2026.4.1-2.3",
4
+ "type": "module",
5
+ "main": "./index.ts",
6
+ "openclaw": {
7
+ "extensions": [
8
+ "./index.ts",
9
+ "./setup-entry.ts"
10
+ ],
11
+ "compat": {
12
+ "pluginApi": ">=2026.4.2",
13
+ "minGatewayVersion": "2026.4.2"
14
+ },
15
+ "build": {
16
+ "openclawVersion": "2026.4.2",
17
+ "pluginSdkVersion": "2026.4.2"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "@sinclair/typebox": "^0.32.0",
22
+ "ws": "^8.18.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/ws": "^8.5.0",
26
+ "openclaw": "^2026.4.2",
27
+ "typescript": "^5.0.0"
28
+ }
29
+ }
package/setup-entry.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { vauxrPlugin } from "./src/channel.js";
3
+
4
+ export default defineSetupPluginEntry(vauxrPlugin);
@@ -0,0 +1,76 @@
1
+ export interface Device {
2
+ id: string;
3
+ name: string;
4
+ state: "idle" | "listening" | "processing" | "speaking" | "offline";
5
+ lastSeen: string;
6
+ }
7
+
8
+ export class VauxrAPIClient {
9
+ constructor(
10
+ private baseUrl: string,
11
+ private token: string,
12
+ ) {}
13
+
14
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
15
+ const url = `${this.baseUrl}${path}`;
16
+ const headers: Record<string, string> = {
17
+ Authorization: `Bearer ${this.token}`,
18
+ "Content-Type": "application/json",
19
+ };
20
+
21
+ const res = await fetch(url, {
22
+ method,
23
+ headers,
24
+ body: body !== undefined ? JSON.stringify(body) : undefined,
25
+ });
26
+
27
+ if (!res.ok) {
28
+ let message = `HTTP ${res.status}`;
29
+ try {
30
+ const errorBody = (await res.json()) as Record<string, unknown>;
31
+ const detail = errorBody.error ?? errorBody.message;
32
+ if (typeof detail === "string") {
33
+ message = detail;
34
+ }
35
+ } catch {
36
+ // response body wasn't JSON — keep generic message
37
+ }
38
+
39
+ if (res.status === 401) {
40
+ throw new Error(`Unauthorized: ${message}`);
41
+ }
42
+ if (res.status === 404) {
43
+ throw new Error(`Device not found: ${message}`);
44
+ }
45
+ if (res.status === 409) {
46
+ throw new Error(`Device busy: ${message}`);
47
+ }
48
+ throw new Error(message);
49
+ }
50
+
51
+ const text = await res.text();
52
+ if (!text) return undefined as T;
53
+ return JSON.parse(text) as T;
54
+ }
55
+
56
+ async listDevices(): Promise<Device[]> {
57
+ return this.request<Device[]>("GET", "/api/devices");
58
+ }
59
+
60
+ async announce(deviceId: string, text: string): Promise<void> {
61
+ await this.request<void>("POST", `/api/devices/${encodeURIComponent(deviceId)}/announce`, {
62
+ text,
63
+ });
64
+ }
65
+
66
+ async command(
67
+ deviceId: string,
68
+ command: string,
69
+ params?: Record<string, unknown>,
70
+ ): Promise<void> {
71
+ await this.request<void>("POST", `/api/devices/${encodeURIComponent(deviceId)}/command`, {
72
+ command,
73
+ params,
74
+ });
75
+ }
76
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,237 @@
1
+ import WebSocket from "ws";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
3
+
4
+ /** Vauxr protocol frames sent by vauxr to the channel plugin */
5
+ interface VauxrInboundFrame {
6
+ type: "channel.transcript" | "channel.device_state" | "channel.ready" | "error";
7
+ deviceId?: string;
8
+ text?: string;
9
+ state?: string;
10
+ name?: string;
11
+ code?: string;
12
+ message?: string;
13
+ }
14
+
15
+ /** Vauxr protocol frames sent by the channel plugin to vauxr */
16
+ type VauxrOutboundFrame =
17
+ | { type: "channel.auth"; token: string }
18
+ | { type: "channel.response.delta"; deviceId: string; runId: string; text: string }
19
+ | { type: "channel.response.end"; deviceId: string; runId: string }
20
+ | { type: "channel.response.error"; deviceId: string; runId: string; message: string };
21
+
22
+ interface VauxrBridgeConfig {
23
+ url: string;
24
+ token?: string;
25
+ voiceSystemPrompt?: string;
26
+ }
27
+
28
+ const INITIAL_RECONNECT_MS = 1000;
29
+ const MAX_RECONNECT_MS = 30000;
30
+
31
+ export class VauxrBridge {
32
+ private ws: WebSocket | null = null;
33
+ private reconnectMs = INITIAL_RECONNECT_MS;
34
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
35
+ private unsubscribeEvents: (() => void) | null = null;
36
+ private activeRuns = new Map<string, string>(); // SDK runId → deviceId
37
+ private runIdMap = new Map<string, string>(); // SDK runId → protocol runId
38
+ private wsUrl: string;
39
+
40
+ constructor(
41
+ private api: OpenClawPluginApi,
42
+ private config: VauxrBridgeConfig,
43
+ ) {
44
+ // Derive WS URL from HTTP base URL
45
+ const base = config.url.replace(/\/$/, "");
46
+ this.wsUrl = base.replace(/^http/, "ws") + "/channel";
47
+ }
48
+
49
+ start(): void {
50
+ this.connect();
51
+ this.subscribeAgentEvents();
52
+ }
53
+
54
+ stop(): void {
55
+ if (this.reconnectTimer) {
56
+ clearTimeout(this.reconnectTimer);
57
+ this.reconnectTimer = null;
58
+ }
59
+ if (this.unsubscribeEvents) {
60
+ this.unsubscribeEvents();
61
+ this.unsubscribeEvents = null;
62
+ }
63
+ if (this.ws) {
64
+ this.ws.close();
65
+ this.ws = null;
66
+ }
67
+ }
68
+
69
+ private connect(): void {
70
+ this.api.logger.info(`[vauxr-bridge] Connecting to vauxr: ${this.wsUrl}`);
71
+
72
+ const ws = new WebSocket(this.wsUrl);
73
+ this.ws = ws;
74
+
75
+ ws.on("open", () => {
76
+ this.api.logger.info("[vauxr-bridge] Connected to vauxr");
77
+ this.reconnectMs = INITIAL_RECONNECT_MS;
78
+
79
+ // Authenticate with channel token
80
+ if (this.config.token) {
81
+ this.send({ type: "channel.auth", token: this.config.token });
82
+ }
83
+ });
84
+
85
+ ws.on("message", (data) => {
86
+ try {
87
+ const frame = JSON.parse(String(data)) as VauxrInboundFrame;
88
+ this.handleFrame(frame);
89
+ } catch (err) {
90
+ this.api.logger.warn(`[vauxr-bridge] Failed to parse inbound frame: ${String(err)}`);
91
+ }
92
+ });
93
+
94
+ ws.on("close", () => {
95
+ this.api.logger.info("[vauxr-bridge] Disconnected from vauxr");
96
+ this.ws = null;
97
+ this.scheduleReconnect();
98
+ });
99
+
100
+ ws.on("error", (err) => {
101
+ this.api.logger.warn(`[vauxr-bridge] WS error: ${String(err)}`);
102
+ // 'close' event will fire after this — reconnect handled there
103
+ });
104
+ }
105
+
106
+ private scheduleReconnect(): void {
107
+ if (this.reconnectTimer) return;
108
+ this.api.logger.info(
109
+ `[vauxr-bridge] Reconnecting in ${this.reconnectMs}ms`,
110
+ );
111
+ this.reconnectTimer = setTimeout(() => {
112
+ this.reconnectTimer = null;
113
+ this.connect();
114
+ }, this.reconnectMs);
115
+ this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
116
+ }
117
+
118
+ private handleFrame(frame: VauxrInboundFrame): void {
119
+ switch (frame.type) {
120
+ case "channel.transcript":
121
+ if (frame.deviceId && frame.text) {
122
+ void this.dispatchTranscript(frame.deviceId, frame.text);
123
+ }
124
+ break;
125
+ case "channel.device_state":
126
+ this.api.logger.info(
127
+ `[vauxr-bridge] Device ${frame.deviceId ?? "unknown"}: ${frame.state ?? "unknown"}`,
128
+ );
129
+ break;
130
+ case "channel.ready":
131
+ this.api.logger.info("[vauxr-bridge] Channel authenticated");
132
+ break;
133
+ case "error":
134
+ this.api.logger.warn(
135
+ `[vauxr-bridge] Error from vauxr: ${frame.code ?? "UNKNOWN"} — ${frame.message ?? "no details"}`,
136
+ );
137
+ break;
138
+ default:
139
+ this.api.logger.warn(
140
+ `[vauxr-bridge] Unknown frame type: ${String((frame as unknown as Record<string, unknown>).type)}`,
141
+ );
142
+ }
143
+ }
144
+
145
+ private async dispatchTranscript(deviceId: string, text: string): Promise<void> {
146
+ const sessionKey = `vauxr:${deviceId}`;
147
+ // Generate a protocol-level runId (sent to vauxr in response frames)
148
+ const protocolRunId = crypto.randomUUID();
149
+ this.api.logger.info(
150
+ `[vauxr-bridge] Dispatching transcript for ${sessionKey} (runId=${protocolRunId}): "${text}"`,
151
+ );
152
+
153
+ try {
154
+ const result = await this.api.runtime.subagent.run({
155
+ sessionKey,
156
+ message: text,
157
+ idempotencyKey: protocolRunId,
158
+ // Inject voice-formatting instructions so the model doesn't emit
159
+ // markdown, emojis, or lists — responses are spoken aloud by TTS.
160
+ // Uses the SDK's extraSystemPrompt field; omitted when not configured.
161
+ ...(this.config.voiceSystemPrompt
162
+ ? { extraSystemPrompt: this.config.voiceSystemPrompt }
163
+ : {}),
164
+ });
165
+ this.activeRuns.set(result.runId, deviceId);
166
+ this.runIdMap.set(result.runId, protocolRunId);
167
+ } catch (err) {
168
+ this.api.logger.warn(
169
+ `[vauxr-bridge] Failed to dispatch transcript for ${sessionKey}: ${String(err)}`,
170
+ );
171
+ this.send({
172
+ type: "channel.response.error",
173
+ deviceId,
174
+ runId: protocolRunId,
175
+ message: String(err),
176
+ });
177
+ }
178
+ }
179
+
180
+ private subscribeAgentEvents(): void {
181
+ this.unsubscribeEvents = this.api.runtime.events.onAgentEvent((event) => {
182
+ const deviceId = this.activeRuns.get(event.runId);
183
+ if (!deviceId) return; // Not a vauxr run
184
+
185
+ const runId = this.runIdMap.get(event.runId) ?? event.runId;
186
+
187
+ if (event.stream === "assistant") {
188
+ // data.delta is the incremental chunk for streaming sessions;
189
+ // data.text is the accumulated text (or full text for single-shot).
190
+ // Prefer delta when present, fall back to text.
191
+ const chunk =
192
+ (typeof event.data["delta"] === "string" && event.data["delta"]) ||
193
+ (typeof event.data["text"] === "string" && event.data["text"]) ||
194
+ null;
195
+ if (chunk) {
196
+ this.send({
197
+ type: "channel.response.delta",
198
+ deviceId,
199
+ runId,
200
+ text: chunk,
201
+ });
202
+ }
203
+ }
204
+
205
+ // Clean up when run ends
206
+ if (event.stream === "lifecycle" && event.data["phase"] === "end") {
207
+ this.send({
208
+ type: "channel.response.end",
209
+ deviceId,
210
+ runId,
211
+ });
212
+ this.activeRuns.delete(event.runId);
213
+ this.runIdMap.delete(event.runId);
214
+ }
215
+
216
+ if (event.stream === "error") {
217
+ this.api.logger.warn(
218
+ `[vauxr-bridge] Agent error for device ${deviceId}: ${JSON.stringify(event.data)}`,
219
+ );
220
+ this.send({
221
+ type: "channel.response.error",
222
+ deviceId,
223
+ runId,
224
+ message: String(event.data["message"] ?? "Agent error"),
225
+ });
226
+ this.activeRuns.delete(event.runId);
227
+ this.runIdMap.delete(event.runId);
228
+ }
229
+ });
230
+ }
231
+
232
+ private send(frame: VauxrOutboundFrame): void {
233
+ if (this.ws?.readyState === WebSocket.OPEN) {
234
+ this.ws.send(JSON.stringify(frame));
235
+ }
236
+ }
237
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { createChatChannelPlugin, createChannelPluginBase } from "openclaw/plugin-sdk/core";
2
+ import { DEFAULT_VOICE_SYSTEM_PROMPT } from "./defaults.js";
3
+ import { createTopLevelChannelConfigBase } from "openclaw/plugin-sdk/channel-config-helpers";
4
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
5
+
6
+ export interface VauxrAccount {
7
+ accountId?: string | null;
8
+ }
9
+
10
+ // Type assertion needed: createChannelPluginBase marks capabilities as Partial
11
+ // in its return type, but createChatChannelPlugin requires it non-optional.
12
+ // We always provide capabilities, so the assertion is safe.
13
+ export const vauxrPlugin = createChatChannelPlugin<VauxrAccount>({
14
+ base: createChannelPluginBase<VauxrAccount>({
15
+ id: "vauxr",
16
+ meta: { label: "Vauxr" },
17
+ capabilities: {
18
+ chatTypes: ["direct"],
19
+ },
20
+ config: createTopLevelChannelConfigBase<VauxrAccount>({
21
+ sectionKey: "vauxr",
22
+ resolveAccount: (cfg) => {
23
+ const section = resolveSection(cfg);
24
+ const url = section?.url;
25
+ return {
26
+ accountId: url ?? "default",
27
+ // running/connected drive UI status indicators
28
+ ...(url ? { running: true, connected: true } : {}),
29
+ };
30
+ },
31
+ // Single-account channel — listAccountIds returns either the single
32
+ // resolved id or an empty array if the channel isn't configured.
33
+ listAccountIds: (cfg) => {
34
+ const section = resolveSection(cfg);
35
+ return section?.url ? [section.url] : [];
36
+ },
37
+ defaultAccountId: (cfg) => resolveSection(cfg)?.url ?? "default",
38
+ }),
39
+ setup: {
40
+ resolveAccountId({ cfg }) {
41
+ const section = resolveSection(cfg);
42
+ return section?.url ?? "default";
43
+ },
44
+ applyAccountConfig({ cfg, input }) {
45
+ const updated = structuredClone(cfg) as Record<string, unknown>;
46
+ const channels = (updated.channels ?? {}) as Record<string, unknown>;
47
+ channels.vauxr = {
48
+ // Seed default voice system prompt so it's populated on first install.
49
+ // Existing value (if any) takes precedence via spread order.
50
+ voiceSystemPrompt: DEFAULT_VOICE_SYSTEM_PROMPT,
51
+ ...((channels.vauxr ?? {}) as Record<string, unknown>),
52
+ ...(input as Record<string, unknown>),
53
+ };
54
+ updated.channels = channels;
55
+ return updated as OpenClawConfig;
56
+ },
57
+ },
58
+ }) as Parameters<typeof createChatChannelPlugin<VauxrAccount>>[0]["base"],
59
+ // No security/pairing — vauxr devices are trusted local hardware
60
+ outbound: {
61
+ // Outbound responses are delivered via the WS bridge, not the outbound adapter
62
+ // This stub satisfies the ChannelPlugin interface
63
+ base: {
64
+ deliveryMode: "direct",
65
+ },
66
+ attachedResults: {
67
+ channel: "vauxr",
68
+ sendText: async () => ({ messageId: "bridge" }),
69
+ },
70
+ },
71
+ });
72
+
73
+ // gateway.startAccount is required for OpenClaw to mark this channel as
74
+ // "running" and "configured" in the UI. The actual bridge lifecycle is
75
+ // managed by registerFull in index.ts (which has access to the full plugin
76
+ // API). This stub holds the channel in running state until the gateway stops.
77
+ // isConfigured: tells OpenClaw the channel is configured when url is set.
78
+ vauxrPlugin.config.isConfigured = (_account: unknown, cfg: OpenClawConfig) => {
79
+ return Boolean(resolveSection(cfg)?.url);
80
+ };
81
+
82
+ vauxrPlugin.gateway = {
83
+ startAccount: async (ctx: { abortSignal: AbortSignal }) => {
84
+ await new Promise<void>((resolve) => {
85
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
86
+ });
87
+ },
88
+ };
89
+
90
+ interface VauxrSection {
91
+ url?: string;
92
+ token?: string;
93
+ voiceSystemPrompt?: string;
94
+ }
95
+
96
+ function resolveSection(cfg: OpenClawConfig): VauxrSection | undefined {
97
+ const raw = cfg as Record<string, unknown>;
98
+ const channelsCfg = (raw.channels as Record<string, unknown> | undefined)?.vauxr as
99
+ | VauxrSection
100
+ | undefined;
101
+ const pluginsCfg = (
102
+ (raw.plugins as Record<string, unknown> | undefined)?.entries as
103
+ | Record<string, unknown>
104
+ | undefined
105
+ )?.vauxr as { config?: VauxrSection } | undefined;
106
+ return channelsCfg ?? pluginsCfg?.config;
107
+ }
@@ -0,0 +1,2 @@
1
+ export const DEFAULT_VOICE_SYSTEM_PROMPT =
2
+ "You are responding to a voice device. Use plain speech only — no emojis, no markdown, no code blocks. Keep replies concise.";
package/src/tools.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
3
+ import type { VauxrAPIClient, Device } from "./api-client.js";
4
+
5
+ function formatDeviceList(devices: Device[]): string {
6
+ if (devices.length === 0) return "No devices connected.";
7
+ return devices
8
+ .map((d) => `• ${d.name} (id: ${d.id}) — ${d.state}, last seen ${d.lastSeen}`)
9
+ .join("\n");
10
+ }
11
+
12
+ export function registerTools(api: OpenClawPluginApi, client: VauxrAPIClient): void {
13
+ api.registerTool(
14
+ {
15
+ name: "vauxr_devices",
16
+ label: "Vauxr Devices",
17
+ description:
18
+ "List Vauxr voice devices currently connected to Vauxr, with their IDs, names, and connection state. Call this first if you don't know which device to target.",
19
+ parameters: Type.Object({}),
20
+ async execute() {
21
+ const devices = await client.listDevices();
22
+ return {
23
+ content: [{ type: "text" as const, text: formatDeviceList(devices) }],
24
+ details: { devices },
25
+ };
26
+ },
27
+ },
28
+ { optional: false },
29
+ );
30
+
31
+ api.registerTool(
32
+ {
33
+ name: "vauxr_announce",
34
+ label: "Vauxr Announce",
35
+ description:
36
+ "Announce a spoken message through a Vauxr voice device. The text will be synthesized to speech and played through the device's speaker. Use `vauxr_devices` first if you don't know the device ID.",
37
+ parameters: Type.Object({
38
+ device_id: Type.String({ description: "ID of the device to speak through" }),
39
+ text: Type.String({
40
+ description:
41
+ "Text to speak aloud — keep it concise, plain sentences only, no markdown or emojis",
42
+ }),
43
+ }),
44
+ async execute(_id, params) {
45
+ await client.announce(params.device_id, params.text);
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text" as const,
50
+ text: `Announced on device ${params.device_id}: "${params.text}"`,
51
+ },
52
+ ],
53
+ details: {},
54
+ };
55
+ },
56
+ },
57
+ { optional: false },
58
+ );
59
+
60
+ api.registerTool(
61
+ {
62
+ name: "vauxr_control",
63
+ label: "Vauxr Control",
64
+ description:
65
+ "Send a control command to a Vauxr voice device (set volume, mute, unmute, or reboot).",
66
+ parameters: Type.Object({
67
+ device_id: Type.String({ description: "ID of the device to control" }),
68
+ command: Type.Union(
69
+ [
70
+ Type.Literal("set_volume"),
71
+ Type.Literal("mute"),
72
+ Type.Literal("unmute"),
73
+ Type.Literal("reboot"),
74
+ ],
75
+ { description: "The control command to send" },
76
+ ),
77
+ volume: Type.Optional(
78
+ Type.Number({
79
+ description: "Volume level 0–100, required when command is set_volume",
80
+ minimum: 0,
81
+ maximum: 100,
82
+ }),
83
+ ),
84
+ }),
85
+ async execute(_id, params) {
86
+ const cmdParams: Record<string, unknown> | undefined =
87
+ params.command === "set_volume" ? { volume: params.volume } : undefined;
88
+ await client.command(params.device_id, params.command, cmdParams);
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text" as const,
93
+ text: `Sent ${params.command} to device ${params.device_id}${params.command === "set_volume" ? ` (volume: ${params.volume})` : ""}`,
94
+ },
95
+ ],
96
+ details: {},
97
+ };
98
+ },
99
+ },
100
+ { optional: false },
101
+ );
102
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": ".",
11
+ "declaration": false
12
+ },
13
+ "include": ["index.ts", "setup-entry.ts", "src/**/*.ts"]
14
+ }