@vauxr/openclaw 2026.4.12-0 → 2026.5.24-2

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 CHANGED
@@ -59,7 +59,13 @@ These tools use the Vauxr REST API and work in any session, not just vauxr voice
59
59
 
60
60
  ## Installation
61
61
 
62
- Install from the repo directly:
62
+ Install from ClawHub 🦞
63
+
64
+ ```bash
65
+ openclaw plugins install clawhub:@vauxr/openclaw
66
+ ```
67
+
68
+ Or install from the repo directly:
63
69
 
64
70
  ```bash
65
71
  openclaw plugins install path:/path/to/vauxr-openclaw
@@ -90,11 +96,32 @@ Then configure in your OpenClaw config:
90
96
  ```
91
97
 
92
98
  - `url` — Vauxr base URL (HTTP)
93
- - `token` — channel token generated in the Vauxr portal
99
+ - `token` — channel token generated in the Vauxr web client
94
100
  - `voiceSystemPrompt` — optional, appended to the system prompt for all vauxr sessions
101
+ - `alsoAllow` — optional, extra tools to grant vauxr-originated agent runs (see below)
102
+ - `targetAgent` — required if `alsoAllow` is set; the id of the agent that handles vauxr sessions
95
103
 
96
104
  The `allowPromptInjection` hook permission is required for the voice system prompt to take effect.
97
105
 
106
+ ### Granting broader tools to vauxr sessions
107
+
108
+ OpenClaw's runtime treats the internal `webchat` channel more permissively than third-party channels: tools like `gateway` and `nodes` are stripped from vauxr-originated runs even when the agent's profile would otherwise allow them. To restore those tools on vauxr sessions, set `alsoAllow` and `targetAgent`:
109
+
110
+ ```json
111
+ {
112
+ "channels": {
113
+ "vauxr": {
114
+ "url": "http://vauxr:8765",
115
+ "token": "your-channel-token",
116
+ "alsoAllow": ["gateway", "nodes"],
117
+ "targetAgent": "nova-cloud"
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ On configure, the plugin writes a `channel:vauxr:*` entry into `agents.list[id=targetAgent].tools.toolsBySender`. The expansion is scoped to vauxr-originated runs only — other channels are unaffected. Be deliberate about what you grant: `gateway` lets the model restart OpenClaw, `nodes` lets it invoke commands on connected hardware nodes.
124
+
98
125
  ---
99
126
 
100
127
  ## Usage
@@ -147,3 +174,4 @@ Vauxr device (speaker)
147
174
  ## License
148
175
 
149
176
  Vauxr OpenClaw is licensed under the [MIT License](LICENSE).
177
+
package/dist/index.js ADDED
@@ -0,0 +1,55 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { vauxrPlugin } from "./src/channel.js";
3
+ import { VauxrAPIClient } from "./src/api-client.js";
4
+ import { registerTools } from "./src/tools.js";
5
+ import { VauxrBridge } from "./src/bridge.js";
6
+ import { DEFAULT_VOICE_SYSTEM_PROMPT } from "./src/defaults.js";
7
+ function resolveConfig(api) {
8
+ if (api.pluginConfig && typeof api.pluginConfig === "object" && "url" in api.pluginConfig) {
9
+ return api.pluginConfig;
10
+ }
11
+ const cfg = api.config;
12
+ const channels = cfg.channels;
13
+ return (channels?.vauxr ?? {});
14
+ }
15
+ const entry = defineChannelPluginEntry({
16
+ id: "vauxr",
17
+ name: "Vauxr",
18
+ description: "Vauxr voice device channel plugin for OpenClaw",
19
+ plugin: vauxrPlugin,
20
+ registerFull(api) {
21
+ const config = resolveConfig(api);
22
+ // REST tools — use explicit httpUrl if set, otherwise derive from ws url
23
+ // (vauxr WS is on :8765, HTTP API is on :8080)
24
+ const httpBase = config.httpUrl ?? (config.url ? config.url.replace(/:8765(\/?$)/, ":8080") : "");
25
+ if (!httpBase) {
26
+ // config.url not available yet (early registration) — skip bridge/tools
27
+ return;
28
+ }
29
+ const client = new VauxrAPIClient(httpBase, config.token ?? "");
30
+ registerTools(api, client);
31
+ // WS bridge to vauxr — guard against double-registration.
32
+ // OpenClaw invokes registerFull from multiple subsystems in the same
33
+ // process; without this flag, both bridges would contend for the
34
+ // single active channel slot in vauxr and flap continuously.
35
+ const g = globalThis;
36
+ if (!g.__vauxrBridgeStarted) {
37
+ g.__vauxrBridgeStarted = true;
38
+ const bridge = new VauxrBridge(api, config);
39
+ bridge.start();
40
+ }
41
+ // Voice system prompt injection for vauxr sessions. Match both the bare
42
+ // form (`vauxr:<deviceId>`) used by the old subagent.run path and the
43
+ // fully-prefixed form (`agent:<agentId>:vauxr:<deviceId>`) used by the
44
+ // current channel.turn.run path. Either form means it's a vauxr turn.
45
+ api.on("before_prompt_build", (_event, ctx) => {
46
+ if (ctx.sessionKey && /(?:^|:)vauxr:/.test(ctx.sessionKey)) {
47
+ return {
48
+ appendSystemContext: config.voiceSystemPrompt ?? DEFAULT_VOICE_SYSTEM_PROMPT,
49
+ };
50
+ }
51
+ return undefined;
52
+ });
53
+ },
54
+ });
55
+ export default entry;
@@ -1,4 +1,3 @@
1
1
  import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
2
2
  import { vauxrPlugin } from "./src/channel.js";
3
-
4
3
  export default defineSetupPluginEntry(vauxrPlugin);
@@ -0,0 +1,61 @@
1
+ export class VauxrAPIClient {
2
+ baseUrl;
3
+ token;
4
+ constructor(baseUrl, token) {
5
+ this.baseUrl = baseUrl;
6
+ this.token = token;
7
+ }
8
+ async request(method, path, body) {
9
+ const url = `${this.baseUrl}${path}`;
10
+ const headers = {
11
+ Authorization: `Bearer ${this.token}`,
12
+ "Content-Type": "application/json",
13
+ };
14
+ const res = await fetch(url, {
15
+ method,
16
+ headers,
17
+ body: body !== undefined ? JSON.stringify(body) : undefined,
18
+ });
19
+ if (!res.ok) {
20
+ let message = `HTTP ${res.status}`;
21
+ try {
22
+ const errorBody = (await res.json());
23
+ const detail = errorBody.error ?? errorBody.message;
24
+ if (typeof detail === "string") {
25
+ message = detail;
26
+ }
27
+ }
28
+ catch {
29
+ // response body wasn't JSON — keep generic message
30
+ }
31
+ if (res.status === 401) {
32
+ throw new Error(`Unauthorized: ${message}`);
33
+ }
34
+ if (res.status === 404) {
35
+ throw new Error(`Device not found: ${message}`);
36
+ }
37
+ if (res.status === 409) {
38
+ throw new Error(`Device busy: ${message}`);
39
+ }
40
+ throw new Error(message);
41
+ }
42
+ const text = await res.text();
43
+ if (!text)
44
+ return undefined;
45
+ return JSON.parse(text);
46
+ }
47
+ async listDevices() {
48
+ return this.request("GET", "/api/devices");
49
+ }
50
+ async announce(deviceId, text) {
51
+ await this.request("POST", `/api/devices/${encodeURIComponent(deviceId)}/announce`, {
52
+ text,
53
+ });
54
+ }
55
+ async command(deviceId, command, params) {
56
+ await this.request("POST", `/api/devices/${encodeURIComponent(deviceId)}/command`, {
57
+ command,
58
+ params,
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,334 @@
1
+ import WebSocket from "ws";
2
+ const INITIAL_RECONNECT_MS = 1000;
3
+ const MAX_RECONNECT_MS = 30000;
4
+ export class VauxrBridge {
5
+ api;
6
+ config;
7
+ ws = null;
8
+ reconnectMs = INITIAL_RECONNECT_MS;
9
+ reconnectTimer = null;
10
+ unsubscribeEvents = null;
11
+ // Inflight turns keyed by deviceId. channel.turn.run doesn't surface an SDK
12
+ // runId to the caller (unlike the old subagent.run path), so dispatch-time
13
+ // bookkeeping is keyed by deviceId; we then latch onto the SDK runId on the
14
+ // first event that arrives carrying a sessionKey (typically lifecycle.start)
15
+ // and use that runId for all subsequent events from the same run. Most
16
+ // event streams (assistant/tool/item) do NOT carry sessionKey — only the
17
+ // lifecycle stream does — so runId-based correlation is load-bearing once
18
+ // we've latched. One inflight turn per device at a time.
19
+ activeRuns = new Map(); // deviceId → turn
20
+ runIdToTurn = new Map(); // sdkRunId → turn
21
+ // Per-device silent-reply sentinel state. "NO_REPLY" often arrives split
22
+ // across streaming deltas (e.g. "NO" then "_REPLY"), so we buffer until
23
+ // the accumulated text either matches the sentinel (suppress the whole
24
+ // run) or diverges (flush and pass through).
25
+ sentinelBuffer = new Map(); // deviceId → held delta text
26
+ sentinelMode = new Map();
27
+ wsUrl;
28
+ constructor(api, config) {
29
+ this.api = api;
30
+ this.config = config;
31
+ // Derive WS URL from HTTP base URL
32
+ const base = config.url.replace(/\/$/, "");
33
+ this.wsUrl = base.replace(/^http/, "ws") + "/channel";
34
+ }
35
+ start() {
36
+ this.connect();
37
+ this.subscribeAgentEvents();
38
+ }
39
+ stop() {
40
+ if (this.reconnectTimer) {
41
+ clearTimeout(this.reconnectTimer);
42
+ this.reconnectTimer = null;
43
+ }
44
+ if (this.unsubscribeEvents) {
45
+ this.unsubscribeEvents();
46
+ this.unsubscribeEvents = null;
47
+ }
48
+ if (this.ws) {
49
+ this.ws.close();
50
+ this.ws = null;
51
+ }
52
+ }
53
+ connect() {
54
+ this.api.logger.info(`[vauxr-bridge] Connecting to vauxr: ${this.wsUrl}`);
55
+ const ws = new WebSocket(this.wsUrl);
56
+ this.ws = ws;
57
+ ws.on("open", () => {
58
+ this.api.logger.info("[vauxr-bridge] Connected to vauxr");
59
+ this.reconnectMs = INITIAL_RECONNECT_MS;
60
+ // Authenticate with channel token
61
+ if (this.config.token) {
62
+ this.send({ type: "channel.auth", token: this.config.token });
63
+ }
64
+ });
65
+ ws.on("message", (data) => {
66
+ try {
67
+ const frame = JSON.parse(String(data));
68
+ this.handleFrame(frame);
69
+ }
70
+ catch (err) {
71
+ this.api.logger.warn(`[vauxr-bridge] Failed to parse inbound frame: ${String(err)}`);
72
+ }
73
+ });
74
+ ws.on("close", () => {
75
+ this.api.logger.info("[vauxr-bridge] Disconnected from vauxr");
76
+ this.ws = null;
77
+ this.scheduleReconnect();
78
+ });
79
+ ws.on("error", (err) => {
80
+ this.api.logger.warn(`[vauxr-bridge] WS error: ${String(err)}`);
81
+ // 'close' event will fire after this — reconnect handled there
82
+ });
83
+ }
84
+ scheduleReconnect() {
85
+ if (this.reconnectTimer)
86
+ return;
87
+ this.api.logger.info(`[vauxr-bridge] Reconnecting in ${this.reconnectMs}ms`);
88
+ this.reconnectTimer = setTimeout(() => {
89
+ this.reconnectTimer = null;
90
+ this.connect();
91
+ }, this.reconnectMs);
92
+ this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
93
+ }
94
+ handleFrame(frame) {
95
+ switch (frame.type) {
96
+ case "channel.transcript":
97
+ if (frame.deviceId && frame.text) {
98
+ void this.dispatchTranscript(frame.deviceId, frame.text);
99
+ }
100
+ break;
101
+ case "channel.device_state":
102
+ this.api.logger.info(`[vauxr-bridge] Device ${frame.deviceId ?? "unknown"}: ${frame.state ?? "unknown"}`);
103
+ break;
104
+ case "channel.ready":
105
+ this.api.logger.info("[vauxr-bridge] Channel authenticated");
106
+ break;
107
+ case "error":
108
+ this.api.logger.warn(`[vauxr-bridge] Error from vauxr: ${frame.code ?? "UNKNOWN"} — ${frame.message ?? "no details"}`);
109
+ break;
110
+ default:
111
+ this.api.logger.warn(`[vauxr-bridge] Unknown frame type: ${String(frame.type)}`);
112
+ }
113
+ }
114
+ async dispatchTranscript(deviceId, text) {
115
+ const cfg = this.api.config;
116
+ // Construct the sessionKey in the same form the old subagent.run path
117
+ // ended up producing after openclaw's internal normalization
118
+ // (`agent:<agentId>:vauxr:<deviceId>`). channel.turn.run does NOT apply
119
+ // that same normalization to routeSessionKey — it stores under whatever
120
+ // string we pass — so we have to build the full form ourselves to
121
+ // preserve session continuity with prior turns / restarts.
122
+ const agentId = resolveTargetAgentId(cfg);
123
+ const sessionKey = `agent:${agentId}:vauxr:${deviceId}`;
124
+ // Protocol-level runId sent to vauxr-ws in response frames so it can
125
+ // correlate delta/end/error chunks back to this transcript.
126
+ const protocolRunId = crypto.randomUUID();
127
+ const turnId = `vauxr-${deviceId}-${Date.now()}`;
128
+ this.api.logger.info(`[vauxr-bridge] Dispatching transcript for ${sessionKey} (runId=${protocolRunId}): "${text}"`);
129
+ // Register before dispatch so onAgentEvent can correlate any event the
130
+ // agent runtime emits for this turn back to the originating device.
131
+ this.activeRuns.set(deviceId, { deviceId, protocolRunId });
132
+ const storePath = this.api.runtime.channel.session.resolveStorePath(cfg.session?.store);
133
+ // Minimal inbound context. Voice channels don't carry replies, media,
134
+ // mentions, forwards, etc. — most MsgContext fields stay undefined.
135
+ const ctxPayload = {
136
+ Body: text,
137
+ BodyForAgent: text,
138
+ From: deviceId,
139
+ SenderId: deviceId,
140
+ SenderName: deviceId,
141
+ SessionKey: sessionKey,
142
+ Provider: "vauxr",
143
+ Surface: "vauxr",
144
+ Timestamp: Date.now(),
145
+ };
146
+ try {
147
+ await this.api.runtime.channel.turn.run({
148
+ channel: "vauxr",
149
+ raw: { deviceId, text },
150
+ adapter: {
151
+ ingest: () => ({
152
+ id: turnId,
153
+ timestamp: Date.now(),
154
+ rawText: text,
155
+ raw: { deviceId, text },
156
+ }),
157
+ classify: () => ({ kind: "message", canStartAgentTurn: true }),
158
+ resolveTurn: () => ({
159
+ channel: "vauxr",
160
+ routeSessionKey: sessionKey,
161
+ storePath,
162
+ // FinalizedMsgContext has ~80 optional fields; ours is a minimal
163
+ // voice-channel subset. The kernel reads what it needs and ignores
164
+ // the rest, so an unsafe cast is acceptable here.
165
+ ctxPayload: ctxPayload,
166
+ recordInboundSession: this.api.runtime.channel.session.recordInboundSession,
167
+ runDispatch: async () => {
168
+ // Outbound delivery flows through the existing onAgentEvent
169
+ // delta tap (subscribeAgentEvents) for lowest TTS latency — see
170
+ // spec decision D2. The reply dispatcher's `deliver` is a no-op
171
+ // here; the dispatcher exists only to satisfy the channel-turn
172
+ // contract and to give the dispatch-from-config pipeline a sink
173
+ // to write into.
174
+ const { dispatcher } = this.api.runtime.channel.reply.createReplyDispatcherWithTyping({
175
+ deliver: async () => undefined,
176
+ });
177
+ return await this.api.runtime.channel.reply.dispatchReplyFromConfig({
178
+ ctx: ctxPayload,
179
+ cfg,
180
+ dispatcher,
181
+ });
182
+ },
183
+ }),
184
+ },
185
+ });
186
+ }
187
+ catch (err) {
188
+ this.api.logger.warn(`[vauxr-bridge] Failed to dispatch transcript for ${sessionKey}: ${String(err)}`);
189
+ this.send({
190
+ type: "channel.response.error",
191
+ deviceId,
192
+ runId: protocolRunId,
193
+ message: String(err),
194
+ });
195
+ }
196
+ finally {
197
+ // channel.turn.run awaits the full turn (including the agent run inside
198
+ // runDispatch), so by this point all events have fired and the turn is
199
+ // done. Safe to clean up correlation state here. runIdToTurn entries
200
+ // for this device are cleaned in the lifecycle.end branch of the event
201
+ // handler — best-effort sweep here in case lifecycle.end never fired.
202
+ this.activeRuns.delete(deviceId);
203
+ this.sentinelBuffer.delete(deviceId);
204
+ this.sentinelMode.delete(deviceId);
205
+ for (const [rid, turn] of this.runIdToTurn) {
206
+ if (turn.deviceId === deviceId)
207
+ this.runIdToTurn.delete(rid);
208
+ }
209
+ }
210
+ }
211
+ subscribeAgentEvents() {
212
+ this.unsubscribeEvents = this.api.runtime.events.onAgentEvent((event) => {
213
+ // Two-stage correlation:
214
+ // 1. If the event carries a sessionKey (lifecycle events do, most
215
+ // others don't), parse the deviceId and look up the inflight turn
216
+ // we registered in dispatchTranscript. Cache the SDK runId so
217
+ // subsequent sessionKey-less events from the same run can be
218
+ // matched by runId alone.
219
+ // 2. Otherwise, look up by event.runId — populated by step (1) for
220
+ // this turn's prior lifecycle event.
221
+ // pi-embedded's first lifecycle.start carries sessionKey and arrives
222
+ // well before any assistant deltas, so the latch is always primed
223
+ // before delta events need to route.
224
+ let active;
225
+ const sk = event.sessionKey;
226
+ if (sk) {
227
+ const m = sk.match(/(?:^|:)vauxr:([^:]+)/);
228
+ if (m) {
229
+ active = this.activeRuns.get(m[1]);
230
+ if (active)
231
+ this.runIdToTurn.set(event.runId, active);
232
+ }
233
+ }
234
+ if (!active)
235
+ active = this.runIdToTurn.get(event.runId);
236
+ if (!active)
237
+ return;
238
+ const { deviceId, protocolRunId: runId } = active;
239
+ if (event.stream === "assistant") {
240
+ // Only forward the incremental delta. data.text is the running
241
+ // accumulated reply — forwarding it as a delta would re-send
242
+ // the entire reply on top of the deltas we've already sent,
243
+ // duplicating it in TTS. The OpenClaw runtime emits at least
244
+ // one final assistant event per run with `{ text }` only (no
245
+ // `delta`); those carry no new content and must be dropped.
246
+ const delta = event.data["delta"];
247
+ if (typeof delta !== "string" || delta.length === 0)
248
+ return;
249
+ const mode = this.sentinelMode.get(deviceId);
250
+ if (mode === "suppressed")
251
+ return;
252
+ if (mode === "passthrough") {
253
+ this.send({ type: "channel.response.delta", deviceId, runId, text: delta });
254
+ return;
255
+ }
256
+ // Buffering: hold deltas while the accumulated text could
257
+ // still complete the silent-reply sentinel.
258
+ const SENTINEL = "NO_REPLY";
259
+ const buffered = (this.sentinelBuffer.get(deviceId) ?? "") + delta;
260
+ const normalized = buffered.trim().toUpperCase();
261
+ if (normalized === SENTINEL) {
262
+ // Confirmed sentinel — suppress everything for this run.
263
+ this.sentinelMode.set(deviceId, "suppressed");
264
+ this.sentinelBuffer.delete(deviceId);
265
+ return;
266
+ }
267
+ if (SENTINEL.startsWith(normalized)) {
268
+ // Could still become the sentinel — keep holding.
269
+ this.sentinelBuffer.set(deviceId, buffered);
270
+ return;
271
+ }
272
+ // Diverged from sentinel — flush the held text and pass through
273
+ // the rest of the run.
274
+ this.sentinelMode.set(deviceId, "passthrough");
275
+ this.sentinelBuffer.delete(deviceId);
276
+ this.send({ type: "channel.response.delta", deviceId, runId, text: buffered });
277
+ }
278
+ // Signal end-of-turn to vauxr-ws so TTS finalizes. dispatchTranscript's
279
+ // finally clears activeRuns once channel.turn.run returns; we don't
280
+ // clean up here to avoid racing that path.
281
+ if (event.stream === "lifecycle" && event.data["phase"] === "end") {
282
+ this.send({
283
+ type: "channel.response.end",
284
+ deviceId,
285
+ runId,
286
+ });
287
+ }
288
+ if (event.stream === "error") {
289
+ this.api.logger.warn(`[vauxr-bridge] Agent error for device ${deviceId}: ${JSON.stringify(event.data)}`);
290
+ this.send({
291
+ type: "channel.response.error",
292
+ deviceId,
293
+ runId,
294
+ message: String(event.data["message"] ?? "Agent error"),
295
+ });
296
+ }
297
+ });
298
+ }
299
+ send(frame) {
300
+ if (this.ws?.readyState === WebSocket.OPEN) {
301
+ this.ws.send(JSON.stringify(frame));
302
+ }
303
+ }
304
+ }
305
+ /**
306
+ * Resolve the agent id that vauxr turns should route to.
307
+ *
308
+ * Preference order:
309
+ * 1. channels.vauxr.targetAgent (explicit operator config)
310
+ * 2. plugins.entries.vauxr.config.targetAgent (alternate config slot)
311
+ * 3. agents.list[].default === true
312
+ * 4. agents.list[0].id (first declared agent)
313
+ * 5. "default" sentinel (last resort — produces an obviously-wrong key the
314
+ * operator can spot in logs)
315
+ */
316
+ function resolveTargetAgentId(cfg) {
317
+ const raw = cfg;
318
+ const fromChannels = raw.channels?.vauxr;
319
+ if (fromChannels?.targetAgent)
320
+ return fromChannels.targetAgent;
321
+ const fromPlugins = raw.plugins?.entries?.vauxr;
322
+ if (fromPlugins?.config?.targetAgent)
323
+ return fromPlugins.config.targetAgent;
324
+ const agents = raw.agents
325
+ ?.list;
326
+ if (Array.isArray(agents)) {
327
+ const defaultAgent = agents.find((a) => a.default && a.id);
328
+ if (defaultAgent?.id)
329
+ return defaultAgent.id;
330
+ if (agents[0]?.id)
331
+ return agents[0].id;
332
+ }
333
+ return "default";
334
+ }
@@ -0,0 +1,153 @@
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
+ // Type assertion needed: createChannelPluginBase marks capabilities as Partial
5
+ // in its return type, but createChatChannelPlugin requires it non-optional.
6
+ // We always provide capabilities, so the assertion is safe.
7
+ export const vauxrPlugin = createChatChannelPlugin({
8
+ base: createChannelPluginBase({
9
+ id: "vauxr",
10
+ meta: { label: "Vauxr" },
11
+ capabilities: {
12
+ chatTypes: ["direct"],
13
+ },
14
+ config: createTopLevelChannelConfigBase({
15
+ sectionKey: "vauxr",
16
+ resolveAccount: (cfg) => {
17
+ const section = resolveSection(cfg);
18
+ const url = section?.url;
19
+ return {
20
+ accountId: url ?? "default",
21
+ // running/connected drive UI status indicators
22
+ ...(url ? { running: true, connected: true } : {}),
23
+ };
24
+ },
25
+ // Single-account channel — listAccountIds returns either the single
26
+ // resolved id or an empty array if the channel isn't configured.
27
+ listAccountIds: (cfg) => {
28
+ const section = resolveSection(cfg);
29
+ return section?.url ? [section.url] : [];
30
+ },
31
+ defaultAccountId: (cfg) => resolveSection(cfg)?.url ?? "default",
32
+ }),
33
+ setup: {
34
+ resolveAccountId({ cfg }) {
35
+ const section = resolveSection(cfg);
36
+ return section?.url ?? "default";
37
+ },
38
+ applyAccountConfig({ cfg, input }) {
39
+ const updated = structuredClone(cfg);
40
+ const channels = (updated.channels ?? {});
41
+ const merged = {
42
+ // Seed default voice system prompt so it's populated on first install.
43
+ // Existing value (if any) takes precedence via spread order.
44
+ voiceSystemPrompt: DEFAULT_VOICE_SYSTEM_PROMPT,
45
+ ...(channels.vauxr ?? {}),
46
+ ...input,
47
+ };
48
+ channels.vauxr = merged;
49
+ updated.channels = channels;
50
+ applyToolsBySenderPolicy(updated, merged);
51
+ return updated;
52
+ },
53
+ },
54
+ }),
55
+ // No security/pairing — vauxr devices are trusted local hardware
56
+ outbound: {
57
+ // Outbound responses are delivered via the WS bridge, not the outbound adapter
58
+ // This stub satisfies the ChannelPlugin interface
59
+ base: {
60
+ deliveryMode: "direct",
61
+ },
62
+ attachedResults: {
63
+ channel: "vauxr",
64
+ sendText: async () => ({ messageId: "bridge" }),
65
+ },
66
+ },
67
+ });
68
+ // gateway.startAccount is required for OpenClaw to mark this channel as
69
+ // "running" and "configured" in the UI. The actual bridge lifecycle is
70
+ // managed by registerFull in index.ts (which has access to the full plugin
71
+ // API). This stub holds the channel in running state until the gateway stops.
72
+ // isConfigured: tells OpenClaw the channel is configured when url is set.
73
+ vauxrPlugin.config.isConfigured = (_account, cfg) => {
74
+ return Boolean(resolveSection(cfg)?.url);
75
+ };
76
+ vauxrPlugin.gateway = {
77
+ startAccount: async (ctx) => {
78
+ await new Promise((resolve) => {
79
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
80
+ });
81
+ },
82
+ };
83
+ function resolveSection(cfg) {
84
+ const raw = cfg;
85
+ const channelsCfg = raw.channels?.vauxr;
86
+ const pluginsCfg = raw.plugins?.entries?.vauxr;
87
+ return channelsCfg ?? pluginsCfg?.config;
88
+ }
89
+ // Key used by OpenClaw's per-sender tool policy resolver to match
90
+ // vauxr-originated runs. Format is `channel:<channelId>:<senderId>`;
91
+ // wildcard senderId applies to every vauxr device.
92
+ const VAUXR_TOOLS_BY_SENDER_KEY = "channel:vauxr:*";
93
+ /**
94
+ * Mirror the vauxr channel's alsoAllow/targetAgent into the target agent's
95
+ * tools.toolsBySender map. Without this expansion, vauxr-originated runs only
96
+ * receive the messaging-profile defaults — platform tools like `gateway` and
97
+ * `nodes` are stripped because the runtime treats third-party channels more
98
+ * strictly than the internal `webchat` channel.
99
+ *
100
+ * Throws when alsoAllow is set without a resolvable targetAgent so the
101
+ * mismatch surfaces during install/configure instead of silently no-op'ing.
102
+ */
103
+ function applyToolsBySenderPolicy(cfgMut, section) {
104
+ const alsoAllow = Array.isArray(section.alsoAllow)
105
+ ? section.alsoAllow.filter((v) => typeof v === "string")
106
+ : [];
107
+ const targetAgent = typeof section.targetAgent === "string" && section.targetAgent.length > 0
108
+ ? section.targetAgent
109
+ : undefined;
110
+ const agentsRoot = (cfgMut.agents ?? {});
111
+ const agentList = Array.isArray(agentsRoot.list)
112
+ ? agentsRoot.list
113
+ : [];
114
+ if (alsoAllow.length > 0) {
115
+ if (!targetAgent) {
116
+ throw new Error("channels.vauxr.alsoAllow is set but channels.vauxr.targetAgent is missing. " +
117
+ "Set targetAgent to the agent id that handles vauxr sessions (e.g. \"nova-cloud\").");
118
+ }
119
+ if (!agentList.some((a) => a.id === targetAgent)) {
120
+ const known = agentList.map((a) => a.id).filter(Boolean).join(", ") || "(none)";
121
+ throw new Error(`channels.vauxr.targetAgent="${targetAgent}" does not match any agent in agents.list. ` +
122
+ `Known agents: ${known}.`);
123
+ }
124
+ }
125
+ // Walk every agent: write the vauxr policy on the target, scrub it from
126
+ // the others. Idempotent: re-running with the same config is a no-op,
127
+ // re-running after clearing alsoAllow removes the policy everywhere.
128
+ for (const agent of agentList) {
129
+ const tools = (agent.tools ?? {});
130
+ const toolsBySender = (tools.toolsBySender ?? {});
131
+ const isTarget = agent.id === targetAgent && alsoAllow.length > 0;
132
+ if (isTarget) {
133
+ toolsBySender[VAUXR_TOOLS_BY_SENDER_KEY] = { alsoAllow };
134
+ tools.toolsBySender = toolsBySender;
135
+ agent.tools = tools;
136
+ }
137
+ else if (VAUXR_TOOLS_BY_SENDER_KEY in toolsBySender) {
138
+ delete toolsBySender[VAUXR_TOOLS_BY_SENDER_KEY];
139
+ if (Object.keys(toolsBySender).length === 0) {
140
+ delete tools.toolsBySender;
141
+ }
142
+ else {
143
+ tools.toolsBySender = toolsBySender;
144
+ }
145
+ if (Object.keys(tools).length === 0) {
146
+ delete agent.tools;
147
+ }
148
+ else {
149
+ agent.tools = tools;
150
+ }
151
+ }
152
+ }
153
+ }
@@ -0,0 +1 @@
1
+ export const DEFAULT_VOICE_SYSTEM_PROMPT = "You are responding to a voice device. Use plain speech only — no emojis, no markdown, no code blocks. Keep replies concise.";