@voicethere/agent 0.1.0

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,251 @@
1
+ # @voicethere/agent
2
+
3
+ VoiceThere **customer agent SDK** — TypeScript types and runtime helpers for sandboxed child bundles running inside [`voicethere/runner`](https://github.com/voicethere/runner).
4
+
5
+ **npm:** `@voicethere/agent`
6
+ **Repo:** [`voicethere/agent`](https://github.com/voicethere/agent)
7
+
8
+ ## Role
9
+
10
+ | Layer | Package | Runs in |
11
+ | --------- | ----------------------------------------------------------- | ------------------------------------ |
12
+ | Parent | [`voicethere/runner`](https://github.com/voicethere/runner) | Trusted Node + WebRTC + speech stack |
13
+ | **Child** | **`@voicethere/agent`** | Sandboxed customer `agent.js` bundle |
14
+
15
+ The child receives speech lifecycle events over IPC (same shapes as `@node-webrtc-rust/sdk/voice`) and calls `speak()` to request TTS from the parent.
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ git clone https://github.com/voicethere/agent.git
21
+ cd agent
22
+ npm install
23
+ npm run build
24
+ ```
25
+
26
+ Run with a local [runner](https://github.com/voicethere/runner) (clone that repo alongside this one):
27
+
28
+ ```bash
29
+ cd ../runner
30
+ AGENT_BUNDLE_PATH=../agent/dist/agent.js npm run start
31
+ ```
32
+
33
+ Open the runner URL in a browser, connect, and speak.
34
+
35
+ ## Verify locally (sandbox, no WebRTC)
36
+
37
+ Before deploying to VoiceThere, confirm your bundle loads and responds under the **same Node sandbox** the runner uses:
38
+
39
+ ```bash
40
+ npm run verify:local
41
+ ```
42
+
43
+ | Script | When to use |
44
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
45
+ | `npm run verify:local` | **Default** — `npm run build`, then fork `dist/agent.js` in the sandbox and assert a `speak` reply to `user_speech_final` |
46
+ | `npm run verify:local:only` | Re-run smoke after build; optional `AGENT_BUNDLE_PATH=./dist/agent.js` or `--bundle <path>` |
47
+
48
+ This checks bundle load, IPC, and Node permission flags. It does **not** replace a voice roundtrip — use [`voicethere/runner`](https://github.com/voicethere/runner) for mic/WebRTC E2E.
49
+
50
+ Harness: [`scripts/sandbox/`](./scripts/sandbox/) (aligned with [`voicethere/runner`](https://github.com/voicethere/runner) child launcher).
51
+
52
+ ## API
53
+
54
+ ```typescript
55
+ import {
56
+ agentLog,
57
+ defineAgent,
58
+ speak,
59
+ type SpeechEvent,
60
+ } from '@voicethere/agent'
61
+ import { SPEECH_EVENT_TYPE } from '@node-webrtc-rust/sdk/voice'
62
+
63
+ defineAgent({
64
+ onSessionStart({ sessionId }) {
65
+ speak(sessionId, 'Hello!')
66
+ },
67
+ onUserSpeechFinal({ sessionId, text }) {
68
+ speak(sessionId, `You said: ${text}`)
69
+ },
70
+ onSpeechEvent({ sessionId }, speech: SpeechEvent) {
71
+ if (speech.type === SPEECH_EVENT_TYPE.bargeIn) {
72
+ agentLog('info', `User interrupted on ${sessionId}`)
73
+ }
74
+ },
75
+ })
76
+ ```
77
+
78
+ | Export | Purpose |
79
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------- |
80
+ | `defineAgent` | Register `onSessionStart`, `onSpeechEvent`, `onUserSpeechFinal`, `onSessionEnd` |
81
+ | `SpeechEvent`, `SpeechEventType` | Re-exported **types** from `@node-webrtc-rust/sdk/voice` |
82
+ | `SPEECH_EVENT_TYPE` | Import from `@node-webrtc-rust/sdk/voice` (runtime constants; not bundled into child) |
83
+ | `speak` | Request parent TTS |
84
+ | `agentLog` | Forward structured logs to parent |
85
+ | `ParentToChildMessage` / `ChildToParentMessage` | IPC contract shared with [`voicethere/runner`](https://github.com/voicethere/runner) |
86
+
87
+ ### Speech events (parent → child)
88
+
89
+ Forwarded from the runner voice pipeline as SDK `SpeechEvent` payloads on `speech_event.event` (`event.type`, optional `text` / `error`):
90
+
91
+ | Event | Typical use in custom agent |
92
+ | --------------------------------------------- | ------------------------------------------------------- |
93
+ | `user_speaking_start` / `user_speaking_end` | UI state, turn-taking |
94
+ | `user_speech_partial` | Live captions, early barge-in logic |
95
+ | `user_speech_final` | Primary turn boundary (`onUserSpeechFinal` convenience) |
96
+ | `agent_speaking_start` / `agent_speaking_end` | Know when TTS playback starts/stops |
97
+ | `barge_in` | User interrupted agent playback |
98
+ | `vad_triggered`, `stt_stream_*`, `user_stt_*` | Low-level pipeline hooks |
99
+ | `error` | Vendor or pipeline failure |
100
+
101
+ Copy [`templates/agent.ts`](./templates/agent.ts) as a starting point — exhaustive `switch` over all 14 `SpeechEvent` types with per-peer state stubs and `agentLog` tracing.
102
+
103
+ ## Building your agent bundle
104
+
105
+ **Recommended:** single ESM bundle (esbuild or similar):
106
+
107
+ ```bash
108
+ npm install @voicethere/agent esbuild
109
+ npx esbuild agent.ts --bundle --platform=node --format=esm --outfile=dist/agent.js
110
+ ```
111
+
112
+ Upload `dist/agent.js` (or point `AGENT_BUNDLE_PATH` at it locally). Inlining dependencies avoids runtime `node_modules` resolution inside the sandbox.
113
+
114
+ ## Sandbox and security model
115
+
116
+ Customer code runs in a **forked child process**, separate from the trusted runner parent (Sherpa, WebRTC, TTS). Security is layered:
117
+
118
+ ```text
119
+ ┌──────────────────────────────────────────────────────────────┐
120
+ │ Runner parent (trusted) — full Node, WebRTC, speech stack │
121
+ │ fork(loader-entry.js, execArgv: [--permission, …]) │
122
+ │ │ IPC (process.send / on('message')) │
123
+ │ ▼ │
124
+ │ Customer child — Node Permission Model + stripped env │
125
+ │ loader-entry.js → import(your agent.js) │
126
+ └──────────────────────────────────────────────────────────────┘
127
+ │ same pod network namespace (K8s)
128
+
129
+ Cilium NetworkPolicy on runner pod (egress rules)
130
+ ```
131
+
132
+ ### Layer 1 — Process isolation
133
+
134
+ | Mechanism | What it means for your bundle |
135
+ | --------- | ----------------------------- |
136
+ | **Separate process** | Crash or `process.exit` in your bundle does not take down the parent voice stack |
137
+ | **IPC only** | Talk to the platform via `defineAgent` / `speak` / `agentLog` — not direct WebRTC or STT |
138
+ | **Stripped `process.env`** | Child receives only `NODE_ENV`, internal loader path, and allowlisted keys (`SESSION_ID`, `PROJECT_ID`, `BUILD_ID`) — not parent/kube secrets |
139
+ | **Console redirection** | `console.log` / `warn` / `error` → IPC logs; raw stdout is not forwarded to cluster logs |
140
+
141
+ ### Layer 2 — Node `--permission` (runtime-enforced)
142
+
143
+ The parent starts the child with Node’s [Permission Model](https://nodejs.org/api/permissions.html) (Node **22+**). Capabilities are **deny-by-default**; only explicitly granted flags apply.
144
+
145
+ **Granted today** (via `execArgv` on `fork()`):
146
+
147
+ | Flag | Effect |
148
+ | ---- | ------ |
149
+ | `--permission` | Enables restriction mode |
150
+ | `--allow-fs-read=<loaderDir>` | Read files under the runner’s child loader directory |
151
+ | `--allow-fs-read=<bundleParentDir>` | Read files under the **directory containing your `agent.js`** (see below) |
152
+
153
+ **Not granted → blocked at runtime:**
154
+
155
+ | Missing flag | What fails |
156
+ | ------------ | ---------- |
157
+ | No `--allow-child-process` | `child_process`, `exec`, `spawn`, `fork` |
158
+ | No `--allow-fs-write` | Any file write (`writeFile`, logs to disk, etc.) |
159
+ | No extra `--allow-fs-read` paths | Reading `/etc/passwd`, parent files, etc. outside bundle dir |
160
+ | No `--allow-addons` | Native `.node` addons (`bcrypt`, `sharp`, …) |
161
+ | No `--allow-worker-threads` | `worker_threads` |
162
+ | No `--allow-wasi` | WASI modules |
163
+
164
+ This is **not** an import allowlist — Node gates **capability classes**, not package names. Using `node:fs` inside the allowed read tree can work; using it on `/etc/passwd` does not.
165
+
166
+ ### Bundle directory vs single file
167
+
168
+ `--allow-fs-read` is applied to **`dirname(bundlePath)`**, not only the `.js` file:
169
+
170
+ ```text
171
+ /app/agents/my-build/
172
+ agent.js ← entry (AGENT_BUNDLE_PATH)
173
+ helper.js ← importable if your bundle references it
174
+ data.json ← readable via fs if you import/read it
175
+ node_modules/ ← JS-only deps may resolve; native addons still blocked
176
+ ```
177
+
178
+ | Artifact in bundle dir | Works? |
179
+ | ---------------------- | ------ |
180
+ | Single bundled `agent.js` (recommended) | Yes |
181
+ | Extra pure `.js` / `.json` siblings | Usually yes (same allowed tree) |
182
+ | `node_modules/` with **JavaScript-only** packages | Often yes (Node resolves imports by reading under that tree) |
183
+ | **Native** npm packages (`.node` binaries) | **No** — requires `--allow-addons` (not enabled) |
184
+ | Packages that **spawn subprocesses** | **No** — no `--allow-child-process` |
185
+
186
+ Prefer **one esbuild bundle** so production behavior matches `npm run verify:local`.
187
+
188
+ ### Layer 3 — Platform policy (documented; not all enforced in-process)
189
+
190
+ These are **unsupported** in customer bundles even if Node might not block them today:
191
+
192
+ | Capability | Enforcement |
193
+ | ---------- | ----------- |
194
+ | **Outbound network** (`fetch`, `http`, `net`, `dns`) | Not gated by `--permission`; child shares the **pod** network. Runner pods use **Cilium NetworkPolicy** (public egress; cluster/RFC1918 denied). **Do not** rely on network from child — use parent/platform APIs. |
195
+ | **`process.exit`** | Not blocked — kills your agent leg; parent may play crash TTS |
196
+ | **Direct WebRTC / mic / STT / TTS** | Parent only — use `speak()` and speech event handlers |
197
+
198
+ ### Layer 4 — Kubernetes (runner pod)
199
+
200
+ On cluster deploy, the runner pod also has Helm hardening (non-root, read-only rootfs, dropped caps) and **NetworkPolicy** for egress. That applies to the whole pod (parent + child).
201
+
202
+ ### What you should use in agent code
203
+
204
+ **Supported**
205
+
206
+ - `@voicethere/agent` (`defineAgent`, `speak`, `agentLog`, `onSpeechEvent`, …)
207
+ - Pure TypeScript/JavaScript logic and in-memory state
208
+ - Allowlisted env from `onSessionStart` (`SESSION_ID`, `PROJECT_ID`, `BUILD_ID`)
209
+ - `SPEECH_EVENT_TYPE` from `@node-webrtc-rust/sdk/voice` at build time (avoid bundling the full SDK runtime into the child when possible)
210
+
211
+ **Blocked or unsupported**
212
+
213
+ - Subprocesses, shells, `child_process`
214
+ - Arbitrary filesystem access outside your bundle deployment directory
215
+ - File writes
216
+ - Native Node addons (`.node`)
217
+ - `worker_threads` (not allowed)
218
+ - Direct media/network stack access
219
+
220
+ **Pre-publish checklist**
221
+
222
+ 1. `npm run build` — produce `dist/agent.js`
223
+ 2. `npm run verify:local` — sandbox + IPC smoke (same flags as production child)
224
+ 3. Optional: voice E2E with [`voicethere/runner`](https://github.com/voicethere/runner)
225
+
226
+ ## Build outputs
227
+
228
+ | Path | Purpose |
229
+ | --------------- | ----------------------------------------------------------- |
230
+ | `dist/index.js` | Published npm library entry |
231
+ | `dist/agent.js` | Example bundle (`examples/agent.ts`) for local runner / verify |
232
+
233
+ ## Scripts
234
+
235
+ ```bash
236
+ npm run build # library + example bundle
237
+ npm run verify:local # sandbox smoke (build + fork bundle)
238
+ npm run test:ci # typecheck + vitest
239
+ ```
240
+
241
+ ## Release
242
+
243
+ See [`scripts/RELEASE.md`](./scripts/RELEASE.md) — tag `release/X.Y.Z` triggers npm publish (same workflow pattern as [`node-webrtc-rust`](https://github.com/akirilyuk/node-webrtc-rust)).
244
+
245
+ ## Related
246
+
247
+ | Repo | Purpose |
248
+ | ----------------------------------------------------------------------------- | ------------------------------------------------------------- |
249
+ | [`voicethere/runner`](https://github.com/voicethere/runner) | Session worker that hosts your `agent.js` bundle |
250
+ | [`voicethere/cli`](https://github.com/voicethere/cli) | CLI for the VoiceThere platform (projects, deploys, sessions) |
251
+ | [`akirilyuk/node-webrtc-rust`](https://github.com/akirilyuk/node-webrtc-rust) | WebRTC + voice SDK (`SpeechEvent` types) |
package/dist/agent.js ADDED
@@ -0,0 +1,148 @@
1
+ // src/runtime.ts
2
+ function isParentMessage(value) {
3
+ if (!value || typeof value !== "object") return false;
4
+ const msg = value;
5
+ return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end";
6
+ }
7
+ function defineAgent(handlers) {
8
+ process.on("message", (message) => {
9
+ if (!isParentMessage(message)) return;
10
+ void (async () => {
11
+ try {
12
+ switch (message.type) {
13
+ case "session_start":
14
+ await handlers.onSessionStart?.({
15
+ sessionId: message.sessionId,
16
+ env: message.env
17
+ });
18
+ break;
19
+ case "speech_event":
20
+ await handlers.onSpeechEvent?.(
21
+ { sessionId: message.sessionId },
22
+ message.event
23
+ );
24
+ if (message.event.type === "user_speech_final" && typeof message.event.text === "string" && message.event.text.trim()) {
25
+ await handlers.onUserSpeechFinal?.({
26
+ sessionId: message.sessionId,
27
+ text: message.event.text.trim()
28
+ });
29
+ }
30
+ break;
31
+ case "session_end":
32
+ await handlers.onSessionEnd?.({ sessionId: message.sessionId });
33
+ break;
34
+ }
35
+ } catch (error) {
36
+ const errMessage = error instanceof Error ? error.message : String(error);
37
+ process.send?.({
38
+ type: "agent_error",
39
+ sessionId: message.sessionId,
40
+ message: errMessage
41
+ });
42
+ }
43
+ })();
44
+ });
45
+ }
46
+ function speak(sessionId, text) {
47
+ process.send?.({ type: "speak", sessionId, text });
48
+ }
49
+ function agentLog(level, message) {
50
+ process.send?.({ type: "log", level, message });
51
+ }
52
+
53
+ // examples/agent.ts
54
+ var peers = /* @__PURE__ */ new Map();
55
+ function peerState(sessionId) {
56
+ let state = peers.get(sessionId);
57
+ if (!state) {
58
+ state = {
59
+ userSpeaking: false,
60
+ agentSpeaking: false,
61
+ lastPartial: "",
62
+ sttActive: false
63
+ };
64
+ peers.set(sessionId, state);
65
+ }
66
+ return state;
67
+ }
68
+ function formatSpeechDetail(speech) {
69
+ if (speech.text) return `"${speech.text}"`;
70
+ if (speech.error) return speech.error;
71
+ return "";
72
+ }
73
+ function handleSpeechEvent(sessionId, speech) {
74
+ const state = peerState(sessionId);
75
+ const detail = formatSpeechDetail(speech);
76
+ switch (speech.type) {
77
+ case "user_speaking_start":
78
+ state.userSpeaking = true;
79
+ agentLog("info", `[${sessionId}] user_speaking_start`);
80
+ break;
81
+ case "user_speaking_end":
82
+ state.userSpeaking = false;
83
+ agentLog("info", `[${sessionId}] user_speaking_end`);
84
+ break;
85
+ case "vad_triggered":
86
+ agentLog("info", `[${sessionId}] vad_triggered`);
87
+ break;
88
+ case "stt_stream_start":
89
+ agentLog("info", `[${sessionId}] stt_stream_start`);
90
+ break;
91
+ case "stt_stream_end":
92
+ agentLog("info", `[${sessionId}] stt_stream_end`);
93
+ break;
94
+ case "user_stt_start":
95
+ state.sttActive = true;
96
+ state.lastPartial = "";
97
+ agentLog("info", `[${sessionId}] user_stt_start`);
98
+ break;
99
+ case "user_stt_end":
100
+ state.sttActive = false;
101
+ agentLog("info", `[${sessionId}] user_stt_end`);
102
+ break;
103
+ case "user_stt_not_found":
104
+ agentLog("info", `[${sessionId}] user_stt_not_found`);
105
+ break;
106
+ case "user_speech_partial":
107
+ state.lastPartial = speech.text ?? "";
108
+ agentLog("info", `[${sessionId}] user_speech_partial ${detail}`);
109
+ break;
110
+ case "user_speech_final":
111
+ state.lastPartial = "";
112
+ agentLog("info", `[${sessionId}] user_speech_final ${detail}`);
113
+ break;
114
+ case "agent_speaking_start":
115
+ state.agentSpeaking = true;
116
+ agentLog("info", `[${sessionId}] agent_speaking_start`);
117
+ break;
118
+ case "agent_speaking_end":
119
+ state.agentSpeaking = false;
120
+ agentLog("info", `[${sessionId}] agent_speaking_end`);
121
+ break;
122
+ case "barge_in":
123
+ agentLog("info", `[${sessionId}] barge_in`);
124
+ break;
125
+ case "error":
126
+ agentLog("error", `[${sessionId}] error: ${speech.error ?? "unknown"}`);
127
+ break;
128
+ default: {
129
+ const _exhaustive = speech.type;
130
+ agentLog("error", `[${sessionId}] unhandled: ${String(_exhaustive)}`);
131
+ }
132
+ }
133
+ }
134
+ defineAgent({
135
+ onSessionStart({ sessionId }) {
136
+ speak(sessionId, "Hello! How can I help?");
137
+ },
138
+ onSpeechEvent({ sessionId }, speech) {
139
+ handleSpeechEvent(sessionId, speech);
140
+ },
141
+ onUserSpeechFinal({ sessionId, text }) {
142
+ speak(sessionId, `You said: ${text}`);
143
+ },
144
+ onSessionEnd({ sessionId }) {
145
+ peers.delete(sessionId);
146
+ agentLog("info", `session_end ${sessionId}`);
147
+ }
148
+ });
@@ -0,0 +1,4 @@
1
+ export type { SpeechEvent, SpeechEventListener, SpeechEventName, SpeechEventType, } from "@node-webrtc-rust/sdk/voice";
2
+ export { ALLOWED_CHILD_ENV_KEYS, type AgentErrorMessage, type AgentLogMessage, type AllowedChildEnvKey, type ChildToParentMessage, type ParentToChildMessage, type SessionEndMessage, type SessionStartMessage, type SpeakMessage, type SpeechEventMessage, } from "./protocol.js";
3
+ export { agentLog, defineAgent, speak, type AgentHandlers, type SessionContext, type SpeechContext, type SpeechEventContext, } from "./runtime.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,WAAW,EACX,mBAAmB,EACnB,eAAe,EACf,eAAe,GAChB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EACL,sBAAsB,EACtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,YAAY,EACjB,KAAK,kBAAkB,GACxB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,QAAQ,EACR,WAAW,EACX,KAAK,EACL,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { ALLOWED_CHILD_ENV_KEYS, } from "./protocol.js";
2
+ export { agentLog, defineAgent, speak, } from "./runtime.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,sBAAsB,GAUvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,QAAQ,EACR,WAAW,EACX,KAAK,GAKN,MAAM,cAAc,CAAC"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * IPC message shapes between trusted runner parent and isolated customer child.
3
+ *
4
+ * Transport: Node.js `process.send` / `process.on('message')` on a forked child.
5
+ * Speech payloads use {@link SpeechEvent} from `@node-webrtc-rust/sdk/voice` unchanged.
6
+ *
7
+ * IPC shapes are shared with [`voicethere/runner`](https://github.com/voicethere/runner).
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import type { SpeechEvent } from "@node-webrtc-rust/sdk/voice";
12
+ /**
13
+ * Messages the trusted runner parent may send to the sandboxed customer child.
14
+ *
15
+ * Register handlers via {@link defineAgent} in `@voicethere/agent` — do not read
16
+ * `process.on('message')` directly in customer bundles.
17
+ */
18
+ export type ParentToChildMessage = SessionStartMessage | SpeechEventMessage | SessionEndMessage;
19
+ /**
20
+ * Messages the customer child may send back to the runner parent.
21
+ *
22
+ * Prefer {@link speak} and {@link agentLog} helpers over raw `process.send`.
23
+ */
24
+ export type ChildToParentMessage = SpeakMessage | AgentLogMessage | AgentErrorMessage;
25
+ /**
26
+ * A WebRTC peer connected to the runner and mapped to this child process.
27
+ *
28
+ * Emitted once per `sessionId` before the first {@link SpeechEventMessage}.
29
+ * `env.SESSION_ID` matches {@link SessionStartMessage.sessionId}.
30
+ */
31
+ export interface SessionStartMessage {
32
+ type: "session_start";
33
+ /** Browser/signaling peer id for this conversation leg. */
34
+ sessionId: string;
35
+ /**
36
+ * Allowlisted environment variables copied from the runner process.
37
+ * Keys are a subset of {@link ALLOWED_CHILD_ENV_KEYS}.
38
+ */
39
+ env: Record<string, string>;
40
+ }
41
+ /**
42
+ * Forwards one speech lifecycle event from the parent Sherpa/VAD/STT/TTS pipeline.
43
+ *
44
+ * The {@link SpeechEvent} shape matches `@node-webrtc-rust/sdk/voice` — see SDK docs
45
+ * for `SpeechEventType` semantics (`user_speech_final`, `barge_in`, etc.).
46
+ *
47
+ * Delivered to customer code as `onSpeechEvent(ctx, message.event)`; `user_speech_final`
48
+ * also triggers the `onUserSpeechFinal` handler when `event.text` is non-empty.
49
+ */
50
+ export interface SpeechEventMessage {
51
+ type: "speech_event";
52
+ /** Peer/session id the event belongs to. */
53
+ sessionId: string;
54
+ /** Native pipeline event payload (`type`, optional `text` / `error`). */
55
+ event: SpeechEvent;
56
+ }
57
+ /**
58
+ * The peer disconnected or the runner is tearing down this session leg.
59
+ *
60
+ * Emitted when the runner unregisters a peer or tears down the session.
61
+ */
62
+ export interface SessionEndMessage {
63
+ type: "session_end";
64
+ /** Peer/session id that ended. */
65
+ sessionId: string;
66
+ }
67
+ /**
68
+ * Ask the parent to synthesize speech on the agent outbound WebRTC track.
69
+ *
70
+ * Handled by the runner parent, which synthesizes audio on the outbound WebRTC track.
71
+ * TTS does **not** run inside the sandboxed child.
72
+ */
73
+ export interface SpeakMessage {
74
+ type: "speak";
75
+ /** Target peer/session id (must match a prior {@link SessionStartMessage}). */
76
+ sessionId: string;
77
+ /** UTF-8 text passed to the parent TTS vendor. */
78
+ text: string;
79
+ }
80
+ /**
81
+ * Structured log line forwarded to runner stdout / Winston.
82
+ *
83
+ * Use {@link agentLog} instead of calling `process.send` directly.
84
+ */
85
+ export interface AgentLogMessage {
86
+ type: "log";
87
+ level: "info" | "error";
88
+ message: string;
89
+ }
90
+ /**
91
+ * Unhandled exception or rejected promise in customer agent code.
92
+ *
93
+ * The parent may play crash TTS and treat the child as failed. Prefer try/catch in
94
+ * handlers; use this only for fatal reporting.
95
+ */
96
+ export interface AgentErrorMessage {
97
+ type: "agent_error";
98
+ /** Peer/session id active when the error occurred. */
99
+ sessionId: string;
100
+ /** Human-readable error summary (no stack traces required). */
101
+ message: string;
102
+ }
103
+ /**
104
+ * Environment variable names the runner may inject into {@link SessionStartMessage.env}.
105
+ *
106
+ * The runner may add more project-specific keys over time; customer bundles must
107
+ * not read `process.env` directly — only the `env` object on session start.
108
+ */
109
+ export declare const ALLOWED_CHILD_ENV_KEYS: readonly ["SESSION_ID", "PROJECT_ID", "BUILD_ID"];
110
+ /** Union of allowlisted env key names. */
111
+ export type AllowedChildEnvKey = (typeof ALLOWED_CHILD_ENV_KEYS)[number];
112
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAE/D;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAC5B,mBAAmB,GACnB,kBAAkB,GAClB,iBAAiB,CAAC;AAEtB;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAC5B,YAAY,GACZ,eAAe,GACf,iBAAiB,CAAC;AAEtB;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,eAAe,CAAC;IACtB,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,cAAc,CAAC;IACrB,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,KAAK,EAAE,WAAW,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,aAAa,CAAC;IACpB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,+EAA+E;IAC/E,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,aAAa,CAAC;IACpB,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,mDAIzB,CAAC;AAEX,0CAA0C;AAC1C,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * IPC message shapes between trusted runner parent and isolated customer child.
3
+ *
4
+ * Transport: Node.js `process.send` / `process.on('message')` on a forked child.
5
+ * Speech payloads use {@link SpeechEvent} from `@node-webrtc-rust/sdk/voice` unchanged.
6
+ *
7
+ * IPC shapes are shared with [`voicethere/runner`](https://github.com/voicethere/runner).
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ /**
12
+ * Environment variable names the runner may inject into {@link SessionStartMessage.env}.
13
+ *
14
+ * The runner may add more project-specific keys over time; customer bundles must
15
+ * not read `process.env` directly — only the `env` object on session start.
16
+ */
17
+ export const ALLOWED_CHILD_ENV_KEYS = [
18
+ "SESSION_ID",
19
+ "PROJECT_ID",
20
+ "BUILD_ID",
21
+ ];
22
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AA6GH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,YAAY;IACZ,YAAY;IACZ,UAAU;CACF,CAAC"}
@@ -0,0 +1,32 @@
1
+ import type { SpeechEvent } from "@node-webrtc-rust/sdk/voice";
2
+ export interface SessionContext {
3
+ sessionId: string;
4
+ env: Record<string, string>;
5
+ }
6
+ export interface SpeechContext {
7
+ sessionId: string;
8
+ text: string;
9
+ }
10
+ export interface SpeechEventContext {
11
+ sessionId: string;
12
+ }
13
+ export interface AgentHandlers {
14
+ onSessionStart?: (ctx: SessionContext) => void | Promise<void>;
15
+ /** Fired for every speech lifecycle event from the parent voice pipeline. */
16
+ onSpeechEvent?: (ctx: SpeechEventContext, event: SpeechEvent) => void | Promise<void>;
17
+ /** Convenience handler — also invoked when `speech.type` is `user_speech_final`. */
18
+ onUserSpeechFinal?: (ctx: SpeechContext) => void | Promise<void>;
19
+ onSessionEnd?: (ctx: {
20
+ sessionId: string;
21
+ }) => void | Promise<void>;
22
+ }
23
+ /**
24
+ * Register IPC handlers for a customer agent child process.
25
+ * Call once at bundle entry; runner parent sends {@link ParentToChildMessage} events.
26
+ */
27
+ export declare function defineAgent(handlers: AgentHandlers): void;
28
+ /** Ask the runner parent to synthesize speech for the session. */
29
+ export declare function speak(sessionId: string, text: string): void;
30
+ /** Structured log forwarded to the runner parent. */
31
+ export declare function agentLog(level: "info" | "error", message: string): void;
32
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAI/D,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,6EAA6E;IAC7E,aAAa,CAAC,EAAE,CACd,GAAG,EAAE,kBAAkB,EACvB,KAAK,EAAE,WAAW,KACf,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrE;AAYD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,CA4CzD;AAED,kEAAkE;AAClE,wBAAgB,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAE3D;AAED,qDAAqD;AACrD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAEvE"}
@@ -0,0 +1,61 @@
1
+ function isParentMessage(value) {
2
+ if (!value || typeof value !== "object")
3
+ return false;
4
+ const msg = value;
5
+ return (msg.type === "session_start" ||
6
+ msg.type === "speech_event" ||
7
+ msg.type === "session_end");
8
+ }
9
+ /**
10
+ * Register IPC handlers for a customer agent child process.
11
+ * Call once at bundle entry; runner parent sends {@link ParentToChildMessage} events.
12
+ */
13
+ export function defineAgent(handlers) {
14
+ process.on("message", (message) => {
15
+ if (!isParentMessage(message))
16
+ return;
17
+ void (async () => {
18
+ try {
19
+ switch (message.type) {
20
+ case "session_start":
21
+ await handlers.onSessionStart?.({
22
+ sessionId: message.sessionId,
23
+ env: message.env,
24
+ });
25
+ break;
26
+ case "speech_event":
27
+ await handlers.onSpeechEvent?.({ sessionId: message.sessionId }, message.event);
28
+ if (message.event.type === "user_speech_final" &&
29
+ typeof message.event.text === "string" &&
30
+ message.event.text.trim()) {
31
+ await handlers.onUserSpeechFinal?.({
32
+ sessionId: message.sessionId,
33
+ text: message.event.text.trim(),
34
+ });
35
+ }
36
+ break;
37
+ case "session_end":
38
+ await handlers.onSessionEnd?.({ sessionId: message.sessionId });
39
+ break;
40
+ }
41
+ }
42
+ catch (error) {
43
+ const errMessage = error instanceof Error ? error.message : String(error);
44
+ process.send?.({
45
+ type: "agent_error",
46
+ sessionId: message.sessionId,
47
+ message: errMessage,
48
+ });
49
+ }
50
+ })();
51
+ });
52
+ }
53
+ /** Ask the runner parent to synthesize speech for the session. */
54
+ export function speak(sessionId, text) {
55
+ process.send?.({ type: "speak", sessionId, text });
56
+ }
57
+ /** Structured log forwarded to the runner parent. */
58
+ export function agentLog(level, message) {
59
+ process.send?.({ type: "log", level, message });
60
+ }
61
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AA8BA,SAAS,eAAe,CAAC,KAAc;IACrC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IACtD,MAAM,GAAG,GAAG,KAA0B,CAAC;IACvC,OAAO,CACL,GAAG,CAAC,IAAI,KAAK,eAAe;QAC5B,GAAG,CAAC,IAAI,KAAK,cAAc;QAC3B,GAAG,CAAC,IAAI,KAAK,aAAa,CAC3B,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,QAAuB;IACjD,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,OAAgB,EAAE,EAAE;QACzC,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC;YAAE,OAAO;QAEtC,KAAK,CAAC,KAAK,IAAI,EAAE;YACf,IAAI,CAAC;gBACH,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;oBACrB,KAAK,eAAe;wBAClB,MAAM,QAAQ,CAAC,cAAc,EAAE,CAAC;4BAC9B,SAAS,EAAE,OAAO,CAAC,SAAS;4BAC5B,GAAG,EAAE,OAAO,CAAC,GAAG;yBACjB,CAAC,CAAC;wBACH,MAAM;oBACR,KAAK,cAAc;wBACjB,MAAM,QAAQ,CAAC,aAAa,EAAE,CAC5B,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,EAChC,OAAO,CAAC,KAAK,CACd,CAAC;wBACF,IACE,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,mBAAmB;4BAC1C,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,QAAQ;4BACtC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,EACzB,CAAC;4BACD,MAAM,QAAQ,CAAC,iBAAiB,EAAE,CAAC;gCACjC,SAAS,EAAE,OAAO,CAAC,SAAS;gCAC5B,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE;6BAChC,CAAC,CAAC;wBACL,CAAC;wBACD,MAAM;oBACR,KAAK,aAAa;wBAChB,MAAM,QAAQ,CAAC,YAAY,EAAE,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;wBAChE,MAAM;gBACV,CAAC;YACH,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,UAAU,GACd,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACzD,OAAO,CAAC,IAAI,EAAE,CAAC;oBACb,IAAI,EAAE,aAAa;oBACnB,SAAS,EAAE,OAAO,CAAC,SAAS;oBAC5B,OAAO,EAAE,UAAU;iBACpB,CAAC,CAAC;YACL,CAAC;QACH,CAAC,CAAC,EAAE,CAAC;IACP,CAAC,CAAC,CAAC;AACL,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,KAAK,CAAC,SAAiB,EAAE,IAAY;IACnD,OAAO,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;AACrD,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,QAAQ,CAAC,KAAuB,EAAE,OAAe;IAC/D,OAAO,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;AAClD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@voicethere/agent",
3
+ "version": "0.1.0",
4
+ "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "templates",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json && esbuild examples/agent.ts --bundle --platform=node --format=esm --outfile=dist/agent.js",
19
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json",
20
+ "test": "vitest run",
21
+ "test:ci": "npm run typecheck && npm run build && npm run test",
22
+ "verify:local": "npm run build && tsx scripts/verify-local.ts",
23
+ "verify:local:only": "tsx scripts/verify-local.ts",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/voicethere/agent.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/voicethere/agent/issues"
32
+ },
33
+ "homepage": "https://github.com/voicethere/agent#readme",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "keywords": [
38
+ "voicethere",
39
+ "voice-agent",
40
+ "webrtc",
41
+ "sandbox"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "license": "UNLICENSED",
47
+ "dependencies": {
48
+ "@node-webrtc-rust/sdk": "0.5.2"
49
+ },
50
+ "peerDependencies": {
51
+ "@node-webrtc-rust/sdk": ">=0.5.2"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.10.0",
55
+ "esbuild": "^0.25.12",
56
+ "tsx": "^4.19.2",
57
+ "typescript": "^5.7.0",
58
+ "vitest": "^2.1.8"
59
+ }
60
+ }
@@ -0,0 +1,26 @@
1
+ # Agent templates
2
+
3
+ ## `agent.ts`
4
+
5
+ Full starter bundle covering every speech event from `@node-webrtc-rust/sdk/voice`:
6
+
7
+ | Group | Events |
8
+ | ----------- | -------------------------------------------------------------------------------------------- |
9
+ | User VAD | `user_speaking_start`, `user_speaking_end`, `vad_triggered` |
10
+ | STT stream | `stt_stream_start`, `stt_stream_end`, `user_stt_start`, `user_stt_end`, `user_stt_not_found` |
11
+ | Transcripts | `user_speech_partial`, `user_speech_final` |
12
+ | Agent TTS | `agent_speaking_start`, `agent_speaking_end`, `barge_in` |
13
+ | Failures | `error` |
14
+
15
+ **Customize:** replace `onUserSpeechFinal` body with your LLM/tools; extend `PeerState` or swap `handleSpeechEvent` for your architecture.
16
+
17
+ **Build:**
18
+
19
+ ```bash
20
+ npm install @voicethere/agent esbuild
21
+ npx esbuild agent.ts --bundle --platform=node --format=esm --outfile=dist/agent.js
22
+ ```
23
+
24
+ **Verify sandbox (no WebRTC):** from the agent repo, `npm run verify:local` after building your bundle.
25
+
26
+ **Voice E2E:** host with [`voicethere/runner`](https://github.com/voicethere/runner) — set `AGENT_BUNDLE_PATH` to your built `dist/agent.js`.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Full starter template — copy into your project and build to a single `agent.js` bundle.
3
+ *
4
+ * Handles every `SpeechEvent` from the runner voice pipeline (`@node-webrtc-rust/sdk/voice`).
5
+ * Replace the stub bodies with your product logic; keep `onUserSpeechFinal` as the main turn hook.
6
+ *
7
+ * Build:
8
+ * npm install @voicethere/agent
9
+ * npx esbuild agent.ts --bundle --platform=node --format=esm --outfile=dist/agent.js
10
+ *
11
+ * Local runner (clone https://github.com/voicethere/runner):
12
+ * AGENT_BUNDLE_PATH=./dist/agent.js npm run start
13
+ */
14
+
15
+ import {
16
+ agentLog,
17
+ defineAgent,
18
+ speak,
19
+ type SpeechEvent,
20
+ } from "@voicethere/agent";
21
+
22
+ /** Per-peer conversational state — extend or replace with your store. */
23
+ interface PeerState {
24
+ userSpeaking: boolean;
25
+ agentSpeaking: boolean;
26
+ lastPartial: string;
27
+ sttActive: boolean;
28
+ }
29
+
30
+ const peers = new Map<string, PeerState>();
31
+
32
+ function peerState(sessionId: string): PeerState {
33
+ let state = peers.get(sessionId);
34
+ if (!state) {
35
+ state = {
36
+ userSpeaking: false,
37
+ agentSpeaking: false,
38
+ lastPartial: "",
39
+ sttActive: false,
40
+ };
41
+ peers.set(sessionId, state);
42
+ }
43
+ return state;
44
+ }
45
+
46
+ function formatSpeechDetail(speech: SpeechEvent): string {
47
+ if (speech.text) return `"${speech.text}"`;
48
+ if (speech.error) return speech.error;
49
+ return "";
50
+ }
51
+
52
+ /**
53
+ * Dispatch every speech lifecycle event from the parent Sherpa/VAD/STT/TTS pipeline.
54
+ * `speech.type` values match `SpeechEventType` in `@node-webrtc-rust/sdk/voice`.
55
+ */
56
+ function handleSpeechEvent(sessionId: string, speech: SpeechEvent): void {
57
+ const state = peerState(sessionId);
58
+ const detail = formatSpeechDetail(speech);
59
+
60
+ switch (speech.type) {
61
+ // --- User turn (VAD + STT) ---
62
+ case "user_speaking_start":
63
+ state.userSpeaking = true;
64
+ agentLog("info", `[${sessionId}] user_speaking_start`);
65
+ break;
66
+
67
+ case "user_speaking_end":
68
+ state.userSpeaking = false;
69
+ agentLog("info", `[${sessionId}] user_speaking_end`);
70
+ break;
71
+
72
+ case "vad_triggered":
73
+ agentLog(
74
+ "info",
75
+ `[${sessionId}] vad_triggered — STT listen window opened`,
76
+ );
77
+ break;
78
+
79
+ case "stt_stream_start":
80
+ agentLog("info", `[${sessionId}] stt_stream_start`);
81
+ break;
82
+
83
+ case "stt_stream_end":
84
+ agentLog("info", `[${sessionId}] stt_stream_end`);
85
+ break;
86
+
87
+ case "user_stt_start":
88
+ state.sttActive = true;
89
+ state.lastPartial = "";
90
+ agentLog("info", `[${sessionId}] user_stt_start`);
91
+ break;
92
+
93
+ case "user_stt_end":
94
+ state.sttActive = false;
95
+ agentLog("info", `[${sessionId}] user_stt_end`);
96
+ break;
97
+
98
+ case "user_stt_not_found":
99
+ agentLog(
100
+ "info",
101
+ `[${sessionId}] user_stt_not_found — no speech recognized in listen window`,
102
+ );
103
+ // Optional: speak(sessionId, "I didn't catch that. Could you repeat?")
104
+ break;
105
+
106
+ case "user_speech_partial":
107
+ state.lastPartial = speech.text ?? "";
108
+ agentLog("info", `[${sessionId}] user_speech_partial ${detail}`);
109
+ // Optional: live captions, early intent detection, custom barge-in rules
110
+ break;
111
+
112
+ case "user_speech_final":
113
+ state.lastPartial = "";
114
+ agentLog("info", `[${sessionId}] user_speech_final ${detail}`);
115
+ // Primary turn boundary — `onUserSpeechFinal` runs after this for convenience
116
+ break;
117
+
118
+ // --- Agent playback (TTS) ---
119
+ case "agent_speaking_start":
120
+ state.agentSpeaking = true;
121
+ agentLog("info", `[${sessionId}] agent_speaking_start`);
122
+ break;
123
+
124
+ case "agent_speaking_end":
125
+ state.agentSpeaking = false;
126
+ agentLog("info", `[${sessionId}] agent_speaking_end`);
127
+ break;
128
+
129
+ case "barge_in":
130
+ agentLog(
131
+ "info",
132
+ `[${sessionId}] barge_in — user interrupted agent playback`,
133
+ );
134
+ // Optional: cancel in-flight LLM/TTS work keyed by sessionId
135
+ break;
136
+
137
+ case "error":
138
+ agentLog(
139
+ "error",
140
+ `[${sessionId}] pipeline error: ${speech.error ?? "unknown"}`,
141
+ );
142
+ break;
143
+
144
+ default: {
145
+ const _exhaustive: never = speech.type;
146
+ agentLog(
147
+ "error",
148
+ `[${sessionId}] unhandled speech event: ${String(_exhaustive)}`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+
154
+ defineAgent({
155
+ onSessionStart({ sessionId, env }) {
156
+ agentLog("info", `session_start ${sessionId} env=${JSON.stringify(env)}`);
157
+ speak(sessionId, "Hello! How can I help?");
158
+ },
159
+
160
+ onSpeechEvent({ sessionId }, speech) {
161
+ handleSpeechEvent(sessionId, speech);
162
+ },
163
+
164
+ onUserSpeechFinal({ sessionId, text }) {
165
+ // Replace with LLM / tool calls / business logic
166
+ speak(sessionId, `You said: ${text}`);
167
+ },
168
+
169
+ onSessionEnd({ sessionId }) {
170
+ peers.delete(sessionId);
171
+ agentLog("info", `session_end ${sessionId}`);
172
+ },
173
+ });