@vauxr/openclaw 2026.4.12-1 → 2026.5.31

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,60 @@
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
+ // Construct the WS bridge but DO NOT start it here. `registerFull` is
32
+ // invoked from introspection paths too (e.g. `openclaw doctor`), and
33
+ // starting the bridge here would open a live WebSocket to vauxr during
34
+ // diagnostics. The bridge is started later by `gateway.startAccount`
35
+ // (see channel.ts), which only fires when the gateway is actually
36
+ // bringing the channel up for runtime use. The globalThis stash bridges
37
+ // the two scopes because `startAccount` doesn't have access to `api`.
38
+ //
39
+ // The single-bridge guard remains here so multiple `registerFull`
40
+ // invocations in the same process don't reconstruct the bridge — they'd
41
+ // contend for the single active channel slot in vauxr otherwise.
42
+ const g = globalThis;
43
+ if (!g.__vauxrBridge) {
44
+ g.__vauxrBridge = new VauxrBridge(api, config);
45
+ }
46
+ // Voice system prompt injection for vauxr sessions. Match both the bare
47
+ // form (`vauxr:<deviceId>`) used by the old subagent.run path and the
48
+ // fully-prefixed form (`agent:<agentId>:vauxr:<deviceId>`) used by the
49
+ // current channel.turn.run path. Either form means it's a vauxr turn.
50
+ api.on("before_prompt_build", (_event, ctx) => {
51
+ if (ctx.sessionKey && /(?:^|:)vauxr:/.test(ctx.sessionKey)) {
52
+ return {
53
+ appendSystemContext: config.voiceSystemPrompt ?? DEFAULT_VOICE_SYSTEM_PROMPT,
54
+ };
55
+ }
56
+ return undefined;
57
+ });
58
+ },
59
+ });
60
+ 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,360 @@
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
+ started = false;
12
+ // Inflight turns keyed by deviceId. channel.turn.run doesn't surface an SDK
13
+ // runId to the caller (unlike the old subagent.run path), so dispatch-time
14
+ // bookkeeping is keyed by deviceId; we then latch onto the SDK runId on the
15
+ // first event that arrives carrying a sessionKey (typically lifecycle.start)
16
+ // and use that runId for all subsequent events from the same run. Most
17
+ // event streams (assistant/tool/item) do NOT carry sessionKey — only the
18
+ // lifecycle stream does — so runId-based correlation is load-bearing once
19
+ // we've latched. One inflight turn per device at a time.
20
+ activeRuns = new Map(); // deviceId → turn
21
+ runIdToTurn = new Map(); // sdkRunId → turn
22
+ // Per-device silent-reply sentinel state. "NO_REPLY" often arrives split
23
+ // across streaming deltas (e.g. "NO" then "_REPLY"), so we buffer until
24
+ // the accumulated text either matches the sentinel (suppress the whole
25
+ // run) or diverges (flush and pass through).
26
+ sentinelBuffer = new Map(); // deviceId → held delta text
27
+ sentinelMode = new Map();
28
+ wsUrl;
29
+ constructor(api, config) {
30
+ this.api = api;
31
+ this.config = config;
32
+ // Derive WS URL from HTTP base URL
33
+ const base = config.url.replace(/\/$/, "");
34
+ this.wsUrl = base.replace(/^http/, "ws") + "/channel";
35
+ }
36
+ start() {
37
+ if (this.started)
38
+ return;
39
+ this.started = true;
40
+ this.connect();
41
+ this.subscribeAgentEvents();
42
+ }
43
+ stop() {
44
+ if (!this.started)
45
+ return;
46
+ this.started = false;
47
+ if (this.reconnectTimer) {
48
+ clearTimeout(this.reconnectTimer);
49
+ this.reconnectTimer = null;
50
+ }
51
+ if (this.unsubscribeEvents) {
52
+ this.unsubscribeEvents();
53
+ this.unsubscribeEvents = null;
54
+ }
55
+ if (this.ws) {
56
+ this.ws.close();
57
+ this.ws = null;
58
+ }
59
+ // Reset the backoff so a subsequent stop/start cycle (e.g. channel
60
+ // aborts then restarts) begins reconnecting at INITIAL_RECONNECT_MS
61
+ // instead of inheriting whatever escalated delay the previous run
62
+ // had accumulated.
63
+ this.reconnectMs = INITIAL_RECONNECT_MS;
64
+ }
65
+ connect() {
66
+ this.api.logger.debug?.(`[vauxr-bridge] Connecting to vauxr: ${this.wsUrl}`);
67
+ const ws = new WebSocket(this.wsUrl);
68
+ this.ws = ws;
69
+ ws.on("open", () => {
70
+ this.api.logger.debug?.("[vauxr-bridge] Connected to vauxr");
71
+ this.reconnectMs = INITIAL_RECONNECT_MS;
72
+ // Authenticate with channel token
73
+ if (this.config.token) {
74
+ this.send({ type: "channel.auth", token: this.config.token });
75
+ }
76
+ });
77
+ ws.on("message", (data) => {
78
+ try {
79
+ const frame = JSON.parse(String(data));
80
+ this.handleFrame(frame);
81
+ }
82
+ catch (err) {
83
+ this.api.logger.warn(`[vauxr-bridge] Failed to parse inbound frame: ${String(err)}`);
84
+ }
85
+ });
86
+ ws.on("close", () => {
87
+ this.api.logger.debug?.("[vauxr-bridge] Disconnected from vauxr");
88
+ // Identity check: a stop() during the close-event async delay can be
89
+ // followed by another start() that opens a fresh ws. If we cleared
90
+ // `this.ws` blindly here we'd wipe the new socket's reference and
91
+ // also fire a duplicate reconnect. Only act if we're still the
92
+ // bridge's current ws.
93
+ if (this.ws !== ws)
94
+ return;
95
+ this.ws = null;
96
+ if (this.started)
97
+ this.scheduleReconnect();
98
+ });
99
+ ws.on("error", (err) => {
100
+ this.api.logger.warn(`[vauxr-bridge] WS error: ${String(err)}`);
101
+ // 'close' event will fire after this — reconnect handled there
102
+ });
103
+ }
104
+ scheduleReconnect() {
105
+ if (this.reconnectTimer)
106
+ return;
107
+ this.api.logger.debug?.(`[vauxr-bridge] Reconnecting in ${this.reconnectMs}ms`);
108
+ this.reconnectTimer = setTimeout(() => {
109
+ this.reconnectTimer = null;
110
+ this.connect();
111
+ }, this.reconnectMs);
112
+ this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
113
+ }
114
+ handleFrame(frame) {
115
+ switch (frame.type) {
116
+ case "channel.transcript":
117
+ if (frame.deviceId && frame.text) {
118
+ void this.dispatchTranscript(frame.deviceId, frame.text);
119
+ }
120
+ break;
121
+ case "channel.device_state":
122
+ this.api.logger.info(`[vauxr-bridge] Device ${frame.deviceId ?? "unknown"}: ${frame.state ?? "unknown"}`);
123
+ break;
124
+ case "channel.ready":
125
+ this.api.logger.debug?.("[vauxr-bridge] Channel authenticated");
126
+ break;
127
+ case "error":
128
+ this.api.logger.warn(`[vauxr-bridge] Error from vauxr: ${frame.code ?? "UNKNOWN"} — ${frame.message ?? "no details"}`);
129
+ break;
130
+ default:
131
+ this.api.logger.warn(`[vauxr-bridge] Unknown frame type: ${String(frame.type)}`);
132
+ }
133
+ }
134
+ async dispatchTranscript(deviceId, text) {
135
+ const cfg = this.api.config;
136
+ // Construct the sessionKey in the same form the old subagent.run path
137
+ // ended up producing after openclaw's internal normalization
138
+ // (`agent:<agentId>:vauxr:<deviceId>`). channel.turn.run does NOT apply
139
+ // that same normalization to routeSessionKey — it stores under whatever
140
+ // string we pass — so we have to build the full form ourselves to
141
+ // preserve session continuity with prior turns / restarts.
142
+ const agentId = resolveTargetAgentId(cfg);
143
+ const sessionKey = `agent:${agentId}:vauxr:${deviceId}`;
144
+ // Protocol-level runId sent to vauxr-ws in response frames so it can
145
+ // correlate delta/end/error chunks back to this transcript.
146
+ const protocolRunId = crypto.randomUUID();
147
+ const turnId = `vauxr-${deviceId}-${Date.now()}`;
148
+ this.api.logger.info(`[vauxr-bridge] Dispatching transcript for ${sessionKey} (runId=${protocolRunId}): "${text}"`);
149
+ // Register before dispatch so onAgentEvent can correlate any event the
150
+ // agent runtime emits for this turn back to the originating device.
151
+ this.activeRuns.set(deviceId, { deviceId, protocolRunId });
152
+ const storePath = this.api.runtime.channel.session.resolveStorePath(cfg.session?.store);
153
+ // Minimal inbound context. Voice channels don't carry replies, media,
154
+ // mentions, forwards, etc. — most MsgContext fields stay undefined.
155
+ const ctxPayload = {
156
+ Body: text,
157
+ BodyForAgent: text,
158
+ From: deviceId,
159
+ SenderId: deviceId,
160
+ SenderName: deviceId,
161
+ SessionKey: sessionKey,
162
+ Provider: "vauxr",
163
+ Surface: "vauxr",
164
+ Timestamp: Date.now(),
165
+ };
166
+ try {
167
+ // OpenClaw 2026.5.28 renamed `runtime.channel.turn` to
168
+ // `runtime.channel.inbound` (pure rename — same signature, same
169
+ // ChannelInboundEventRunnerParams shape as the prior
170
+ // RunChannelTurnParams). Earlier vauxr-openclaw releases that
171
+ // referenced `.turn.run` will throw `Cannot read properties of
172
+ // undefined (reading 'run')` on gateways 2026.5.28+.
173
+ await this.api.runtime.channel.inbound.run({
174
+ channel: "vauxr",
175
+ raw: { deviceId, text },
176
+ adapter: {
177
+ ingest: () => ({
178
+ id: turnId,
179
+ timestamp: Date.now(),
180
+ rawText: text,
181
+ raw: { deviceId, text },
182
+ }),
183
+ classify: () => ({ kind: "message", canStartAgentTurn: true }),
184
+ resolveTurn: () => ({
185
+ channel: "vauxr",
186
+ routeSessionKey: sessionKey,
187
+ storePath,
188
+ // FinalizedMsgContext has ~80 optional fields; ours is a minimal
189
+ // voice-channel subset. The kernel reads what it needs and ignores
190
+ // the rest, so an unsafe cast is acceptable here.
191
+ ctxPayload: ctxPayload,
192
+ recordInboundSession: this.api.runtime.channel.session.recordInboundSession,
193
+ runDispatch: async () => {
194
+ // Outbound delivery flows through the existing onAgentEvent
195
+ // delta tap (subscribeAgentEvents) for lowest TTS latency — see
196
+ // spec decision D2. The reply dispatcher's `deliver` is a no-op
197
+ // here; the dispatcher exists only to satisfy the channel-turn
198
+ // contract and to give the dispatch-from-config pipeline a sink
199
+ // to write into.
200
+ const { dispatcher } = this.api.runtime.channel.reply.createReplyDispatcherWithTyping({
201
+ deliver: async () => undefined,
202
+ });
203
+ return await this.api.runtime.channel.reply.dispatchReplyFromConfig({
204
+ ctx: ctxPayload,
205
+ cfg,
206
+ dispatcher,
207
+ });
208
+ },
209
+ }),
210
+ },
211
+ });
212
+ }
213
+ catch (err) {
214
+ this.api.logger.warn(`[vauxr-bridge] Failed to dispatch transcript for ${sessionKey}: ${String(err)}`);
215
+ this.send({
216
+ type: "channel.response.error",
217
+ deviceId,
218
+ runId: protocolRunId,
219
+ message: String(err),
220
+ });
221
+ }
222
+ finally {
223
+ // channel.turn.run awaits the full turn (including the agent run inside
224
+ // runDispatch), so by this point all events have fired and the turn is
225
+ // done. Safe to clean up correlation state here. runIdToTurn entries
226
+ // for this device are cleaned in the lifecycle.end branch of the event
227
+ // handler — best-effort sweep here in case lifecycle.end never fired.
228
+ this.activeRuns.delete(deviceId);
229
+ this.sentinelBuffer.delete(deviceId);
230
+ this.sentinelMode.delete(deviceId);
231
+ for (const [rid, turn] of this.runIdToTurn) {
232
+ if (turn.deviceId === deviceId)
233
+ this.runIdToTurn.delete(rid);
234
+ }
235
+ }
236
+ }
237
+ subscribeAgentEvents() {
238
+ this.unsubscribeEvents = this.api.runtime.events.onAgentEvent((event) => {
239
+ // Two-stage correlation:
240
+ // 1. If the event carries a sessionKey (lifecycle events do, most
241
+ // others don't), parse the deviceId and look up the inflight turn
242
+ // we registered in dispatchTranscript. Cache the SDK runId so
243
+ // subsequent sessionKey-less events from the same run can be
244
+ // matched by runId alone.
245
+ // 2. Otherwise, look up by event.runId — populated by step (1) for
246
+ // this turn's prior lifecycle event.
247
+ // pi-embedded's first lifecycle.start carries sessionKey and arrives
248
+ // well before any assistant deltas, so the latch is always primed
249
+ // before delta events need to route.
250
+ let active;
251
+ const sk = event.sessionKey;
252
+ if (sk) {
253
+ const m = sk.match(/(?:^|:)vauxr:([^:]+)/);
254
+ if (m) {
255
+ active = this.activeRuns.get(m[1]);
256
+ if (active)
257
+ this.runIdToTurn.set(event.runId, active);
258
+ }
259
+ }
260
+ if (!active)
261
+ active = this.runIdToTurn.get(event.runId);
262
+ if (!active)
263
+ return;
264
+ const { deviceId, protocolRunId: runId } = active;
265
+ if (event.stream === "assistant") {
266
+ // Only forward the incremental delta. data.text is the running
267
+ // accumulated reply — forwarding it as a delta would re-send
268
+ // the entire reply on top of the deltas we've already sent,
269
+ // duplicating it in TTS. The OpenClaw runtime emits at least
270
+ // one final assistant event per run with `{ text }` only (no
271
+ // `delta`); those carry no new content and must be dropped.
272
+ const delta = event.data["delta"];
273
+ if (typeof delta !== "string" || delta.length === 0)
274
+ return;
275
+ const mode = this.sentinelMode.get(deviceId);
276
+ if (mode === "suppressed")
277
+ return;
278
+ if (mode === "passthrough") {
279
+ this.send({ type: "channel.response.delta", deviceId, runId, text: delta });
280
+ return;
281
+ }
282
+ // Buffering: hold deltas while the accumulated text could
283
+ // still complete the silent-reply sentinel.
284
+ const SENTINEL = "NO_REPLY";
285
+ const buffered = (this.sentinelBuffer.get(deviceId) ?? "") + delta;
286
+ const normalized = buffered.trim().toUpperCase();
287
+ if (normalized === SENTINEL) {
288
+ // Confirmed sentinel — suppress everything for this run.
289
+ this.sentinelMode.set(deviceId, "suppressed");
290
+ this.sentinelBuffer.delete(deviceId);
291
+ return;
292
+ }
293
+ if (SENTINEL.startsWith(normalized)) {
294
+ // Could still become the sentinel — keep holding.
295
+ this.sentinelBuffer.set(deviceId, buffered);
296
+ return;
297
+ }
298
+ // Diverged from sentinel — flush the held text and pass through
299
+ // the rest of the run.
300
+ this.sentinelMode.set(deviceId, "passthrough");
301
+ this.sentinelBuffer.delete(deviceId);
302
+ this.send({ type: "channel.response.delta", deviceId, runId, text: buffered });
303
+ }
304
+ // Signal end-of-turn to vauxr-ws so TTS finalizes. dispatchTranscript's
305
+ // finally clears activeRuns once channel.turn.run returns; we don't
306
+ // clean up here to avoid racing that path.
307
+ if (event.stream === "lifecycle" && event.data["phase"] === "end") {
308
+ this.send({
309
+ type: "channel.response.end",
310
+ deviceId,
311
+ runId,
312
+ });
313
+ }
314
+ if (event.stream === "error") {
315
+ this.api.logger.warn(`[vauxr-bridge] Agent error for device ${deviceId}: ${JSON.stringify(event.data)}`);
316
+ this.send({
317
+ type: "channel.response.error",
318
+ deviceId,
319
+ runId,
320
+ message: String(event.data["message"] ?? "Agent error"),
321
+ });
322
+ }
323
+ });
324
+ }
325
+ send(frame) {
326
+ if (this.ws?.readyState === WebSocket.OPEN) {
327
+ this.ws.send(JSON.stringify(frame));
328
+ }
329
+ }
330
+ }
331
+ /**
332
+ * Resolve the agent id that vauxr turns should route to.
333
+ *
334
+ * Preference order:
335
+ * 1. channels.vauxr.targetAgent (explicit operator config)
336
+ * 2. plugins.entries.vauxr.config.targetAgent (alternate config slot)
337
+ * 3. agents.list[].default === true
338
+ * 4. agents.list[0].id (first declared agent)
339
+ * 5. "default" sentinel (last resort — produces an obviously-wrong key the
340
+ * operator can spot in logs)
341
+ */
342
+ function resolveTargetAgentId(cfg) {
343
+ const raw = cfg;
344
+ const fromChannels = raw.channels?.vauxr;
345
+ if (fromChannels?.targetAgent)
346
+ return fromChannels.targetAgent;
347
+ const fromPlugins = raw.plugins?.entries?.vauxr;
348
+ if (fromPlugins?.config?.targetAgent)
349
+ return fromPlugins.config.targetAgent;
350
+ const agents = raw.agents
351
+ ?.list;
352
+ if (Array.isArray(agents)) {
353
+ const defaultAgent = agents.find((a) => a.default && a.id);
354
+ if (defaultAgent?.id)
355
+ return defaultAgent.id;
356
+ if (agents[0]?.id)
357
+ return agents[0].id;
358
+ }
359
+ return "default";
360
+ }