@guava-ai/guava-sdk 0.43.0 → 0.44.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/src/agent.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type EscalateEvent,
19
19
  type GuavaEvent,
20
20
  type OutboundCallFailed,
21
+ type WebRTCMessageEvent,
21
22
  decodeEventDict,
22
23
  } from "./events.ts";
23
24
  import {
@@ -25,7 +26,7 @@ import {
25
26
  type ServerMessage as DialerServerMessage,
26
27
  decodeServerMessage as decodeDialerServerMessage,
27
28
  } from "./guavadialer-events.ts";
28
- import { HealthContext, getHealthServer } from "./health.ts";
29
+ import { HealthContext } from "./health.ts";
29
30
  import { _generate } from "./helpers/llm.ts";
30
31
  import { Call, Client } from "./index.ts";
31
32
  import { type Logger, getDefaultLogger } from "./logging.ts";
@@ -33,6 +34,7 @@ import type { CallInfo } from "./socket/call-info.ts";
33
34
  import { GuavaSocket, GuavaSocketClosedError } from "./socket/client.ts";
34
35
  import * as ListenInbound from "./socket/listen-inbound.ts";
35
36
  import { telemetryClient } from "./telemetry.ts";
37
+ import { Runner } from "./runner.ts";
36
38
  import { runChat } from "./testing/chat.ts";
37
39
  import { SessionStarted } from "./testing/protocol.ts";
38
40
  import { TestSession } from "./testing/session.ts";
@@ -99,6 +101,7 @@ export class Agent {
99
101
  private _onEscalate?: (call: Call, event: EscalateEvent) => Promise<void>;
100
102
  private _onCallerDtmf?: (call: Call, event: DTMFPressedEvent) => Promise<void>;
101
103
  private _onAgentDtmf?: (call: Call, event: AgentDTMFSentEvent) => Promise<void>;
104
+ private _onWebrtcMessage?: (call: Call, event: WebRTCMessageEvent) => Promise<void>;
102
105
  private _onOutboundFailed?: (call: Call, event: OutboundCallFailed) => Promise<void>;
103
106
 
104
107
  constructor(args?: {
@@ -217,6 +220,11 @@ export class Agent {
217
220
  this._onAgentDtmf = callback;
218
221
  }
219
222
 
223
+ /** Register a handler for messages sent by a connected WebRTC client. */
224
+ onWebrtcMessage(callback: (call: Call, event: WebRTCMessageEvent) => Promise<void>): void {
225
+ this._onWebrtcMessage = callback;
226
+ }
227
+
220
228
  onOutboundFailed(callback: (call: Call, event: OutboundCallFailed) => Promise<void>): void {
221
229
  this._onOutboundFailed = callback;
222
230
  }
@@ -275,25 +283,15 @@ export class Agent {
275
283
  }
276
284
 
277
285
  async listenPhone(phoneNumber: string): Promise<void> {
278
- const healthCtx = new HealthContext();
279
- await using _server = await getHealthServer(healthCtx);
280
- return await this._listenInbound(healthCtx, { agent_number: phoneNumber });
286
+ await new Runner().listenPhone(this, phoneNumber).run();
281
287
  }
282
288
 
283
289
  async listenWebrtc(webrtcCode?: string): Promise<void> {
284
- if (!webrtcCode) {
285
- this._logger.info("No WebRTC code provided. Creating a temporary one.");
286
- webrtcCode = await this._client.createWebrtcAgent(3600);
287
- }
288
- const healthCtx = new HealthContext();
289
- await using _server = await getHealthServer(healthCtx);
290
- return await this._listenInbound(healthCtx, { webrtc_code: webrtcCode });
290
+ await new Runner().listenWebrtc(this, webrtcCode).run();
291
291
  }
292
292
 
293
293
  async listenSip(sipCode: string): Promise<void> {
294
- const healthCtx = new HealthContext();
295
- await using _server = await getHealthServer(healthCtx);
296
- return await this._listenInbound(healthCtx, { sip_code: sipCode });
294
+ await new Runner().listenSip(this, sipCode).run();
297
295
  }
298
296
 
299
297
  async callLocal(variables: Record<string, any> = {}): Promise<void> {
@@ -511,6 +509,12 @@ export class Agent {
511
509
  this._onAgentDtmf!(call, event),
512
510
  );
513
511
  }
512
+ } else if (event.event_type === "webrtc-message") {
513
+ if (this._onWebrtcMessage !== undefined) {
514
+ await this._invokeHandler(call, "onWebrtcMessage", false, () =>
515
+ this._onWebrtcMessage!(call, event),
516
+ );
517
+ }
514
518
  } else if (event.event_type === "escalate") {
515
519
  if (this._onEscalate !== undefined) {
516
520
  await this._invokeHandler(call, "onEscalate", true, () => this._onEscalate!(call, event));
@@ -542,7 +546,7 @@ export class Agent {
542
546
  callInfo: CallInfo,
543
547
  variables: Record<string, any> = {},
544
548
  ): Promise<Call> {
545
- const call = new Call(callId, callInfo, variables);
549
+ const call = new Call(callId, callInfo);
546
550
  await call.setPersona({
547
551
  agentName: this._name,
548
552
  agentPurpose: this._purpose,
@@ -560,6 +564,9 @@ export class Agent {
560
564
  accept_dtmf_for_numbers: this._acceptDtmfForNumbers,
561
565
  has_on_agent_dtmf: this._onAgentDtmf !== undefined,
562
566
  });
567
+ for (const [key, value] of Object.entries(variables)) {
568
+ await call.setVariable(key, value);
569
+ }
563
570
  if (this._onCallStart !== undefined) {
564
571
  await this._onCallStart(call);
565
572
  }
@@ -568,13 +575,10 @@ export class Agent {
568
575
 
569
576
  async _attachToCall(
570
577
  callId: string,
571
- callInfo: CallInfo,
572
- initialVariables: Record<string, any> = {},
578
+ call: Call,
573
579
  testSession?: TestSession,
574
580
  route: string = "v2/connect-call",
575
581
  ): Promise<void> {
576
- const call = await this._initCall(callId, callInfo, initialVariables);
577
-
578
582
  const url = new URL(`${route}/${callId}`, this._client.getWebsocketBase());
579
583
  await using socket = await new GuavaSocket<Command, GuavaEvent | null>(
580
584
  `call-connection-${callId}`,
@@ -712,8 +716,19 @@ export class Agent {
712
716
  socket.send({ message_type: "decline-call", call_id: callId });
713
717
  } else {
714
718
  this._logger.info("Accepting call %s.", callId);
719
+
720
+ // Initialize the call, invoking onCallStart.
721
+ let call: Call;
722
+ try {
723
+ call = await this._initCall(callId, callInfo, initialVariables);
724
+ } catch (err) {
725
+ this._logger.error("Failed to initialize inbound call %s. Declining: %s", callId, err);
726
+ socket.send({ message_type: "decline-call", call_id: callId });
727
+ return;
728
+ }
729
+
715
730
  socket.send({ message_type: "answer-call", call_id: callId });
716
- await this._attachToCall(callId, callInfo, initialVariables);
731
+ await this._attachToCall(callId, call);
717
732
  }
718
733
  }
719
734
 
@@ -725,24 +740,51 @@ export class Agent {
725
740
  toNumber: string,
726
741
  variables: Record<string, any> = {},
727
742
  ): Promise<void> {
728
- const url = new URL("v2/create-outbound", this._client.getHttpBase());
729
- url.searchParams.set("from_number", fromNumber);
730
- url.searchParams.set("to_number", toNumber);
743
+ for (const [key, value] of Object.entries(variables)) {
744
+ if (!z.json().safeParse(value).success) {
745
+ throw new Error(`Variable '${key}' value is not JSON serializable: ${String(value)}`);
746
+ }
747
+ }
748
+
749
+ const reserveUrl = new URL("v3/reserve-outbound", this._client.getHttpBase());
750
+ reserveUrl.searchParams.set("from_number", fromNumber);
751
+ reserveUrl.searchParams.set("to_number", toNumber);
731
752
 
732
- const response = await fetchOrThrow(url, {
753
+ const response = await fetchOrThrow(reserveUrl, {
733
754
  method: "POST",
734
755
  headers: await this._client.headers(),
735
756
  });
736
757
  const { call_id } = (await response.json()) as { call_id: string };
737
758
 
738
- this._logger.info("Outbound call created with session ID: %s", call_id);
739
759
  const callInfo: CallInfo = {
740
760
  call_type: "pstn",
741
761
  from_number: fromNumber ?? null,
742
762
  to_number: toNumber,
743
763
  caller_id: null,
744
764
  };
745
- await this._attachToCall(call_id, callInfo, variables);
765
+
766
+ // Initialize the call before dialing, invoking onCallStart.
767
+ let call: Call;
768
+ try {
769
+ call = await this._initCall(call_id, callInfo, variables);
770
+ } catch (err) {
771
+ this._logger.error(
772
+ "Failed to initialize outbound call %s. The call will not be dialed: %s",
773
+ call_id,
774
+ err,
775
+ );
776
+ throw err;
777
+ }
778
+
779
+ const createUrl = new URL("v3/create-outbound", this._client.getHttpBase());
780
+ createUrl.searchParams.set("call_id", call_id);
781
+ await fetchOrThrow(createUrl, {
782
+ method: "POST",
783
+ headers: await this._client.headers(),
784
+ });
785
+
786
+ this._logger.info("Outbound call created with session ID: %s", call_id);
787
+ await this._attachToCall(call_id, call);
746
788
  }
747
789
 
748
790
  /**
@@ -788,14 +830,20 @@ export class Agent {
788
830
  to_number: "+15555555555",
789
831
  caller_id: null,
790
832
  };
791
- const attachPromise = this._attachToCall(
792
- sessionStarted.session_id,
793
- testCallInfo,
794
- variables,
795
- testSession,
796
- ).catch((err: unknown) => {
797
- this._logger.error("Error in _attachToCall during test: %s", err);
798
- });
833
+ // Initialize the call, invoking onCallStart.
834
+ let call: Call;
835
+ try {
836
+ call = await this._initCall(sessionStarted.session_id, testCallInfo, variables);
837
+ } catch (err) {
838
+ ws.close();
839
+ throw err;
840
+ }
841
+
842
+ const attachPromise = this._attachToCall(sessionStarted.session_id, call, testSession).catch(
843
+ (err: unknown) => {
844
+ this._logger.error("Error in _attachToCall during test: %s", err);
845
+ },
846
+ );
799
847
 
800
848
  try {
801
849
  await callback(testSession);
@@ -917,6 +965,7 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
917
965
  * affecting the original agent.
918
966
  */
919
967
  patch(): Agent {
968
+ // Keep this up to date with the Agent constructor args.
920
969
  const cloned = new Agent({
921
970
  name: this._name,
922
971
  organization: this._organization,
@@ -926,6 +975,8 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
926
975
  acceptDtmf: this._acceptDtmfForNumbers,
927
976
  speechSpeed: this._speechSpeed,
928
977
  });
978
+
979
+ // Keep this up to date with the handlers on Agent.
929
980
  cloned._onCallReceived = this._onCallReceived;
930
981
  cloned._onCallStart = this._onCallStart;
931
982
  cloned._onCallerSpeech = this._onCallerSpeech;
@@ -942,6 +993,7 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
942
993
  cloned._onEscalate = this._onEscalate;
943
994
  cloned._onCallerDtmf = this._onCallerDtmf;
944
995
  cloned._onAgentDtmf = this._onAgentDtmf;
996
+ cloned._onWebrtcMessage = this._onWebrtcMessage;
945
997
  cloned._onOutboundFailed = this._onOutboundFailed;
946
998
  return cloned;
947
999
  }
@@ -990,7 +1042,6 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
990
1042
  // TODO: Possible memory leak.
991
1043
  activeCalls.push(
992
1044
  (async () => {
993
- socket.send({ message_type: "controller-ready", call_id });
994
1045
  const variables = (data?.data as Record<string, unknown>) ?? {};
995
1046
  const campaignCallInfo: CallInfo = {
996
1047
  call_type: "pstn",
@@ -998,13 +1049,24 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
998
1049
  to_number: (data?.phone_number as string) ?? "",
999
1050
  caller_id: null,
1000
1051
  };
1001
- await this._attachToCall(
1002
- call_id,
1003
- campaignCallInfo,
1004
- variables,
1005
- undefined,
1006
- "v2/connect-campaign-call",
1007
- );
1052
+
1053
+ // Initialize the call, invoking onCallStart.
1054
+ let call: Call;
1055
+ try {
1056
+ call = await this._initCall(call_id, campaignCallInfo, variables);
1057
+ } catch (err) {
1058
+ this._logger.error(
1059
+ "Failed to initialize campaign call %s. The call will not be dialed: %s",
1060
+ call_id,
1061
+ err,
1062
+ );
1063
+ socket.send({ message_type: "init-controller-failed", call_id });
1064
+ return;
1065
+ }
1066
+
1067
+ // Signal that we're ready for this call to go out, and attach to the endpoint.
1068
+ socket.send({ message_type: "controller-ready", call_id });
1069
+ await this._attachToCall(call_id, call, undefined, "v2/connect-campaign-call");
1008
1070
  })().catch((err) => {
1009
1071
  this._logger.error("Error handling campaign call %s: %s", call_id, err);
1010
1072
  }),
@@ -1036,9 +1098,7 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
1036
1098
  * await agent.attachCampaign("gcmp-abc123");
1037
1099
  */
1038
1100
  async attachCampaign(campaignCode: string): Promise<void> {
1039
- const healthCtx = new HealthContext();
1040
- await using _server = await getHealthServer(healthCtx);
1041
- return await this._serveCampaign(healthCtx, campaignCode);
1101
+ await new Runner().attachCampaign(this, campaignCode).run();
1042
1102
  }
1043
1103
 
1044
1104
  /* ===== Aliases to be removed at some point. ===== */
package/src/call.ts CHANGED
@@ -8,13 +8,16 @@ import {
8
8
  SetTaskCommand,
9
9
  SendInstructionCommand,
10
10
  TransferCommand,
11
+ HangupCommand,
11
12
  ReadScriptCommand,
12
13
  RetryTaskCommand,
13
14
  SetAgentDTMFCommand,
14
15
  SendAgentDTMFCommand,
16
+ SetVariableCommand,
17
+ SendWebRTCMessageCommand,
15
18
  } from "./commands.ts";
16
19
  import { DTMF_DIGITS, type DTMFDigit } from "./events.ts";
17
- import type * as z from "zod";
20
+ import * as z from "zod";
18
21
  import type {
19
22
  ActionItem,
20
23
  FieldItem,
@@ -26,6 +29,8 @@ import { Say } from "./action-item.ts";
26
29
  import { telemetryClient } from "./telemetry.ts";
27
30
  import type { CallInfo } from "./socket/call-info.ts";
28
31
  import type { SipUri } from "./sip.ts";
32
+ import { Client } from "./client.ts";
33
+ import { idempotentPost } from "./utils.ts";
29
34
 
30
35
  export type TaskObjective =
31
36
  | { objective: string }
@@ -60,8 +65,179 @@ function voicemailMessageInstruction(message: string): string {
60
65
  return `Say this message VERBATIM: "${message}" Then hang up.`;
61
66
  }
62
67
 
68
+ /** Call-control methods shared by {@link Call} and {@link RemoteCall}. */
69
+ export abstract class BaseCall {
70
+ /** Validate a command against its schema and send it to the call. */
71
+ async sendCommand<C extends Command, Schema extends z.ZodType<C>>(
72
+ schema: Schema,
73
+ data: z.input<Schema>,
74
+ ) {
75
+ await this._sendCommand(schema.parse(data));
76
+ }
77
+
78
+ protected abstract _sendCommand(command: Command): Promise<void>;
79
+
80
+ async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
81
+ await this.sendCommand(SetLanguageModeCommand, {
82
+ command_type: "set-language-mode",
83
+ primary: args.primary ?? "english",
84
+ secondary: args.secondary ?? [],
85
+ });
86
+ }
87
+
88
+ /**
89
+ * @description provide identifiers the agent will use to identify the virtual agent
90
+ */
91
+ async setPersona(args: {
92
+ organizationName?: string;
93
+ agentName?: string;
94
+ agentPurpose?: string;
95
+ voice?: string;
96
+ speechSpeed?: SpeedPreset;
97
+ pronunciations?: Record<string, string>;
98
+ }) {
99
+ await this.sendCommand(SetPersona, {
100
+ command_type: "set-persona",
101
+ organization_name: args.organizationName,
102
+ agent_name: args.agentName,
103
+ agent_purpose: args.agentPurpose,
104
+ voice: args.voice,
105
+ tts_speed: args.speechSpeed,
106
+ tts_replacements: args.pronunciations,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Transfer the call to another destination.
112
+ *
113
+ * This method does not interfere with turn-taking — use this when
114
+ * you want the agent to naturally transfer the call.
115
+ *
116
+ * @param destination - The phone number, SIP address, or SipUri to transfer the call to.
117
+ * @param instructions - Optional guidance for how the agent should handle the transfer.
118
+ */
119
+ async transfer(destination: string | SipUri, instructions?: string) {
120
+ await this.sendCommand(TransferCommand, {
121
+ command_type: "transfer-call",
122
+ to_number: destination.toString(),
123
+ transfer_message:
124
+ instructions ?? "Notify the caller that you will be transferring them, and then transfer.",
125
+ soft_transfer: true,
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Transfer the call immediately.
131
+ *
132
+ * Unlike transfer(), this does not respect turn-taking. The agent immediately
133
+ * initiates the transfer, saying the script (if provided).
134
+ *
135
+ * @param destination - The phone number, SIP address, or SipUri to transfer the call to.
136
+ * @param finalScript - Optional message for the agent to deliver immediately
137
+ * before transferring. Defaults to no message.
138
+ */
139
+ async immediateTransfer(destination: string | SipUri, finalScript?: string) {
140
+ await this.sendCommand(TransferCommand, {
141
+ command_type: "transfer-call",
142
+ to_number: destination.toString(),
143
+ transfer_message: finalScript ?? "",
144
+ soft_transfer: false,
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Hang up the call immediately.
150
+ *
151
+ * Unlike hangup(), this does not respect turn-taking. Any speech the agent
152
+ * has already queued finishes, then the agent says the script (if provided)
153
+ * and the call ends. The caller cannot interrupt once this has been sent.
154
+ *
155
+ * @param finalScript - Optional message for the agent to deliver word-for-word
156
+ * immediately before hanging up. Defaults to no message.
157
+ */
158
+ async immediateHangup(finalScript?: string) {
159
+ await this.sendCommand(HangupCommand, {
160
+ command_type: "hangup-call",
161
+ final_script: finalScript ?? null,
162
+ });
163
+ }
164
+
165
+ async addInfo(label: string, info: unknown) {
166
+ await this.sendInstruction(
167
+ `Here is some information about the following topic ${label}:\n${JSON.stringify(info, null, 2)}`,
168
+ );
169
+ }
170
+
171
+ async retryTask(reason: string) {
172
+ await this.sendCommand(RetryTaskCommand, {
173
+ command_type: "retry-task",
174
+ reason,
175
+ });
176
+ }
177
+
178
+ async readScript(script: string | Partial<Record<Language, string>>) {
179
+ await this.sendCommand(ReadScriptCommand, {
180
+ command_type: "read-script",
181
+ script,
182
+ });
183
+ }
184
+
185
+ async sendInstruction(instruction: string) {
186
+ await this.sendCommand(SendInstructionCommand, {
187
+ command_type: "send-instruction",
188
+ instruction: instruction,
189
+ });
190
+ }
191
+
192
+ /**
193
+ * @description hang up an accepted call
194
+ */
195
+ async hangup(final_instructions: string = "") {
196
+ let instructions: string;
197
+ if (final_instructions) {
198
+ instructions = `Start ending the conversation. Here are your final instructions: ${final_instructions} Once you've completed the final instructions, naturally end the conversation and hang up the call.`;
199
+ } else {
200
+ instructions = "Naturally end the conversation and hang up the call.";
201
+ }
202
+
203
+ await this.sendInstruction(instructions);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Controls a call from outside the process handling it, e.g. to transfer a
209
+ * call that another agent is handling.
210
+ */
211
+ @telemetryClient.trackClass()
212
+ export class RemoteCall extends BaseCall {
213
+ private _callId: string;
214
+ private _client: Client;
215
+
216
+ /**
217
+ * @param callId - The ID of the call to control.
218
+ * @param client - Client used to send commands. Defaults to a new {@link Client}.
219
+ */
220
+ constructor(callId: string, client: Client = new Client()) {
221
+ super();
222
+ this._callId = callId;
223
+ this._client = client;
224
+ }
225
+
226
+ get id(): string {
227
+ return this._callId;
228
+ }
229
+
230
+ protected async _sendCommand(command: Command): Promise<void> {
231
+ const url = new URL(`v1/call/${this._callId}/send-commands`, this._client.getHttpBase());
232
+ await idempotentPost(url, {
233
+ headers: { ...(await this._client.headers()), "Content-Type": "application/json" },
234
+ body: JSON.stringify([command]),
235
+ });
236
+ }
237
+ }
238
+
63
239
  @telemetryClient.trackClass()
64
- export class Call {
240
+ export class Call extends BaseCall {
65
241
  private _callId: string;
66
242
  private _callInfo: CallInfo;
67
243
  protected _commandQueue: Command[] = [];
@@ -81,6 +257,7 @@ export class Call {
81
257
  variables: Record<string, any> = {},
82
258
  logger: Logger = getDefaultLogger(),
83
259
  ) {
260
+ super();
84
261
  this._callId = callId;
85
262
  this._callInfo = callInfo;
86
263
 
@@ -127,11 +304,7 @@ export class Call {
127
304
  return key in this._fieldValues;
128
305
  }
129
306
 
130
- async sendCommand<C extends Command, Schema extends z.ZodType<C>>(
131
- schema: Schema,
132
- data: z.input<Schema>,
133
- ) {
134
- const command = schema.parse(data);
307
+ protected async _sendCommand(command: Command): Promise<void> {
135
308
  this._commandQueue.push(command);
136
309
  await this.flush();
137
310
  }
@@ -166,33 +339,22 @@ export class Call {
166
339
  });
167
340
  }
168
341
 
169
- async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
170
- await this.sendCommand(SetLanguageModeCommand, {
171
- command_type: "set-language-mode",
172
- primary: args.primary ?? "english",
173
- secondary: args.secondary ?? [],
174
- });
175
- }
176
-
177
342
  /**
178
- * @description provide identifiers the agent will use to identify the virtual agent
343
+ * Send a message to a connected WebRTC client.
344
+ *
345
+ * @param payload - A JSON-serializable object delivered to the client.
346
+ * @throws If this is not a WebRTC call, or `payload` is not JSON-serializable.
179
347
  */
180
- async setPersona(args: {
181
- organizationName?: string;
182
- agentName?: string;
183
- agentPurpose?: string;
184
- voice?: string;
185
- speechSpeed?: SpeedPreset;
186
- pronunciations?: Record<string, string>;
187
- }) {
188
- await this.sendCommand(SetPersona, {
189
- command_type: "set-persona",
190
- organization_name: args.organizationName,
191
- agent_name: args.agentName,
192
- agent_purpose: args.agentPurpose,
193
- voice: args.voice,
194
- tts_speed: args.speechSpeed,
195
- tts_replacements: args.pronunciations,
348
+ async sendWebrtcMessage(payload: Record<string, unknown>) {
349
+ if (this._callInfo.call_type !== "webrtc") {
350
+ throw new Error("Only WebRTC calls support sending WebRTC messages.");
351
+ }
352
+ if (!z.record(z.string(), z.json()).safeParse(payload).success) {
353
+ throw new Error("WebRTC message payload must be JSON-serializable.");
354
+ }
355
+ await this.sendCommand(SendWebRTCMessageCommand, {
356
+ command_type: "send-webrtc-message",
357
+ payload: payload as Record<string, z.core.util.JSONType>,
196
358
  });
197
359
  }
198
360
 
@@ -243,85 +405,6 @@ export class Call {
243
405
  });
244
406
  }
245
407
 
246
- /**
247
- * Transfer the call to another destination.
248
- *
249
- * This method does not interfere with turn-taking — use this when
250
- * you want the agent to naturally transfer the call.
251
- *
252
- * @param destination - The phone number, SIP address, or SipUri to transfer the call to.
253
- * @param instructions - Optional guidance for how the agent should handle the transfer.
254
- */
255
- async transfer(destination: string | SipUri, instructions?: string) {
256
- await this.sendCommand(TransferCommand, {
257
- command_type: "transfer-call",
258
- to_number: destination.toString(),
259
- transfer_message:
260
- instructions ?? "Notify the caller that you will be transferring them, and then transfer.",
261
- soft_transfer: true,
262
- });
263
- }
264
-
265
- /**
266
- * Transfer the call immediately.
267
- *
268
- * Unlike transfer(), this does not respect turn-taking. The agent immediately
269
- * initiates the transfer, saying the script (if provided).
270
- *
271
- * @param destination - The phone number, SIP address, or SipUri to transfer the call to.
272
- * @param finalScript - Optional message for the agent to deliver immediately
273
- * before transferring. Defaults to no message.
274
- */
275
- async immediateTransfer(destination: string | SipUri, finalScript?: string) {
276
- await this.sendCommand(TransferCommand, {
277
- command_type: "transfer-call",
278
- to_number: destination.toString(),
279
- transfer_message: finalScript ?? "",
280
- soft_transfer: false,
281
- });
282
- }
283
-
284
- async addInfo(label: string, info: unknown) {
285
- await this.sendInstruction(
286
- `Here is some information about the following topic ${label}:\n${JSON.stringify(info, null, 2)}`,
287
- );
288
- }
289
-
290
- async retryTask(reason: string) {
291
- await this.sendCommand(RetryTaskCommand, {
292
- command_type: "retry-task",
293
- reason,
294
- });
295
- }
296
-
297
- async readScript(script: string | Partial<Record<Language, string>>) {
298
- await this.sendCommand(ReadScriptCommand, {
299
- command_type: "read-script",
300
- script,
301
- });
302
- }
303
-
304
- async sendInstruction(instruction: string) {
305
- await this.sendCommand(SendInstructionCommand, {
306
- command_type: "send-instruction",
307
- instruction: instruction,
308
- });
309
- }
310
-
311
- /**
312
- * @description hang up an accepted call
313
- */
314
- async hangup(final_instructions: string = "") {
315
- let instructions: string;
316
- if (final_instructions) {
317
- instructions = `Start ending the conversation. Here are your final instructions: ${final_instructions} Once you've completed the final instructions, naturally end the conversation and hang up the call.`;
318
- } else {
319
- instructions = "Naturally end the conversation and hang up the call.";
320
- }
321
-
322
- await this.sendInstruction(instructions);
323
- }
324
-
325
408
  async reachPerson(
326
409
  contactFullName: string,
327
410
  options: {
@@ -431,8 +514,21 @@ TASK COMPLETION REQUIREMENTS:
431
514
  }
432
515
  }
433
516
 
517
+ /**
518
+ * Set a variable on the call, both locally and on the server.
519
+ *
520
+ * @throws If `variableValue` is not JSON-serializable.
521
+ */
434
522
  async setVariable(variableName: string, variableValue: any) {
523
+ if (!z.json().safeParse(variableValue).success) {
524
+ throw new Error(`Variable value for key '${variableName}' must be JSON-serializable.`);
525
+ }
435
526
  this._variables[variableName] = variableValue;
527
+ await this.sendCommand(SetVariableCommand, {
528
+ command_type: "set-variable",
529
+ key: variableName,
530
+ value: variableValue,
531
+ });
436
532
  }
437
533
 
438
534
  async getVariable(variableName: string) {