@byokit/realtime 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 (2026-09-30)
6
+
7
+
8
+
9
+ - FIX: Make each realtime README example independently typecheck against the package exports.
10
+ - FIX: Start phone WebRTC with React Native AbortSignal implementations that have no throwIfAborted method.
11
+ - FIX: Report connected when playback has nothing to drain, and flush queued speech after audio clears while muted.
12
+ - FIX: Offer opt-in PCM capture and playback-tail retention across carrier reconnects, releasing retained media on stop.
13
+ - FIX: Apply app-supplied hangup policy on every route and end the session after the next agent transcript.
14
+ - FIX: Use the host tool watchdog and failure wording instead of a competing fixed 300-second child deadline.
15
+ - FIX: Keep ChatGPT delegations and retransmission results across ordinary user turns; explicit interruption and close still cancel them.
16
+ - FIX: Ignore empty ChatGPT transcripts instead of emitting a failure line or clearing audio.
17
+ - FIX: Drain child output before reporting process disconnection so the provider's final close reason survives.
18
+ - FIX: Preserve transcripts up to 4,000 UTF-8 bytes and bound state details by bytes without splitting Unicode characters.
19
+ - FIX: Preserve sanitized credential-error remedies and accept app-supplied auth failure wording.
20
+ - FIX: Overlap ChatGPT media preparation, child startup and access lookup, keep early offers behind credential configuration, and allow explicit app signaling identity.
21
+ - FIX: Route semantic app tool replies to the host bridge and cancel pending app requests when the session closes.
22
+ - Add structured delegation through app-supplied tools, preserving named targets and operation ids.
23
+ - Add a bounded, read-only advisory auth check that never gates session startup or initiates login.
5
24
  - Add interruption fencing and tool cancellation, bounded provider and transport reconnects, and fresh-call ChatGPT subscription voice interruption.
6
25
 
7
26
  ## 0.1.0 (2026-09-30)
package/README.md CHANGED
@@ -32,10 +32,176 @@ const client = realtimeClient({
32
32
 
33
33
  For ChatGPT voice register a `delegate` tool handler receiving `{ request }` and supply the app's planner. API routes use `auth: { kind: 'key', key }`, with billing per use. The subscription route uses the person's ChatGPT sign-in from `@byokit/accounts`; it never reads Codex credentials or starts the Codex CLI. Backend refusal is reported as `not-included`.
34
34
 
35
+ ## Structured delegation and named targets
36
+
37
+ `delegationHandler` dispatches a JSON request of the form `{ name, arguments }`
38
+ directly through an app-supplied `toolBridge`. For ordinary spoken requests it
39
+ uses the optional app planner. Invalid structured requests never enter the planner.
40
+ Keep the action bridge separate from the outer `delegate` bridge:
41
+
42
+ ```ts
43
+ import { delegationHandler, toolBridge } from '@byokit/realtime/node';
44
+ import type { ToolHandler } from '@byokit/realtime/node';
45
+ import type { RealtimeHostFrame } from '@byokit/realtime';
46
+
47
+ declare const emit: (frame: RealtimeHostFrame) => void;
48
+ declare const sendAuthorizedMessage: ToolHandler;
49
+ declare const plan: (request: string, context: Parameters<ToolHandler>[1]) => Promise<string>;
50
+ const actionTools = [{ name: 'message', description: 'Message a named agent', parameters: {
51
+ type: 'object', properties: { agent: { type: 'string' }, text: { type: 'string' } },
52
+ required: ['agent', 'text'], additionalProperties: false,
53
+ } }];
54
+ const actions = toolBridge({ tools: actionTools, handlers: { message: sendAuthorizedMessage },
55
+ emit, failure: () => 'The message could not be confirmed.', timeoutFor: () => 75000 });
56
+ const delegate = delegationHandler({ bridge: actions, plan });
57
+ const tools = [{ name: 'delegate', description: 'Delegate a spoken request', parameters: { type: 'object' } }];
58
+ const bridge = toolBridge({ tools, handlers: { delegate }, emit,
59
+ timeoutFor: () => 340000, failure: () => 'The request could not be confirmed.' });
60
+ // Pass the outer bridge and its delegate tool to realtimeEngine.
61
+ // A structured request can be:
62
+ // {"name":"message","arguments":{"agent":"Avery","text":"Please report progress"}}
63
+ // Close actions as well as the outer bridge when the session ends.
64
+ ```
65
+
66
+ The kit preserves arguments and operation ids; retries with the same call id
67
+ share a result. Named targets, target lookup, ambiguity checks, authorization,
68
+ argument validation and mutation receipts belong to the app's handlers. Tool
69
+ JSON schemas describe the provider catalog; they are not runtime authorization.
70
+ The app planner owns its model, conversation memory, tool budget and deadline;
71
+ the kit does not add a planner model or any agent runtime dependency. Aborted
72
+ calls must stop in the app too; completed side effects are never rolled back.
73
+ The host bridge owns every tool watchdog and failure message; the child has no
74
+ second deadline. A 340-second planner or a 290-second watch can therefore use
75
+ the app's declared budget. An ordinary ChatGPT user turn preserves unfinished
76
+ delegations and their retransmission cache. Explicit `interrupt` or close aborts
77
+ them; a new turn id still identifies a new request.
78
+
79
+ ## Advisory auth check
80
+
81
+ Pass a read-only saved-sign-in lookup as `authCheck` on the engine. It starts
82
+ independently, is never awaited by startup, and cannot close the session. It
83
+ does not call `auth.access`, refresh a token, run a CLI or initiate login.
84
+ The app's `peek` callback must also never refresh, prompt or start login.
85
+
86
+ ```ts
87
+ import { realtimeEngine, toolBridge } from '@byokit/realtime/node';
88
+ import type { RealtimeAuth, RealtimeHostFrame, RealtimeTool } from '@byokit/realtime';
89
+
90
+ declare const auth: Extract<RealtimeAuth, { kind: 'plan' }>;
91
+ declare const tools: RealtimeTool[];
92
+ declare const bridge: ReturnType<typeof toolBridge>;
93
+ declare const emit: (frame: RealtimeHostFrame) => void;
94
+ declare const peekSavedSignIn: (signal: AbortSignal) => Promise<boolean>;
95
+ declare const updateSettingsBadge: (status: 'ready' | 'signed-out' | 'unknown') => void;
96
+ const engine = realtimeEngine({
97
+ engine: 'chatgpt', auth,
98
+ tools, bridge, emit,
99
+ authCheck: { peek: peekSavedSignIn, onStatus: status => updateSettingsBadge(status) },
100
+ });
101
+ ```
102
+
103
+ `ready` means the app reports a saved sign-in, not provider entitlement.
104
+ `signed-out` means the lookup returned false. Errors or a timeout produce
105
+ `unknown`; the default timeout is one second, capped at ten seconds. Close
106
+ cancels pending checks and suppresses late notifications. Normal session
107
+ authentication still needs the credential from `auth.access`.
108
+ For a standalone settings lookup, `realtimeAuthCheck({ peek })` is also exported
109
+ from the portable entry and returns `{ result, close }`. Do not await this check
110
+ before opening a voice session or show a login prompt from its result.
111
+
112
+ ## Lifecycle announcements and semantic app tools
113
+
114
+ Feed app-confirmed lifecycle reports into the existing speech path. The kit
115
+ does not watch agents or infer their outcome:
116
+
117
+ ```ts
118
+ import type { realtimeClient } from '@byokit/realtime';
119
+ import type { realtimeEngine } from '@byokit/realtime/node';
120
+ declare const client: ReturnType<typeof realtimeClient>;
121
+ declare const engine: ReturnType<typeof realtimeEngine>;
122
+
123
+ // Called by the app's lifecycle subscription; use bounded, sanitized app wording.
124
+ client.speak('Avery has finished the task.');
125
+ // A host can inject the same report directly:
126
+ engine.receive({ type: 'realtime.say', text: 'Avery has finished the task.' });
127
+ ```
128
+
129
+ `speak` queues at most sixteen reports until the call is ready, waits for local
130
+ playback drain and does not replay sent speech on reconnect. `onTurn` observes
131
+ transcripts and `onStatus` observes playback states; a transcript alone does
132
+ not prove the report was audible. The app owns push delivery, deduplication,
133
+ waking a sleeping call and stopping it after reports.
134
+ When `player.finish(callback)` returns false, the client reports connected
135
+ immediately. True means the player will call back after draining. Clearing
136
+ audio also rechecks queued speech, including while the microphone is muted.
137
+
138
+ Compose `appBridge` with `toolBridge` to round-trip semantic phone requests:
139
+
140
+ ```ts
141
+ import { appBridge, toolBridge } from '@byokit/realtime/node';
142
+ import type { RealtimeHostFrame } from '@byokit/realtime';
143
+ declare const emit: (frame: RealtimeHostFrame) => void;
144
+ const app = appBridge(emit);
145
+ const navigation = [{ name: 'navigate', description: 'Open an app destination',
146
+ parameters: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'] } }];
147
+ const bridge = toolBridge({ tools: navigation, app, emit,
148
+ handlers: { navigate: (args, { signal }) => app.run('navigate', String(args.target), signal) },
149
+ failure: () => 'The app could not complete that request.',
150
+ });
151
+ // client.onAppRequest handles view/navigate/activate; forward all client frames
152
+ // to engine.receive, which routes app results back to this app bridge.
153
+ // Closing the engine also cancels pending app requests.
154
+ ```
155
+
35
156
  The main entry is portable for web and React Native and loads no native code. `./webrtc` supplies `webRtcPeer` for browsers and selects its native implementation under the `react-native` condition; install the optional `react-native-webrtc` 124.0.8 peer in the RN app. All media APIs can be injected for tests. `./node` requires Node 22.18+ and is for the credential host. It starts its own adapter child with an empty environment; credentials travel over private stdin. No provider key or plan token is sent to the device.
36
157
 
37
158
  `AudioPorts` requires microphone acquisition/release, PCM capture, playback admission/drain and audio routing. The app owns permissions and its microphone service. Acquire must resolve only after that service is ready. The client awaits it before capture. Each client owns its ports; the app must arbitrate shared microphones.
38
159
 
39
160
  Close the client and engine when the call ends. `client.interrupt()` clears playback and aborts unfinished tools; old response output is fenced. Transient provider and transport failures retry twice, after 500 and 1,000 ms, with a fresh budget after 30 healthy seconds. Sent audio, speech and tool calls are never replayed. The app can mark transport closure terminal with `retryableClose`. Explicit ChatGPT interruption opens a fresh call; explicit Gemini interruption rotates its provider session. Native speech interruption remains provider-driven. No STT/LLM/TTS chain is exported. See [the lift contract](../../docs/realtime-kit.md) for bounds, events and usage.
40
161
 
162
+ ## Reconnect and host policy
163
+
164
+ Set `preserveMediaOnReconnect: true` on the client to retain a ready PCM
165
+ recorder, microphone lease, audio route and playback tail across a carrier
166
+ reconnect. Audio captured while the carrier is down is discarded. Pending
167
+ microphone acquisition still releases before retry; WebRTC always creates a
168
+ fresh peer for a fresh call. A changed PCM format releases the old recorder
169
+ before acquiring another. Stop releases retained media even during backoff.
170
+ The default remains release/clear/reacquire.
171
+
172
+ The engine accepts app phrase policy and credential-error wording:
173
+
174
+ ```ts
175
+ import { realtimeEngine, toolBridge } from '@byokit/realtime/node';
176
+ import type { RealtimeAuth, RealtimeHostFrame, RealtimeTool } from '@byokit/realtime';
177
+ declare const auth: Extract<RealtimeAuth, { kind: 'plan' }>;
178
+ declare const tools: RealtimeTool[];
179
+ declare const bridge: ReturnType<typeof toolBridge>;
180
+ declare const emit: (frame: RealtimeHostFrame) => void;
181
+
182
+ const engine = realtimeEngine({ engine: 'chatgpt',
183
+ auth, tools, bridge, emit,
184
+ hangup: text => /^goodbye[.!]?$/i.test(text.trim()),
185
+ authFailure: () => 'Sign in again from Settings.',
186
+ signalingIdentity: { originator: 'voice-app', userAgent: 'voice-app/1' },
187
+ });
188
+ ```
189
+
190
+ Hangup ends after the next completed agent transcript so the provider can
191
+ give a farewell. It is a transcript-level policy, not proof that playback
192
+ drained. Credential errors are sanitized, bounded and emitted as the close
193
+ reason; the kit initiates no login or prompt. Transcripts preserve up to
194
+ 4,000 UTF-8 bytes, state details up to 500 bytes, and close reasons up to
195
+ 2,048 bytes. Empty ChatGPT transcripts are ignored.
196
+
197
+ ChatGPT emits `realtime.webrtc.start` while the isolated child starts and
198
+ access is being resolved. Device frames remain bounded and queued until the
199
+ credential/config line is sent. This overlaps local setup without sending a
200
+ signaling request before credentials are available. `signalingIdentity`
201
+ lets the app name its own originator/user-agent; the default for both is
202
+ `byokit`, and identity values must be printable ASCII of at most 160 characters.
203
+ Credentials, endpoint restrictions and redirect rejection are unchanged.
204
+ The identity option allows a consumer to compare signaling latency; offline
205
+ tests prove startup ordering, not a reduction in the remote provider's POST time.
206
+
41
207
  For an explicitly live microphone and subscription-provider proof, run [the bounded voice demo](../../examples/realtime-voice/README.md). It signs in through accounts, keeps credentials only in memory, and records connection facts and turn counts. Offline tests do not establish a live pass.
package/dist/adapter.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { toolBridge } from './tools.ts';
2
2
  import type { RealtimeTool } from './types.ts';
3
+ export type RealtimeSignalingIdentity = {
4
+ originator?: string;
5
+ userAgent?: string;
6
+ };
3
7
  export type AdapterOptions = {
4
8
  key: string;
5
9
  instructions: string;
@@ -10,4 +14,5 @@ export type AdapterOptions = {
10
14
  endpoint?: string;
11
15
  redact?: RegExp[];
12
16
  hangup?: (text: string) => boolean;
17
+ signalingIdentity?: RealtimeSignalingIdentity;
13
18
  };
package/dist/auth.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type RealtimeAuthStatus = 'ready' | 'signed-out' | 'unknown';
2
+ export type RealtimeAuthCheckOptions = {
3
+ /** Read-only saved-sign-in check. Must never start login, refresh access or prompt. */
4
+ peek(signal: AbortSignal): Promise<boolean>;
5
+ onStatus?(status: RealtimeAuthStatus): void;
6
+ /** Defaults to 1 second; capped at 10 seconds. */
7
+ timeoutMs?: number;
8
+ };
9
+ /** Advisory only: session startup must proceed independently of this check. */
10
+ export declare function realtimeAuthCheck(options: RealtimeAuthCheckOptions): {
11
+ result: Promise<RealtimeAuthStatus>;
12
+ close(): void;
13
+ };
package/dist/auth.js ADDED
@@ -0,0 +1,29 @@
1
+ /** Advisory only: session startup must proceed independently of this check. */
2
+ export function realtimeAuthCheck(options) {
3
+ const controller = new AbortController();
4
+ let settled = false;
5
+ let resolve;
6
+ const result = new Promise(done => { resolve = done; });
7
+ const finish = (status, notify = true) => {
8
+ if (settled)
9
+ return;
10
+ settled = true;
11
+ clearTimeout(timer);
12
+ controller.abort();
13
+ resolve(status);
14
+ // A settings observer cannot fail a call or create an unhandled rejection.
15
+ if (notify) {
16
+ try {
17
+ options.onStatus?.(status);
18
+ }
19
+ catch { }
20
+ }
21
+ };
22
+ const timeout = options.timeoutMs ?? 1000;
23
+ const timer = setTimeout(() => finish('unknown'), Number.isFinite(timeout) ? Math.min(10000, Math.max(1, timeout)) : 1000);
24
+ void Promise.resolve().then(() => {
25
+ if (!settled)
26
+ return options.peek(controller.signal);
27
+ }).then(value => finish(value === true ? 'ready' : value === false ? 'signed-out' : 'unknown'), () => finish('unknown'));
28
+ return { result, close() { finish('unknown', false); } };
29
+ }
package/dist/chatgpt.js CHANGED
@@ -1,5 +1,4 @@
1
- import { responseFence } from "./policy.js";
2
- import { cleanProse } from "./prose.js";
1
+ import { cleanProse, cleanProseBytes } from "./prose.js";
3
2
  import { MAX_REALTIME_SDP_BYTES } from "./frames.js";
4
3
  export const CHATGPT_SIGNALING_URL = 'https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas';
5
4
  function emit(frame) { process.stdout.write(`${JSON.stringify(frame)}\n`); }
@@ -36,7 +35,6 @@ export async function boundedBody(response, max = MAX_REALTIME_SDP_BYTES) {
36
35
  return new TextDecoder().decode(bytes);
37
36
  }
38
37
  export function createChatgptAdapter(options) {
39
- const fence = responseFence();
40
38
  const lifetime = new AbortController();
41
39
  let offered = false, closed = false;
42
40
  const safe = (text) => cleanProse(text, 'Voice could not complete that request.', 4000, options.redact);
@@ -57,7 +55,7 @@ export function createChatgptAdapter(options) {
57
55
  send({ type: id ? 'delegation.context.append' : 'session.context.append', ...(id ? { delegation_item_id: id } : {}), channel: 'speakable', content: [{ type: 'input_text', text }] });
58
56
  };
59
57
  const close = (reason = 'Voice ended.', retryable = false) => { if (closed)
60
- return; closed = true; fence.interrupt(); lifetime.abort(); options.bridge.close(); emit({ type: 'realtime.closed', reason: safe(reason), ...(retryable ? { retryable: true } : {}) }); };
58
+ return; closed = true; lifetime.abort(); options.bridge.close(); emit({ type: 'realtime.closed', reason: cleanProseBytes(reason, 'Voice ended.', 2048, options.redact), ...(retryable ? { retryable: true } : {}) }); };
61
59
  const signalOffer = async (sdp) => {
62
60
  if (offered)
63
61
  throw new Error('Duplicate voice offer.');
@@ -68,7 +66,7 @@ export function createChatgptAdapter(options) {
68
66
  throw new Error('Unsupported voice signaling origin.');
69
67
  const response = await fetch(url, {
70
68
  method: 'POST', redirect: 'error', signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(20000)]),
71
- headers: { Authorization: `Bearer ${options.key}`, 'chatgpt-account-id': options.accountId, originator: 'byokit', 'User-Agent': 'byokit', 'OpenAI-Alpha': 'quicksilver=v2', 'content-type': 'application/json' },
69
+ headers: { Authorization: `Bearer ${options.key}`, 'chatgpt-account-id': options.accountId, originator: options.signalingIdentity?.originator ?? 'byokit', 'User-Agent': options.signalingIdentity?.userAgent ?? 'byokit', 'OpenAI-Alpha': 'quicksilver=v2', 'content-type': 'application/json' },
72
70
  body: JSON.stringify({ sdp, session: { model: options.model ?? 'gpt-live-1-codex', instructions: options.instructions, audio: { output: { voice: options.voice ?? 'sol' } }, delegation: { type: 'client', ack_filler: false } } }),
73
71
  });
74
72
  const answer = await boundedBody(response);
@@ -85,13 +83,13 @@ export function createChatgptAdapter(options) {
85
83
  // Provider event vocabulary stays inside the credential child.
86
84
  const event = JSON.parse(raw);
87
85
  if (event.type === 'turn.done' && typeof event.turn?.transcript === 'string') {
86
+ const text = cleanProseBytes(event.turn.transcript, '', 4000, options.redact);
87
+ if (!text)
88
+ return;
88
89
  const role = event.turn.role === 'assistant' ? 'agent' : 'user';
89
- if (role === 'user') {
90
- fence.begin();
91
- turns.clear();
90
+ if (role === 'user')
92
91
  emit({ type: 'realtime.audio.clear' });
93
- }
94
- emit({ type: 'realtime.transcript', role, text: safe(event.turn.transcript) });
92
+ emit({ type: 'realtime.transcript', role, text });
95
93
  if (role === 'agent')
96
94
  options.bridge.answered();
97
95
  options.bridge.state(role === 'agent' ? 'connected' : 'thinking');
@@ -109,20 +107,18 @@ export function createChatgptAdapter(options) {
109
107
  return;
110
108
  const turn = event.item.user_bidi_turn_id;
111
109
  const share = typeof turn === 'string' && turn.trim() && turn.length <= 128 ? JSON.stringify([turn.trim(), request.trim()]) : undefined;
112
- const signal = fence.signal;
113
110
  let promise = share ? turns.get(share) : undefined;
114
111
  if (!promise) {
115
- promise = options.bridge.run('delegate', { request }, `codex:${id}`, AbortSignal.any([lifetime.signal, signal]));
112
+ promise = options.bridge.run('delegate', { request }, `codex:${id}`, lifetime.signal);
116
113
  if (share)
117
114
  turns.set(share, promise);
118
115
  }
119
116
  delegations.set(id, promise);
120
117
  const result = await promise;
121
- if (!closed && !signal.aborted)
118
+ if (!closed)
122
119
  append(result, id);
123
120
  }
124
121
  };
125
- emit({ type: 'realtime.webrtc.start', dataChannelLabel: 'oai-events' });
126
122
  return {
127
123
  receive(frame) {
128
124
  if (closed)
package/dist/child.js CHANGED
@@ -7,7 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { createInterface } from 'node:readline';
10
- import { toolBridge } from "./tools.js";
10
+ import { randomUUID } from 'node:crypto';
11
11
  import { parseRealtimeClientFrame } from "./frames.js";
12
12
  import { createChatgptAdapter } from "./chatgpt.js";
13
13
  const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -20,14 +20,36 @@ input.on('line', line => {
20
20
  if (!starting) {
21
21
  const config = JSON.parse(line);
22
22
  starting = (async () => {
23
- const handlers = Object.fromEntries(config.tools.map(tool => [tool.name, (_args, context) => new Promise(resolve => {
24
- const finish = (value) => { context.signal.removeEventListener('abort', abort); pending.delete(context.id); resolve(value); };
25
- const abort = () => { process.stdout.write(`${JSON.stringify({ type: 'kit.tool.cancel', id: context.id })}\n`); finish('The request was cancelled.'); };
26
- pending.set(context.id, finish);
27
- context.signal.addEventListener('abort', abort, { once: true });
28
- process.stdout.write(`${JSON.stringify({ type: 'kit.tool', name: tool.name, args: _args, id: context.id })}\n`);
29
- })]));
30
- const bridge = toolBridge({ tools: config.tools, handlers, emit, timeoutFor: () => 300000, failure: () => 'The request could not be completed.' });
23
+ // The parent is the single authority for tool bounds, watchdogs, failure
24
+ // wording and answer state. This proxy adds no competing deadline.
25
+ const bridge = {
26
+ run(name, args = {}, id = randomUUID(), signal) {
27
+ if (signal?.aborted)
28
+ return Promise.resolve('The request was cancelled.');
29
+ if (!config.tools.some(tool => tool.name === name) || !args || typeof args !== 'object' || Array.isArray(args)
30
+ || typeof id !== 'string' || id.length > 160 || Buffer.byteLength(JSON.stringify(args)) > 16000)
31
+ return Promise.resolve('That request is invalid.');
32
+ const key = JSON.stringify([name, args]);
33
+ const prior = pending.get(id);
34
+ if (prior)
35
+ return prior.key === key ? prior.promise : Promise.resolve('Conflicting repeated request. No additional action was performed.');
36
+ if (pending.size >= 8)
37
+ return Promise.resolve('Requests are at the session limit.');
38
+ let finish;
39
+ const cancel = () => { process.stdout.write(`${JSON.stringify({ type: 'kit.tool.cancel', id })}\n`); finish('The request was cancelled.'); };
40
+ const promise = new Promise(resolve => {
41
+ finish = output => { signal?.removeEventListener('abort', cancel); pending.delete(id); resolve(output); };
42
+ });
43
+ pending.set(id, { key, promise, finish, cancel });
44
+ signal?.addEventListener('abort', cancel, { once: true });
45
+ process.stdout.write(`${JSON.stringify({ type: 'kit.tool', name, args, id })}\n`);
46
+ return promise;
47
+ },
48
+ state(state, detail) { emit({ type: 'realtime.state', state, ...(detail ? { detail } : {}) }); },
49
+ answered() { }, receive: () => false,
50
+ close() { for (const call of [...pending.values()])
51
+ call.cancel(); },
52
+ };
31
53
  const options = { ...config, bridge, redact: config.redact?.map(pattern => new RegExp(pattern.source, pattern.flags)) };
32
54
  if (config.engine === 'chatgpt')
33
55
  adapter = createChatgptAdapter({ ...options, accountId: config.accountId ?? '' });
@@ -35,6 +57,7 @@ input.on('line', line => {
35
57
  const module = await import(__rewriteRelativeImportExtension(`./providers/${config.engine}.${import.meta.url.endsWith('.ts') ? 'ts' : 'js'}`));
36
58
  adapter = module.createAdapter(options);
37
59
  }
60
+ process.stdout.write(`${JSON.stringify({ type: 'kit.ready' })}\n`);
38
61
  })();
39
62
  await starting;
40
63
  return;
@@ -42,7 +65,7 @@ input.on('line', line => {
42
65
  await starting;
43
66
  const frame = JSON.parse(line);
44
67
  if (frame.type === 'kit.tool.result')
45
- pending.get(frame.id)?.(String(frame.output));
68
+ pending.get(frame.id)?.finish(String(frame.output));
46
69
  else
47
70
  adapter?.receive(parseRealtimeClientFrame(frame));
48
71
  })().catch(() => { emit({ type: 'realtime.closed', reason: 'Voice session could not start.' }); process.exit(1); });
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@ export function realtimeClient(options) {
5
5
  let stopped = false, muted = false, epoch = 0, reconnects = 0;
6
6
  let retry, reconnectTimer, stableTimer;
7
7
  let attempt;
8
+ let retained;
8
9
  const mic = [], speech = [];
9
10
  let micBytes = 0;
10
11
  const stats = { micCaptured: 0, micSent: 0, micQueued: 0, micDropped: 0, transportReconnects: 0, providerReconnects: 0 };
@@ -29,7 +30,8 @@ export function realtimeClient(options) {
29
30
  }
30
31
  }
31
32
  };
32
- const reset = () => { clearTimeout(retry); retry = undefined; clearTimeout(stableTimer); audio.player.clear(); mic.length = 0; micBytes = 0; };
33
+ const reset = (clearPlayback = true) => { clearTimeout(retry); retry = undefined; clearTimeout(stableTimer); if (clearPlayback)
34
+ audio.player.clear(); mic.length = 0; micBytes = 0; };
33
35
  const stop = (reason) => {
34
36
  if (stopped)
35
37
  return;
@@ -37,8 +39,9 @@ export function realtimeClient(options) {
37
39
  epoch++;
38
40
  clearTimeout(reconnectTimer);
39
41
  reset();
40
- const current = attempt;
42
+ const current = attempt ?? retained;
41
43
  attempt = undefined;
44
+ retained = undefined;
42
45
  current?.stream?.send({ type: 'realtime.control', action: 'stop' });
43
46
  void cleanup(current).catch(() => { });
44
47
  audio.player.stop();
@@ -84,10 +87,19 @@ export function realtimeClient(options) {
84
87
  }
85
88
  attempt = undefined;
86
89
  epoch++;
87
- reset();
90
+ reset(!options.preserveMediaOnReconnect);
88
91
  reconnects++;
89
92
  stats.transportReconnects++;
90
93
  options.onStatus('connecting', reason);
94
+ if (options.preserveMediaOnReconnect && current.capture && !current.peer) {
95
+ retained = current;
96
+ current.signal.abort();
97
+ current.stream?.close();
98
+ if (current.stream)
99
+ audio.player.unbind(current.stream);
100
+ reconnectTimer = setTimeout(connect, reconnects * 500);
101
+ return;
102
+ }
91
103
  // A slow microphone release cannot overlap the next acquisition.
92
104
  void cleanup(current).then(() => {
93
105
  if (!stopped)
@@ -98,12 +110,17 @@ export function realtimeClient(options) {
98
110
  if (stopped)
99
111
  return;
100
112
  reconnectTimer = undefined;
101
- const current = { acquired: false, ready: false, signal: new AbortController() };
113
+ const current = { ...retained, stream: undefined, acquired: retained?.acquired ?? false, ready: false, signal: new AbortController() };
114
+ retained = undefined;
102
115
  attempt = current;
103
116
  const generation = ++epoch;
104
117
  const active = () => !stopped && attempt === current && epoch === generation;
105
118
  const onAudio = (data) => {
106
- if (!active() || muted)
119
+ // A retained recorder's callback follows its capture handle into the new carrier.
120
+ const captureActive = options.preserveMediaOnReconnect && current.capture
121
+ ? !stopped && attempt?.capture === current.capture && attempt.ready
122
+ : active();
123
+ if (!captureActive || muted)
107
124
  return;
108
125
  try {
109
126
  const bytes = realtimePcm16ByteLength(data);
@@ -131,6 +148,28 @@ export function realtimeClient(options) {
131
148
  options.onActivity?.();
132
149
  switch (frame.type) {
133
150
  case 'realtime.ready':
151
+ if (current.capture && (current.inputRate !== frame.inputRate || current.outputRate !== frame.outputRate)) {
152
+ const capture = current.capture, acquired = current.acquired;
153
+ current.capture = undefined;
154
+ current.acquired = false;
155
+ current.ready = false;
156
+ // Cleanup also awaits this teardown if stop races a format change.
157
+ current.media = (async () => {
158
+ try {
159
+ await capture.release();
160
+ }
161
+ finally {
162
+ if (acquired) {
163
+ audio.microphone.release();
164
+ await audio.unroute();
165
+ }
166
+ }
167
+ })();
168
+ await current.media;
169
+ if (!active())
170
+ return;
171
+ current.media = undefined;
172
+ }
134
173
  if (current.media) {
135
174
  current.ready = true;
136
175
  stats.providerReconnects++;
@@ -138,6 +177,8 @@ export function realtimeClient(options) {
138
177
  flush();
139
178
  break;
140
179
  }
180
+ current.inputRate = frame.inputRate;
181
+ current.outputRate = frame.outputRate;
141
182
  current.media = (async () => {
142
183
  await audio.microphone.acquire();
143
184
  current.acquired = true;
@@ -207,6 +248,7 @@ export function realtimeClient(options) {
207
248
  break;
208
249
  case 'realtime.audio.clear':
209
250
  audio.player.clear();
251
+ flush();
210
252
  break;
211
253
  case 'realtime.transcript':
212
254
  options.onTurn(frame.role, frame.text);
@@ -214,11 +256,15 @@ export function realtimeClient(options) {
214
256
  case 'realtime.state':
215
257
  if (frame.state === 'connecting') {
216
258
  current.ready = false;
217
- reset();
259
+ reset(!options.preserveMediaOnReconnect);
260
+ }
261
+ if (frame.state === 'connected') {
262
+ let drained = false;
263
+ const onDrained = () => { if (!active() || drained)
264
+ return; drained = true; options.onStatus('connected', frame.detail); flush(); };
265
+ if (!audio.player.finish(onDrained))
266
+ onDrained();
218
267
  }
219
- if (frame.state === 'connected')
220
- audio.player.finish(() => { if (active())
221
- options.onStatus('connected', frame.detail); });
222
268
  else
223
269
  options.onStatus(frame.state, frame.detail);
224
270
  break;
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export * from './types.ts';
3
3
  export * from './words.ts';
4
4
  export * from './prose.ts';
5
5
  export * from './client.ts';
6
+ export * from './auth.ts';
6
7
  export { providers, type RealtimeProviderInfo } from './catalogue.ts';
package/dist/index.js CHANGED
@@ -3,4 +3,5 @@ export * from "./types.js";
3
3
  export * from "./words.js";
4
4
  export * from "./prose.js";
5
5
  export * from "./client.js";
6
+ export * from "./auth.js";
6
7
  export { providers } from "./catalogue.js";
package/dist/node.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { RealtimeClientFrame, RealtimeHostFrame } from './frames.ts';
2
2
  import type { RealtimeAuth, RealtimeConfig, RealtimeProviderId, RealtimeUsage } from './types.ts';
3
+ import type { RealtimeSignalingIdentity } from './adapter.ts';
3
4
  import { toolBridge } from './tools.ts';
4
- export { toolBridge, appBridge } from './tools.ts';
5
+ import type { RealtimeAuthCheckOptions } from './auth.ts';
6
+ export { toolBridge, appBridge, delegationHandler } from './tools.ts';
7
+ export { realtimeAuthCheck } from './auth.ts';
8
+ export type { RealtimeAuthCheckOptions, RealtimeAuthStatus } from './auth.ts';
5
9
  export type { ToolHandler, ToolBridgeOptions } from './tools.ts';
10
+ export type { RealtimeSignalingIdentity } from './adapter.ts';
6
11
  export type RealtimeEngineOptions = RealtimeConfig & {
7
12
  engine: RealtimeProviderId;
8
13
  auth: RealtimeAuth;
@@ -12,6 +17,14 @@ export type RealtimeEngineOptions = RealtimeConfig & {
12
17
  endpoint?: string;
13
18
  redact?: RegExp[];
14
19
  onUsage?(usage: RealtimeUsage): void;
20
+ /** Optional read-only advisory check; never awaited before starting the provider. */
21
+ authCheck?: RealtimeAuthCheckOptions;
22
+ /** App phrase policy; close after the next completed agent transcript. */
23
+ hangup?(text: string): boolean;
24
+ /** Plain app wording for credential acquisition failures, with kit redaction. */
25
+ authFailure?(error: unknown): string;
26
+ /** Explicit app identity for ChatGPT signaling; defaults remain byokit. */
27
+ signalingIdentity?: RealtimeSignalingIdentity;
15
28
  };
16
29
  /** Provider sockets and SDP signaling run in a kit-owned child, never in the app process. */
17
30
  export declare function realtimeEngine(options: RealtimeEngineOptions): {
package/dist/node.js CHANGED
@@ -3,8 +3,10 @@ import { createInterface } from 'node:readline';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseRealtimeClientFrame, parseRealtimeHostFrame } from "./frames.js";
5
5
  import { RealtimeError } from "./types.js";
6
- import { cleanProse } from "./prose.js";
7
- export { toolBridge, appBridge } from "./tools.js";
6
+ import { cleanProseBytes } from "./prose.js";
7
+ import { realtimeAuthCheck } from "./auth.js";
8
+ export { toolBridge, appBridge, delegationHandler } from "./tools.js";
9
+ export { realtimeAuthCheck } from "./auth.js";
8
10
  /** Provider sockets and SDP signaling run in a kit-owned child, never in the app process. */
9
11
  export function realtimeEngine(options) {
10
12
  if (!['chatgpt', 'openai', 'gemini', 'xai'].includes(options.engine))
@@ -13,26 +15,33 @@ export function realtimeEngine(options) {
13
15
  throw new RealtimeError('unsupported');
14
16
  if ((options.engine === 'chatgpt') !== (options.auth.kind === 'plan'))
15
17
  throw new RealtimeError('unsupported');
18
+ for (const value of Object.values(options.signalingIdentity ?? {})) {
19
+ if (typeof value !== 'string' || !/^[\x20-\x7E]{1,160}$/.test(value) || !value.trim())
20
+ throw new RealtimeError('protocol');
21
+ }
16
22
  let stopped = false, emittedClose = false;
23
+ let ending = false;
17
24
  const lifetime = new AbortController();
25
+ const authCheck = options.authCheck ? realtimeAuthCheck(options.authCheck) : undefined;
18
26
  const start = Date.now();
19
27
  let child;
28
+ let configured = false;
20
29
  let killTimer;
21
30
  let queuedBytes = 0;
22
31
  const pending = [];
23
32
  const toolCalls = new Map();
24
- const secrets = [];
33
+ const secrets = options.auth.kind === 'key' ? [options.auth.key] : [];
25
34
  const usage = { seconds: 0, basis: options.engine === 'chatgpt' ? 'subscription' : options.engine === 'xai' ? 'minutes' : 'tokens' };
26
- const safe = (value) => { let result = value; for (const secret of secrets)
35
+ const safe = (value, max = 2048) => { let result = value; for (const secret of secrets)
27
36
  if (secret)
28
- result = result.replaceAll(secret, '[hidden]'); return cleanProse(result, 'Voice ended.', 500, options.redact); };
37
+ result = result.replaceAll(secret, '[hidden]'); return cleanProseBytes(result, 'Voice ended.', max, options.redact); };
29
38
  const emit = (frame) => {
30
39
  if (frame.type === 'realtime.state' && frame.detail)
31
- frame = { ...frame, detail: safe(frame.detail) };
40
+ frame = { ...frame, detail: safe(frame.detail, 500) };
32
41
  if (frame.type === 'realtime.closed' && frame.reason)
33
42
  frame = { ...frame, reason: safe(frame.reason) };
34
43
  if (frame.type === 'realtime.transcript')
35
- frame = { ...frame, text: safe(frame.text) };
44
+ frame = { ...frame, text: safe(frame.text, 4000) };
36
45
  // Inspect every outbound string, including data-channel payloads, for exact credentials.
37
46
  const serialized = JSON.stringify(frame);
38
47
  if (secrets.some(secret => secret && serialized.includes(secret))) {
@@ -52,6 +61,7 @@ export function realtimeEngine(options) {
52
61
  emittedClose = true;
53
62
  stopped = true;
54
63
  lifetime.abort();
64
+ authCheck?.close();
55
65
  options.bridge.close();
56
66
  usage.seconds = (Date.now() - start) / 1000;
57
67
  options.onUsage?.({ ...usage });
@@ -70,15 +80,15 @@ export function realtimeEngine(options) {
70
80
  return true;
71
81
  };
72
82
  const ready = (async () => {
73
- const credential = options.auth.kind === 'key' ? { access: options.auth.key, accountId: '' } : await options.auth.access(lifetime.signal);
74
- if (stopped)
75
- return;
76
- secrets.push(credential.access);
83
+ // Warm the isolated child while the app resolves access. No credential is
84
+ // sent until access succeeds; queued device frames still follow its config.
77
85
  const entry = new URL(`./child.${import.meta.url.endsWith('.ts') ? 'ts' : 'js'}`, import.meta.url);
78
86
  child = spawn(process.execPath, [fileURLToPath(entry)], { env: {}, stdio: ['pipe', 'pipe', 'pipe'] });
79
87
  child.stdin.on('error', () => finish('Voice input failed.'));
80
88
  child.on('error', () => finish('Voice could not start.'));
81
- child.on('exit', () => { clearTimeout(killTimer); finish('Voice provider disconnected.'); });
89
+ child.on('exit', () => clearTimeout(killTimer));
90
+ // 'close' follows drained stdio, so a final provider reason wins over exit.
91
+ child.on('close', () => finish('Voice provider disconnected.'));
82
92
  // Never forward stderr: providers may include credentials in URLs or errors.
83
93
  child.stderr.resume();
84
94
  let lineBytes = 0;
@@ -98,6 +108,11 @@ export function realtimeEngine(options) {
98
108
  return;
99
109
  try {
100
110
  const raw = JSON.parse(line);
111
+ if (raw.type === 'kit.ready') {
112
+ if (options.engine === 'chatgpt')
113
+ clearTimeout(startupTimer);
114
+ return;
115
+ }
101
116
  if (raw.type === 'kit.tool.cancel') {
102
117
  toolCalls.get(raw.id)?.abort();
103
118
  return;
@@ -108,6 +123,13 @@ export function realtimeEngine(options) {
108
123
  void options.bridge.run(raw.name, raw.args, raw.id, AbortSignal.any([lifetime.signal, controller.signal])).then(output => { send(`${JSON.stringify({ type: 'kit.tool.result', id: raw.id, output })}\n`); }).catch(() => finish('Voice tool failed.')).finally(() => toolCalls.delete(raw.id));
109
124
  return;
110
125
  }
126
+ // Normalize prose before byte-bound admission, including provider Unicode.
127
+ if (raw.type === 'realtime.state' && typeof raw.detail === 'string')
128
+ raw.detail = safe(raw.detail, 500);
129
+ if (raw.type === 'realtime.transcript' && typeof raw.text === 'string')
130
+ raw.text = safe(raw.text, 4000);
131
+ if (raw.type === 'realtime.closed' && typeof raw.reason === 'string')
132
+ raw.reason = safe(raw.reason);
111
133
  const frame = parseRealtimeHostFrame(raw);
112
134
  if (frame.type === 'realtime.ready' || frame.type === 'realtime.webrtc.start')
113
135
  clearTimeout(startupTimer);
@@ -125,13 +147,36 @@ export function realtimeEngine(options) {
125
147
  }
126
148
  if (frame.type === 'realtime.transcript' && frame.role === 'agent')
127
149
  options.bridge.answered();
150
+ if (frame.type === 'realtime.state') {
151
+ options.bridge.state(frame.state, frame.detail, emit);
152
+ return;
153
+ }
154
+ if (frame.type === 'realtime.transcript' && frame.role === 'user' && options.hangup?.(frame.text))
155
+ ending = true;
128
156
  emit(frame);
157
+ if (frame.type === 'realtime.transcript' && frame.role === 'agent' && ending)
158
+ finish('ended');
129
159
  }
130
160
  catch {
131
161
  finish('Voice provider sent an invalid frame.');
132
162
  }
133
163
  });
134
- send(`${JSON.stringify({ engine: options.engine, key: credential.access, accountId: credential.accountId, instructions: options.instructions ?? '', tools: options.tools ?? [], model: options.model, voice: options.voice, endpoint: options.endpoint, redact: options.redact?.map(pattern => ({ source: pattern.source, flags: pattern.flags })) })}\n`);
164
+ // Local media setup overlaps access and child loading instead of waiting for both.
165
+ if (options.engine === 'chatgpt')
166
+ emit({ type: 'realtime.webrtc.start', dataChannelLabel: 'oai-events' });
167
+ let credential;
168
+ try {
169
+ credential = options.auth.kind === 'key' ? { access: options.auth.key, accountId: '' } : await options.auth.access(lifetime.signal);
170
+ }
171
+ catch (error) {
172
+ finish(options.authFailure?.(error) ?? (error instanceof Error ? error.message : 'Voice sign-in is unavailable.'));
173
+ return;
174
+ }
175
+ if (stopped)
176
+ return;
177
+ secrets.push(credential.access);
178
+ send(`${JSON.stringify({ engine: options.engine, key: credential.access, accountId: credential.accountId, instructions: options.instructions ?? '', tools: options.tools ?? [], model: options.model, voice: options.voice, endpoint: options.endpoint, signalingIdentity: options.signalingIdentity, redact: options.redact?.map(pattern => ({ source: pattern.source, flags: pattern.flags })) })}\n`);
179
+ configured = true;
135
180
  for (const line of pending)
136
181
  if (!send(line)) {
137
182
  finish('Voice input overflowed.');
@@ -145,8 +190,11 @@ export function realtimeEngine(options) {
145
190
  receive(raw) {
146
191
  if (stopped)
147
192
  return false;
148
- const frame = parseRealtimeClientFrame(raw), line = `${JSON.stringify(frame)}\n`;
149
- if (!child) {
193
+ const frame = parseRealtimeClientFrame(raw);
194
+ if (options.bridge.receive(frame))
195
+ return true;
196
+ const line = `${JSON.stringify(frame)}\n`;
197
+ if (!configured) {
150
198
  const bytes = Buffer.byteLength(line);
151
199
  if (queuedBytes + bytes > 128 * 1024) {
152
200
  finish('Voice input overflowed.');
package/dist/prose.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export declare const cleanProse: (value: unknown, fallback: string, max: number, redact?: RegExp[]) => string;
2
+ /** Frame limits are UTF-8 bytes; truncate on whole Unicode characters. */
3
+ export declare function cleanProseBytes(value: unknown, fallback: string, max: number, redact?: RegExp[]): string;
2
4
  /**
3
5
  * The provider explains a refusal in the HTTP body; the close code does not.
4
6
  * An out-of-credits 403 is otherwise indistinguishable from a dropped network,
package/dist/prose.js CHANGED
@@ -14,6 +14,20 @@ export const cleanProse = (value, fallback, max, redact = []) => {
14
14
  .replace(/\s+/g, ' ').trim().slice(0, max);
15
15
  return clean || fallback;
16
16
  };
17
+ /** Frame limits are UTF-8 bytes; truncate on whole Unicode characters. */
18
+ export function cleanProseBytes(value, fallback, max, redact = []) {
19
+ const clean = cleanProse(value, fallback, max, redact);
20
+ const encoder = new TextEncoder();
21
+ let result = '', bytes = 0;
22
+ for (const char of clean) {
23
+ const size = encoder.encode(char).length;
24
+ if (bytes + size > max)
25
+ break;
26
+ result += char;
27
+ bytes += size;
28
+ }
29
+ return result.trim();
30
+ }
17
31
  /**
18
32
  * The provider explains a refusal in the HTTP body; the close code does not.
19
33
  * An out-of-credits 403 is otherwise indistinguishable from a dropped network,
package/dist/tools.d.ts CHANGED
@@ -11,15 +11,22 @@ export type ToolBridgeOptions = {
11
11
  timeoutFor?: (name: string) => number;
12
12
  answerTimeoutMs?: number;
13
13
  failure(name: string, error: unknown, timedOut: boolean): string;
14
+ /** Route semantic app replies to the same bridge used by tool handlers. */
15
+ app?: ReturnType<typeof appBridge>;
14
16
  };
15
17
  /** Bounded, deduplicated tool execution. Product authorization stays in handlers. */
16
18
  export declare function toolBridge(options: ToolBridgeOptions): {
17
19
  run: (name: string, args?: unknown, id?: string, signal?: AbortSignal) => Promise<string>;
18
- state: (value: RealtimeState, detail?: string) => void;
20
+ state: (value: RealtimeState, detail?: string, emit?: (frame: RealtimeHostFrame) => void) => void;
19
21
  answered: () => void;
20
- receive: (_frame: RealtimeClientFrame) => boolean;
22
+ receive: (frame: RealtimeClientFrame) => boolean;
21
23
  close(): void;
22
24
  };
25
+ /** Structured requests use the app's allowlisted tools; prose uses its planner. */
26
+ export declare function delegationHandler(options: {
27
+ bridge: ReturnType<typeof toolBridge>;
28
+ plan?: (request: string, context: Parameters<ToolHandler>[1]) => Promise<string>;
29
+ }): ToolHandler;
23
30
  export declare function appBridge(emit: (frame: RealtimeHostFrame) => void | boolean, options?: {
24
31
  timeoutMs?: number;
25
32
  maxPending?: number;
package/dist/tools.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { cleanProseBytes } from "./prose.js";
2
3
  /** Bounded, deduplicated tool execution. Product authorization stays in handlers. */
3
4
  export function toolBridge(options) {
4
5
  const lifetime = new AbortController();
5
6
  const requests = new Map();
6
7
  let active = 0, waiting = false;
7
8
  let answerTimer;
8
- const state = (value, detail) => options.emit({ type: 'realtime.state', state: value === 'connected' && (active > 0 || waiting) ? 'thinking' : value, ...(detail ? { detail } : {}) });
9
+ const state = (value, detail, emit = options.emit) => emit({ type: 'realtime.state', state: value === 'connected' && (active > 0 || waiting) ? 'thinking' : value, ...(detail ? { detail: cleanProseBytes(detail, 'Voice could not complete that request.', 500) } : {}) });
9
10
  const answered = () => { if (active)
10
11
  return; clearTimeout(answerTimer); waiting = false; };
11
12
  const run = (name, args = {}, id = randomUUID(), signal) => {
@@ -57,7 +58,34 @@ export function toolBridge(options) {
57
58
  requests.set(id, { key, promise });
58
59
  return promise;
59
60
  };
60
- return { run, state, answered, receive: (_frame) => false, close() { lifetime.abort(); clearTimeout(answerTimer); waiting = false; } };
61
+ return { run, state, answered, receive: (frame) => options.app?.receive(frame) ?? false, close() { lifetime.abort(); options.app?.close(); clearTimeout(answerTimer); waiting = false; } };
62
+ }
63
+ /** Structured requests use the app's allowlisted tools; prose uses its planner. */
64
+ export function delegationHandler(options) {
65
+ return async (args, context) => {
66
+ context.signal.throwIfAborted();
67
+ const request = args.request;
68
+ if (typeof request !== 'string' || !request.trim() || Buffer.byteLength(request) > 16000)
69
+ return 'That delegated request is invalid.';
70
+ let structured;
71
+ try {
72
+ structured = JSON.parse(request);
73
+ }
74
+ catch {
75
+ // JSON-looking input is never reinterpreted as permission to plan an action.
76
+ if (/^[\s]*[\[{]/.test(request))
77
+ return 'That delegated request is invalid.';
78
+ return options.plan ? options.plan(request, context) : 'That delegation needs an app planner.';
79
+ }
80
+ if (!structured || typeof structured !== 'object' || Array.isArray(structured))
81
+ return 'That delegated request is invalid.';
82
+ const call = structured;
83
+ if (typeof call.name !== 'string' || !call.name.trim() || call.name === 'delegate'
84
+ || Object.keys(call).some(key => key !== 'name' && key !== 'arguments'))
85
+ return 'That delegated request is invalid.';
86
+ // Arguments, including named targets, stay intact. The app validates and authorizes them.
87
+ return options.bridge.run(call.name, call.arguments === undefined ? {} : call.arguments, context.id, context.signal);
88
+ };
61
89
  }
62
90
  export function appBridge(emit, options = {}) {
63
91
  const pending = new Map();
package/dist/types.d.ts CHANGED
@@ -63,6 +63,7 @@ export type AudioPorts = {
63
63
  unbind(sink: PlaybackSink): void;
64
64
  admit(base64: string): 'ok' | 'malformed' | 'overflow';
65
65
  clear(): void;
66
+ /** True means a drain callback was scheduled; false means nothing needs draining. */
66
67
  finish(onDrained?: () => void): boolean;
67
68
  afterDrain(kind: 'connected' | 'speech', run: () => void): boolean;
68
69
  stop(): void;
@@ -85,6 +86,9 @@ export type RealtimeClientOptions = {
85
86
  audio: AudioPorts;
86
87
  /** Transport closures retry by default. Return false for intentional or terminal closures. */
87
88
  retryableClose?(reason?: string): boolean;
89
+ /** Retain ready PCM capture and the playback tail across carrier reconnects.
90
+ * WebRTC still needs a fresh peer. Pending microphone acquisition is never retained. */
91
+ preserveMediaOnReconnect?: boolean;
88
92
  webrtc?: (options: import('./webrtc.ts').WebRtcOptions) => Promise<import('./webrtc.ts').WebRtcHandle>;
89
93
  onStatus(status: 'connecting' | 'connected' | 'thinking' | 'speaking' | 'disconnected', reason?: string): void;
90
94
  onTurn(role: 'user' | 'agent', text: string): void;
package/dist/webrtc.js CHANGED
@@ -36,7 +36,8 @@ export async function webRtcPeer(options) {
36
36
  } };
37
37
  try {
38
38
  options.onConnectionState('connecting');
39
- options.signal?.throwIfAborted();
39
+ if (options.signal?.aborted)
40
+ throw new Error('Voice is closed.');
40
41
  await options.audio.microphone.acquire();
41
42
  acquired = true;
42
43
  if (stopped) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/realtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Realtime voice sessions with host-held credentials and injected audio.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",