@spekoai/sdk 0.4.3 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +88 -0
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/lib/client.d.ts +13 -1
  7. package/dist/lib/client.d.ts.map +1 -1
  8. package/dist/lib/client.js +22 -2
  9. package/dist/lib/http.d.ts +12 -4
  10. package/dist/lib/http.d.ts.map +1 -1
  11. package/dist/lib/http.js +31 -14
  12. package/dist/lib/resources/agents.d.ts +12 -2
  13. package/dist/lib/resources/agents.d.ts.map +1 -1
  14. package/dist/lib/resources/agents.js +12 -4
  15. package/dist/lib/resources/calls.d.ts +19 -1
  16. package/dist/lib/resources/calls.d.ts.map +1 -1
  17. package/dist/lib/resources/calls.js +22 -0
  18. package/dist/lib/resources/knowledge-bases.d.ts +1 -1
  19. package/dist/lib/resources/knowledge-bases.js +1 -1
  20. package/dist/lib/resources/phone-numbers.d.ts +2 -1
  21. package/dist/lib/resources/phone-numbers.d.ts.map +1 -1
  22. package/dist/lib/resources/phone-numbers.js +2 -1
  23. package/dist/lib/resources/realtime.d.ts +3 -5
  24. package/dist/lib/resources/realtime.d.ts.map +1 -1
  25. package/dist/lib/resources/realtime.js +829 -91
  26. package/dist/lib/resources/sessions.d.ts +53 -0
  27. package/dist/lib/resources/sessions.d.ts.map +1 -0
  28. package/dist/lib/resources/sessions.js +166 -0
  29. package/dist/lib/resources/sms.d.ts +80 -0
  30. package/dist/lib/resources/sms.d.ts.map +1 -0
  31. package/dist/lib/resources/sms.js +152 -0
  32. package/dist/lib/resources/synthesize.d.ts.map +1 -1
  33. package/dist/lib/resources/synthesize.js +12 -6
  34. package/dist/lib/resources/transcribe.d.ts.map +1 -1
  35. package/dist/lib/resources/transcribe.js +9 -2
  36. package/dist/lib/resources/voice.d.ts +283 -1
  37. package/dist/lib/resources/voice.d.ts.map +1 -1
  38. package/dist/lib/resources/voice.js +345 -0
  39. package/dist/lib/resources/webhooks.d.ts +25 -0
  40. package/dist/lib/resources/webhooks.d.ts.map +1 -0
  41. package/dist/lib/resources/webhooks.js +46 -0
  42. package/dist/lib/types/index.d.ts +998 -9
  43. package/dist/lib/types/index.d.ts.map +1 -1
  44. package/dist/lib/voice-contract.d.ts +280 -0
  45. package/dist/lib/voice-contract.d.ts.map +1 -0
  46. package/dist/lib/voice-contract.js +115 -0
  47. package/package.json +2 -1
  48. package/src/index.ts +212 -0
  49. package/src/lib/client.ts +169 -0
  50. package/src/lib/errors.ts +28 -0
  51. package/src/lib/http.ts +442 -0
  52. package/src/lib/resources/agents.ts +211 -0
  53. package/src/lib/resources/callbacks.ts +40 -0
  54. package/src/lib/resources/calls.ts +113 -0
  55. package/src/lib/resources/complete.ts +63 -0
  56. package/src/lib/resources/credits.ts +41 -0
  57. package/src/lib/resources/knowledge-bases.ts +199 -0
  58. package/src/lib/resources/phone-numbers.ts +109 -0
  59. package/src/lib/resources/realtime-globals.d.ts +31 -0
  60. package/src/lib/resources/realtime.spec.ts +565 -0
  61. package/src/lib/resources/realtime.ts +1169 -0
  62. package/src/lib/resources/sessions.ts +191 -0
  63. package/src/lib/resources/sms.ts +214 -0
  64. package/src/lib/resources/synthesize.ts +101 -0
  65. package/src/lib/resources/transcribe.ts +91 -0
  66. package/src/lib/resources/usage.ts +24 -0
  67. package/src/lib/resources/voice.ts +426 -0
  68. package/src/lib/resources/voices.ts +32 -0
  69. package/src/lib/resources/webhooks.ts +67 -0
  70. package/src/lib/types/index.ts +2409 -0
  71. package/src/lib/voice-contract.ts +358 -0
@@ -1,5 +1,6 @@
1
1
  import type { HttpClient } from '../http.js';
2
- import type { VoiceDialParams, VoiceDialResult } from '../types/index.js';
2
+ import type { CallControlDialParams, CallControlDialResult, CallControlListParams, VoiceDialParams, VoiceDialResult } from '../types/index.js';
3
+ import type { BridgeCommandPayload, BrokerPresenceResource, BrokerPresenceStatus, CallCommandResult, CallEventResource, CallJoinCredentials, CallResource, DtmfCommandPayload, HangupCommandPayload, TransferCommandPayload } from '../voice-contract.js';
3
4
  /**
4
5
  * Outbound phone calls via Speko's managed telephony gateway.
5
6
  *
@@ -25,4 +26,285 @@ export declare class Voice {
25
26
  */
26
27
  dial(params: VoiceDialParams): Promise<VoiceDialResult>;
27
28
  }
29
+ /**
30
+ * Programmable voice — call control for **human** calls.
31
+ *
32
+ * Modeled on Telnyx Call Control, and the mapping is meant to be mechanical:
33
+ * every leg of a call has an opaque `controlId` (Telnyx's `call_control_id`),
34
+ * and every verb is addressed to that handle rather than to a room, a session,
35
+ * or a participant. `speko.callControl.hangup(controlId)` is
36
+ * `POST /calls/{call_control_id}/actions/hangup`, and so on down the list.
37
+ *
38
+ * This is a different product from {@link Voice.dial}, which dials an **AI
39
+ * agent** out over the same telephony gateway. Here the parties are people:
40
+ * brokers on a browser softphone and phones on the PSTN. An AI leg can still be
41
+ * added to a call, which is what makes escalating an agent call to a human an
42
+ * ordinary {@link CallControl.bridge} rather than a special case.
43
+ *
44
+ * ### Porting from Telnyx — the two things that differ
45
+ *
46
+ * 1. **`hold` is silent by default.** There is no hold primitive on the
47
+ * underlying transport; hold is synthesized by isolating subscriptions in
48
+ * both directions. With no music-on-hold source configured the held party
49
+ * hears nothing at all.
50
+ * If your Telnyx flow relied on `playback_start` looping hold music, that
51
+ * has no equivalent yet.
52
+ * 2. **There is no SIP registrar.** A desk phone, a third-party softphone, or
53
+ * another PBX cannot register against Speko and be rung. Human legs are
54
+ * browser legs — `browser` and `pstn` are the only human-reachable leg
55
+ * kinds. Anything in your Telnyx setup that terminated on a SIP connection
56
+ * needs to become a PSTN transfer or a browser client.
57
+ *
58
+ * A handful of Telnyx events also have no faithful equivalent and are absent
59
+ * rather than approximated: no early-media event, and no way to tell a 486 Busy
60
+ * from a generic rejection.
61
+ *
62
+ * ### Broker identity
63
+ *
64
+ * Human calling uses the API key for organization authentication and the
65
+ * `brokerId` passed to `new Speko({ apiKey, brokerId })` for softphone identity.
66
+ * The SDK automatically includes that id on presence, dial, and join requests.
67
+ * Commands and org-wide reads do not need it on the wire.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const speko = new Speko({ apiKey: process.env.SPEKO_API_KEY, brokerId: 'broker-42' });
72
+ *
73
+ * // Come online, then hold a presence connection open so inbound can ring you.
74
+ * await speko.callControl.register();
75
+ * const presence = await speko.callControl.presenceToken();
76
+ * setInterval(() => speko.callControl.heartbeat(), PRESENCE_STALE_AFTER_MS / 3);
77
+ *
78
+ * // Dial out. The browser leg is yours; the PSTN leg is the customer.
79
+ * const { call, join } = await speko.callControl.dial({ to: '+12015551234' });
80
+ * const mine = call.legs.find((leg) => leg.kind === 'browser');
81
+ *
82
+ * // Park the customer, consult a colleague, then hand the call over.
83
+ * await speko.callControl.hold(mine!.controlId);
84
+ * await speko.callControl.transfer(mine!.controlId, { to: '+12015559876', mode: 'warm' });
85
+ * ```
86
+ */
87
+ export declare class CallControl {
88
+ private readonly http;
89
+ private readonly configuredBrokerId?;
90
+ constructor(http: HttpClient, configuredBrokerId?: string | undefined);
91
+ /**
92
+ * Place an outbound PSTN call — Telnyx `POST /calls`.
93
+ *
94
+ * Resolves as soon as the call exists, with the PSTN leg in `initiating` /
95
+ * `ringing`; it does **not** wait for anyone to answer. Watch
96
+ * {@link CallControl.events} (or the browser leg's own connection) for
97
+ * `call.answered`.
98
+ *
99
+ * **Resolves to `{ call, join }`, not to a bare call.** The join credentials
100
+ * for the dialing broker's own browser leg come back with the call because the
101
+ * softphone must already be in the room when the far end answers — fetch them
102
+ * in a second round trip and the first moments of the conversation are
103
+ * silence. `call` carries both legs, so the `controlId` every later command
104
+ * needs is in hand without a read: the `browser` leg is the broker's
105
+ * softphone, the `pstn` leg is the far end.
106
+ *
107
+ * The browser leg belongs to the `brokerId` configured on this SDK instance.
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * const { call, join } = await speko.callControl.dial({ to: '+12015551234' });
112
+ * const mine = call.legs.find((leg) => leg.kind === 'browser')!;
113
+ * // In the browser, connect with the credentials you were just handed:
114
+ * // VoiceConversation.create({ transportToken: join.token, transportUrl: join.url })
115
+ * ```
116
+ */
117
+ dial(params: CallControlDialParams): Promise<CallControlDialResult>;
118
+ /**
119
+ * Mint room credentials for one of **your own** browser legs — how a broker who
120
+ * was rung answers.
121
+ *
122
+ * {@link CallControl.dial} hands you credentials for the leg it created, so
123
+ * this is the inbound counterpart: a `RingOffer` arrives on the presence
124
+ * channel carrying the callee's `controlId`, you `join(controlId)` to get into
125
+ * the room, then {@link CallControl.answer} to connect the audio. Joining
126
+ * first is not optional — answering a leg whose participant is not in the room
127
+ * yet buys you the same silent opening that a post-hoc dial join would.
128
+ *
129
+ * Tokens are minted per join and short-lived by design. A token is a live
130
+ * credential for a room that may carry a customer conversation, so re-join on
131
+ * reconnect instead of caching one; there is no endpoint that hands back a
132
+ * token you already used.
133
+ *
134
+ * Scoped to the configured broker's own legs. A leg belonging to another
135
+ * broker returns `NOT_FOUND` — deliberately indistinguishable from a leg that
136
+ * does not exist, so a `controlId` cannot be probed for existence.
137
+ */
138
+ join(controlId: string): Promise<CallJoinCredentials>;
139
+ /** Retrieve one call and every leg on it. */
140
+ get(callId: string): Promise<CallResource>;
141
+ /**
142
+ * List the org's calls, newest first. Filters are AND-ed, and `status` is
143
+ * validated against the contract's `CallStatus` — a value outside it is a
144
+ * `VALIDATION_ERROR`, not a silently ignored filter, so this type is worth
145
+ * respecting rather than casting past.
146
+ */
147
+ list(params?: CallControlListParams): Promise<{
148
+ calls: CallResource[];
149
+ }>;
150
+ /**
151
+ * Event history for a call, oldest first — the recorded equivalent of the
152
+ * Telnyx webhook stream, so a flow you drove off webhooks can be reconstructed
153
+ * (or reconciled) after the fact.
154
+ */
155
+ events(callId: string): Promise<{
156
+ events: CallEventResource[];
157
+ }>;
158
+ /**
159
+ * Answer a ringing leg — Telnyx `actions/answer`.
160
+ *
161
+ * Only meaningful on an inbound leg that is still `ringing`; answering a leg
162
+ * that is already `active` resolves with `noop: true`.
163
+ */
164
+ answer(controlId: string): Promise<CallCommandResult>;
165
+ /**
166
+ * Hang a leg up — Telnyx `actions/hangup`.
167
+ *
168
+ * Drops this leg only. The call ends when it runs out of live legs, so
169
+ * hanging up one side of a two-party call ends the call; hanging up one of
170
+ * three legs leaves the other two talking.
171
+ *
172
+ * `reason` is free text and comes back as the leg's `endReason`.
173
+ */
174
+ hangup(controlId: string, params?: HangupCommandPayload): Promise<CallCommandResult>;
175
+ /**
176
+ * Bridge this leg to another — Telnyx `actions/bridge`.
177
+ *
178
+ * Both legs must be live. `bridgeTo` is the *other* leg's `controlId`, not a
179
+ * call id: bridging is leg-to-leg, which is how an AI leg and a broker leg
180
+ * end up in the same conversation. A target that is not a live leg of a call
181
+ * your org owns fails with `BRIDGE_TARGET_INVALID`.
182
+ */
183
+ bridge(controlId: string, params: BridgeCommandPayload): Promise<CallCommandResult>;
184
+ /**
185
+ * Put a leg on hold — the leg stops hearing and being heard, and its status
186
+ * becomes `held`.
187
+ *
188
+ * **Diverges from Telnyx.** There is no hold primitive on the transport, so
189
+ * hold is synthesized by isolating subscriptions in both directions. Without
190
+ * a music-on-hold source configured the held party hears **silence**, not hold
191
+ * music — worth saying out loud in your UI, because callers read silence as a
192
+ * dropped call.
193
+ *
194
+ * Hold is a distinct state from {@link CallControl.mute}: a muted leg still
195
+ * hears the call, a held one does not.
196
+ */
197
+ hold(controlId: string): Promise<CallCommandResult>;
198
+ /** Take a leg off hold, restoring audio in both directions. */
199
+ unhold(controlId: string): Promise<CallCommandResult>;
200
+ /**
201
+ * Stop publishing this leg's audio to the call. The leg keeps hearing
202
+ * everyone else — for "they can't hear me but I can hear them", which is what
203
+ * a softphone mute button means. To silence the far end instead, use
204
+ * {@link CallControl.hold}.
205
+ */
206
+ mute(controlId: string): Promise<CallCommandResult>;
207
+ /** Resume publishing this leg's audio. */
208
+ unmute(controlId: string): Promise<CallCommandResult>;
209
+ /**
210
+ * Send DTMF digits out of this leg — Telnyx `actions/send_dtmf`.
211
+ *
212
+ * Accepts `0-9`, `*`, `#`, `,`, and `w` for a pause, up to 64 characters;
213
+ * anything else is rejected with `VALIDATION_ERROR`. Pauses matter when
214
+ * you're driving an IVR that swallows digits sent too early — `"w1w2"` beats
215
+ * `"12"` on most menus.
216
+ *
217
+ * **Address it to the `pstn` leg**, the leg the tones are *for* — any other leg
218
+ * kind fails with `UNSUPPORTED_COMMAND`, since DTMF is only meaningful towards
219
+ * a phone. That the server relays the tones out through an in-room browser leg
220
+ * (only a participant inside the room can publish SIP DTMF) is an
221
+ * implementation detail; it does not change which `controlId` you name.
222
+ */
223
+ dtmf(controlId: string, params: DtmfCommandPayload): Promise<CallCommandResult>;
224
+ /**
225
+ * Transfer a leg elsewhere — Telnyx `actions/transfer`.
226
+ *
227
+ * `to` is an E.164 number for a PSTN transfer, or the `controlId` of a broker
228
+ * leg for an internal one.
229
+ *
230
+ * `mode: 'blind'` hands the call off and drops this leg immediately.
231
+ * `mode: 'warm'` moves this leg into a fresh consultation room and dials the
232
+ * destination into it, so the two humans can talk before the handoff
233
+ * completes. Complete it by {@link CallControl.bridge}-ing the consultation
234
+ * leg back into the original call.
235
+ *
236
+ * **The far end is left alone in the original room, not held.** They hear
237
+ * silence because nobody else is in there — but their leg stays `active` with
238
+ * `onHold: false`, and no `call.hold` event fires, so a UI that renders "on
239
+ * hold" from `leg.onHold` will show them as live. Call
240
+ * {@link CallControl.hold} on that leg yourself before transferring if you
241
+ * want the state to say what the caller is experiencing.
242
+ *
243
+ * Resolves once the transfer is *initiated*, and the two modes then report
244
+ * differently — do not wait on the wrong event:
245
+ *
246
+ * - `blind` emits `call.transfer.completed` once the carrier owns the leg (or
247
+ * `call.transfer.failed` if the REFER was rejected).
248
+ * - `warm` emits `call.transfer.initiated` only. There is no
249
+ * `call.transfer.completed` for it, because *you* complete it: bridge the
250
+ * consultation leg back into the original call, which emits `call.bridged`.
251
+ * `call.transfer.failed` still fires if setting the consultation up failed.
252
+ */
253
+ transfer(controlId: string, params: TransferCommandPayload): Promise<CallCommandResult>;
254
+ /**
255
+ * Come online: mark the configured broker `available` so inbound calls can
256
+ * be routed to them.
257
+ *
258
+ * **Not a SIP registration** despite the name — there is no registrar here,
259
+ * and no endpoint to bind. This flips a flag; what actually makes a browser
260
+ * reachable is holding the connection minted by
261
+ * {@link CallControl.presenceToken} open and calling
262
+ * {@link CallControl.heartbeat} on a timer.
263
+ *
264
+ * Equivalent to `setStatus('available')`.
265
+ *
266
+ * Requires `brokerId` on the SDK instance.
267
+ */
268
+ register(): Promise<BrokerPresenceResource>;
269
+ /**
270
+ * Refresh the broker's heartbeat. Presence goes stale after
271
+ * `PRESENCE_STALE_AFTER_MS`, and a stale broker is treated as offline for
272
+ * routing purposes whatever their stored status says — a crashed tab must not
273
+ * black-hole calls. Call this on an interval comfortably inside that window;
274
+ * a third of it is a good default.
275
+ *
276
+ * Refreshes the broker configured on this SDK instance.
277
+ */
278
+ heartbeat(): Promise<BrokerPresenceResource>;
279
+ /**
280
+ * Set the broker's availability. `busy` and `away` both keep the heartbeat
281
+ * alive while diverting inbound elsewhere; `offline` takes them out of
282
+ * routing entirely.
283
+ *
284
+ * Always sets the broker configured on this SDK instance.
285
+ */
286
+ setStatus(status: BrokerPresenceStatus): Promise<BrokerPresenceResource>;
287
+ /**
288
+ * Mint the credentials a browser needs to hold its presence connection open
289
+ * and be rung. Hand `token` and `url` to `@spekoai/client`.
290
+ *
291
+ * A live credential with a real expiry — mint it when the client connects,
292
+ * re-mint on reconnect, don't stockpile it. A broker with no presence
293
+ * connection open cannot be rung even while `available`: inbound to an
294
+ * unreachable broker fails with `NO_BROKER_AVAILABLE`.
295
+ *
296
+ * What arrives on that connection is a {@link PresenceMessage} — the ring
297
+ * offers and live call events the softphone reacts to.
298
+ *
299
+ * The room minted belongs to the broker configured on this SDK instance.
300
+ */
301
+ presenceToken(): Promise<CallJoinCredentials>;
302
+ private brokerId;
303
+ /**
304
+ * Every command is one POST to the leg's action endpoint, and every one of
305
+ * them resolves to the leg's post-command state — so a client never has to
306
+ * re-read to find out what it just did.
307
+ */
308
+ private command;
309
+ }
28
310
  //# sourceMappingURL=voice.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../../../src/lib/resources/voice.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAE1E;;;;;;;;;;;;GAYG;AACH,qBAAa,KAAK;IACJ,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,UAAU;IAE7C;;;;;;OAMG;IACG,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC;CAG9D"}
1
+ {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../../../src/lib/resources/voice.ts"],"names":[],"mappings":"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"}
@@ -27,3 +27,348 @@ export class Voice {
27
27
  return this.http.post('/v1/sessions/phone', params);
28
28
  }
29
29
  }
30
+ /**
31
+ * Programmable voice — call control for **human** calls.
32
+ *
33
+ * Modeled on Telnyx Call Control, and the mapping is meant to be mechanical:
34
+ * every leg of a call has an opaque `controlId` (Telnyx's `call_control_id`),
35
+ * and every verb is addressed to that handle rather than to a room, a session,
36
+ * or a participant. `speko.callControl.hangup(controlId)` is
37
+ * `POST /calls/{call_control_id}/actions/hangup`, and so on down the list.
38
+ *
39
+ * This is a different product from {@link Voice.dial}, which dials an **AI
40
+ * agent** out over the same telephony gateway. Here the parties are people:
41
+ * brokers on a browser softphone and phones on the PSTN. An AI leg can still be
42
+ * added to a call, which is what makes escalating an agent call to a human an
43
+ * ordinary {@link CallControl.bridge} rather than a special case.
44
+ *
45
+ * ### Porting from Telnyx — the two things that differ
46
+ *
47
+ * 1. **`hold` is silent by default.** There is no hold primitive on the
48
+ * underlying transport; hold is synthesized by isolating subscriptions in
49
+ * both directions. With no music-on-hold source configured the held party
50
+ * hears nothing at all.
51
+ * If your Telnyx flow relied on `playback_start` looping hold music, that
52
+ * has no equivalent yet.
53
+ * 2. **There is no SIP registrar.** A desk phone, a third-party softphone, or
54
+ * another PBX cannot register against Speko and be rung. Human legs are
55
+ * browser legs — `browser` and `pstn` are the only human-reachable leg
56
+ * kinds. Anything in your Telnyx setup that terminated on a SIP connection
57
+ * needs to become a PSTN transfer or a browser client.
58
+ *
59
+ * A handful of Telnyx events also have no faithful equivalent and are absent
60
+ * rather than approximated: no early-media event, and no way to tell a 486 Busy
61
+ * from a generic rejection.
62
+ *
63
+ * ### Broker identity
64
+ *
65
+ * Human calling uses the API key for organization authentication and the
66
+ * `brokerId` passed to `new Speko({ apiKey, brokerId })` for softphone identity.
67
+ * The SDK automatically includes that id on presence, dial, and join requests.
68
+ * Commands and org-wide reads do not need it on the wire.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const speko = new Speko({ apiKey: process.env.SPEKO_API_KEY, brokerId: 'broker-42' });
73
+ *
74
+ * // Come online, then hold a presence connection open so inbound can ring you.
75
+ * await speko.callControl.register();
76
+ * const presence = await speko.callControl.presenceToken();
77
+ * setInterval(() => speko.callControl.heartbeat(), PRESENCE_STALE_AFTER_MS / 3);
78
+ *
79
+ * // Dial out. The browser leg is yours; the PSTN leg is the customer.
80
+ * const { call, join } = await speko.callControl.dial({ to: '+12015551234' });
81
+ * const mine = call.legs.find((leg) => leg.kind === 'browser');
82
+ *
83
+ * // Park the customer, consult a colleague, then hand the call over.
84
+ * await speko.callControl.hold(mine!.controlId);
85
+ * await speko.callControl.transfer(mine!.controlId, { to: '+12015559876', mode: 'warm' });
86
+ * ```
87
+ */
88
+ export class CallControl {
89
+ http;
90
+ configuredBrokerId;
91
+ constructor(http, configuredBrokerId) {
92
+ this.http = http;
93
+ this.configuredBrokerId = configuredBrokerId;
94
+ }
95
+ // --- Calls ---------------------------------------------------------------
96
+ /**
97
+ * Place an outbound PSTN call — Telnyx `POST /calls`.
98
+ *
99
+ * Resolves as soon as the call exists, with the PSTN leg in `initiating` /
100
+ * `ringing`; it does **not** wait for anyone to answer. Watch
101
+ * {@link CallControl.events} (or the browser leg's own connection) for
102
+ * `call.answered`.
103
+ *
104
+ * **Resolves to `{ call, join }`, not to a bare call.** The join credentials
105
+ * for the dialing broker's own browser leg come back with the call because the
106
+ * softphone must already be in the room when the far end answers — fetch them
107
+ * in a second round trip and the first moments of the conversation are
108
+ * silence. `call` carries both legs, so the `controlId` every later command
109
+ * needs is in hand without a read: the `browser` leg is the broker's
110
+ * softphone, the `pstn` leg is the far end.
111
+ *
112
+ * The browser leg belongs to the `brokerId` configured on this SDK instance.
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * const { call, join } = await speko.callControl.dial({ to: '+12015551234' });
117
+ * const mine = call.legs.find((leg) => leg.kind === 'browser')!;
118
+ * // In the browser, connect with the credentials you were just handed:
119
+ * // VoiceConversation.create({ transportToken: join.token, transportUrl: join.url })
120
+ * ```
121
+ */
122
+ dial(params) {
123
+ return this.http.post('/v1/voice/calls', {
124
+ ...params,
125
+ brokerId: this.brokerId(),
126
+ });
127
+ }
128
+ /**
129
+ * Mint room credentials for one of **your own** browser legs — how a broker who
130
+ * was rung answers.
131
+ *
132
+ * {@link CallControl.dial} hands you credentials for the leg it created, so
133
+ * this is the inbound counterpart: a `RingOffer` arrives on the presence
134
+ * channel carrying the callee's `controlId`, you `join(controlId)` to get into
135
+ * the room, then {@link CallControl.answer} to connect the audio. Joining
136
+ * first is not optional — answering a leg whose participant is not in the room
137
+ * yet buys you the same silent opening that a post-hoc dial join would.
138
+ *
139
+ * Tokens are minted per join and short-lived by design. A token is a live
140
+ * credential for a room that may carry a customer conversation, so re-join on
141
+ * reconnect instead of caching one; there is no endpoint that hands back a
142
+ * token you already used.
143
+ *
144
+ * Scoped to the configured broker's own legs. A leg belonging to another
145
+ * broker returns `NOT_FOUND` — deliberately indistinguishable from a leg that
146
+ * does not exist, so a `controlId` cannot be probed for existence.
147
+ */
148
+ join(controlId) {
149
+ return this.http.post(`/v1/voice/legs/${encodeURIComponent(controlId)}/join`, { brokerId: this.brokerId() });
150
+ }
151
+ /** Retrieve one call and every leg on it. */
152
+ get(callId) {
153
+ return this.http.get(`/v1/voice/calls/${encodeURIComponent(callId)}`);
154
+ }
155
+ /**
156
+ * List the org's calls, newest first. Filters are AND-ed, and `status` is
157
+ * validated against the contract's `CallStatus` — a value outside it is a
158
+ * `VALIDATION_ERROR`, not a silently ignored filter, so this type is worth
159
+ * respecting rather than casting past.
160
+ */
161
+ list(params = {}) {
162
+ const query = new URLSearchParams();
163
+ if (params.status)
164
+ query.set('status', params.status);
165
+ if (params.direction)
166
+ query.set('direction', params.direction);
167
+ if (params.brokerId)
168
+ query.set('brokerId', params.brokerId);
169
+ if (params.limit !== undefined)
170
+ query.set('limit', String(params.limit));
171
+ const suffix = query.toString() ? `?${query}` : '';
172
+ return this.http.get(`/v1/voice/calls${suffix}`);
173
+ }
174
+ /**
175
+ * Event history for a call, oldest first — the recorded equivalent of the
176
+ * Telnyx webhook stream, so a flow you drove off webhooks can be reconstructed
177
+ * (or reconciled) after the fact.
178
+ */
179
+ events(callId) {
180
+ return this.http.get(`/v1/voice/calls/${encodeURIComponent(callId)}/events`);
181
+ }
182
+ // --- Commands ------------------------------------------------------------
183
+ /**
184
+ * Answer a ringing leg — Telnyx `actions/answer`.
185
+ *
186
+ * Only meaningful on an inbound leg that is still `ringing`; answering a leg
187
+ * that is already `active` resolves with `noop: true`.
188
+ */
189
+ answer(controlId) {
190
+ return this.command(controlId, 'answer');
191
+ }
192
+ /**
193
+ * Hang a leg up — Telnyx `actions/hangup`.
194
+ *
195
+ * Drops this leg only. The call ends when it runs out of live legs, so
196
+ * hanging up one side of a two-party call ends the call; hanging up one of
197
+ * three legs leaves the other two talking.
198
+ *
199
+ * `reason` is free text and comes back as the leg's `endReason`.
200
+ */
201
+ hangup(controlId, params = {}) {
202
+ return this.command(controlId, 'hangup', params);
203
+ }
204
+ /**
205
+ * Bridge this leg to another — Telnyx `actions/bridge`.
206
+ *
207
+ * Both legs must be live. `bridgeTo` is the *other* leg's `controlId`, not a
208
+ * call id: bridging is leg-to-leg, which is how an AI leg and a broker leg
209
+ * end up in the same conversation. A target that is not a live leg of a call
210
+ * your org owns fails with `BRIDGE_TARGET_INVALID`.
211
+ */
212
+ bridge(controlId, params) {
213
+ return this.command(controlId, 'bridge', params);
214
+ }
215
+ /**
216
+ * Put a leg on hold — the leg stops hearing and being heard, and its status
217
+ * becomes `held`.
218
+ *
219
+ * **Diverges from Telnyx.** There is no hold primitive on the transport, so
220
+ * hold is synthesized by isolating subscriptions in both directions. Without
221
+ * a music-on-hold source configured the held party hears **silence**, not hold
222
+ * music — worth saying out loud in your UI, because callers read silence as a
223
+ * dropped call.
224
+ *
225
+ * Hold is a distinct state from {@link CallControl.mute}: a muted leg still
226
+ * hears the call, a held one does not.
227
+ */
228
+ hold(controlId) {
229
+ return this.command(controlId, 'hold');
230
+ }
231
+ /** Take a leg off hold, restoring audio in both directions. */
232
+ unhold(controlId) {
233
+ return this.command(controlId, 'unhold');
234
+ }
235
+ /**
236
+ * Stop publishing this leg's audio to the call. The leg keeps hearing
237
+ * everyone else — for "they can't hear me but I can hear them", which is what
238
+ * a softphone mute button means. To silence the far end instead, use
239
+ * {@link CallControl.hold}.
240
+ */
241
+ mute(controlId) {
242
+ return this.command(controlId, 'mute');
243
+ }
244
+ /** Resume publishing this leg's audio. */
245
+ unmute(controlId) {
246
+ return this.command(controlId, 'unmute');
247
+ }
248
+ /**
249
+ * Send DTMF digits out of this leg — Telnyx `actions/send_dtmf`.
250
+ *
251
+ * Accepts `0-9`, `*`, `#`, `,`, and `w` for a pause, up to 64 characters;
252
+ * anything else is rejected with `VALIDATION_ERROR`. Pauses matter when
253
+ * you're driving an IVR that swallows digits sent too early — `"w1w2"` beats
254
+ * `"12"` on most menus.
255
+ *
256
+ * **Address it to the `pstn` leg**, the leg the tones are *for* — any other leg
257
+ * kind fails with `UNSUPPORTED_COMMAND`, since DTMF is only meaningful towards
258
+ * a phone. That the server relays the tones out through an in-room browser leg
259
+ * (only a participant inside the room can publish SIP DTMF) is an
260
+ * implementation detail; it does not change which `controlId` you name.
261
+ */
262
+ dtmf(controlId, params) {
263
+ return this.command(controlId, 'dtmf', params);
264
+ }
265
+ /**
266
+ * Transfer a leg elsewhere — Telnyx `actions/transfer`.
267
+ *
268
+ * `to` is an E.164 number for a PSTN transfer, or the `controlId` of a broker
269
+ * leg for an internal one.
270
+ *
271
+ * `mode: 'blind'` hands the call off and drops this leg immediately.
272
+ * `mode: 'warm'` moves this leg into a fresh consultation room and dials the
273
+ * destination into it, so the two humans can talk before the handoff
274
+ * completes. Complete it by {@link CallControl.bridge}-ing the consultation
275
+ * leg back into the original call.
276
+ *
277
+ * **The far end is left alone in the original room, not held.** They hear
278
+ * silence because nobody else is in there — but their leg stays `active` with
279
+ * `onHold: false`, and no `call.hold` event fires, so a UI that renders "on
280
+ * hold" from `leg.onHold` will show them as live. Call
281
+ * {@link CallControl.hold} on that leg yourself before transferring if you
282
+ * want the state to say what the caller is experiencing.
283
+ *
284
+ * Resolves once the transfer is *initiated*, and the two modes then report
285
+ * differently — do not wait on the wrong event:
286
+ *
287
+ * - `blind` emits `call.transfer.completed` once the carrier owns the leg (or
288
+ * `call.transfer.failed` if the REFER was rejected).
289
+ * - `warm` emits `call.transfer.initiated` only. There is no
290
+ * `call.transfer.completed` for it, because *you* complete it: bridge the
291
+ * consultation leg back into the original call, which emits `call.bridged`.
292
+ * `call.transfer.failed` still fires if setting the consultation up failed.
293
+ */
294
+ transfer(controlId, params) {
295
+ return this.command(controlId, 'transfer', params);
296
+ }
297
+ // --- Broker presence -----------------------------------------------------
298
+ /**
299
+ * Come online: mark the configured broker `available` so inbound calls can
300
+ * be routed to them.
301
+ *
302
+ * **Not a SIP registration** despite the name — there is no registrar here,
303
+ * and no endpoint to bind. This flips a flag; what actually makes a browser
304
+ * reachable is holding the connection minted by
305
+ * {@link CallControl.presenceToken} open and calling
306
+ * {@link CallControl.heartbeat} on a timer.
307
+ *
308
+ * Equivalent to `setStatus('available')`.
309
+ *
310
+ * Requires `brokerId` on the SDK instance.
311
+ */
312
+ register() {
313
+ return this.setStatus('available');
314
+ }
315
+ /**
316
+ * Refresh the broker's heartbeat. Presence goes stale after
317
+ * `PRESENCE_STALE_AFTER_MS`, and a stale broker is treated as offline for
318
+ * routing purposes whatever their stored status says — a crashed tab must not
319
+ * black-hole calls. Call this on an interval comfortably inside that window;
320
+ * a third of it is a good default.
321
+ *
322
+ * Refreshes the broker configured on this SDK instance.
323
+ */
324
+ heartbeat() {
325
+ return this.http.post('/v1/voice/presence/heartbeat', {
326
+ brokerId: this.brokerId(),
327
+ });
328
+ }
329
+ /**
330
+ * Set the broker's availability. `busy` and `away` both keep the heartbeat
331
+ * alive while diverting inbound elsewhere; `offline` takes them out of
332
+ * routing entirely.
333
+ *
334
+ * Always sets the broker configured on this SDK instance.
335
+ */
336
+ setStatus(status) {
337
+ return this.http.put('/v1/voice/presence', {
338
+ status,
339
+ brokerId: this.brokerId(),
340
+ });
341
+ }
342
+ /**
343
+ * Mint the credentials a browser needs to hold its presence connection open
344
+ * and be rung. Hand `token` and `url` to `@spekoai/client`.
345
+ *
346
+ * A live credential with a real expiry — mint it when the client connects,
347
+ * re-mint on reconnect, don't stockpile it. A broker with no presence
348
+ * connection open cannot be rung even while `available`: inbound to an
349
+ * unreachable broker fails with `NO_BROKER_AVAILABLE`.
350
+ *
351
+ * What arrives on that connection is a {@link PresenceMessage} — the ring
352
+ * offers and live call events the softphone reacts to.
353
+ *
354
+ * The room minted belongs to the broker configured on this SDK instance.
355
+ */
356
+ presenceToken() {
357
+ return this.http.post('/v1/voice/presence/token', {
358
+ brokerId: this.brokerId(),
359
+ });
360
+ }
361
+ brokerId() {
362
+ if (this.configuredBrokerId)
363
+ return this.configuredBrokerId;
364
+ throw new Error('Speko: brokerId is required for human-calling presence, dial, and join methods; pass it to new Speko({ apiKey, brokerId })');
365
+ }
366
+ /**
367
+ * Every command is one POST to the leg's action endpoint, and every one of
368
+ * them resolves to the leg's post-command state — so a client never has to
369
+ * re-read to find out what it just did.
370
+ */
371
+ command(controlId, command, payload = {}) {
372
+ return this.http.post(`/v1/voice/legs/${encodeURIComponent(controlId)}/actions/${command}`, payload);
373
+ }
374
+ }
@@ -0,0 +1,25 @@
1
+ import type { HttpClient } from '../http.js';
2
+ import type { WebhookDeliveryDetail, WebhookDeliveryListParams, WebhookDeliveryPage, WebhookEndpoint, WebhookEndpointInput, WebhookEndpointUpdate } from '../types/index.js';
3
+ /** Organization-owned lifecycle webhook endpoints and their delivery history. */
4
+ export declare class Webhooks {
5
+ private readonly http;
6
+ readonly deliveries: WebhookDeliveries;
7
+ constructor(http: HttpClient);
8
+ list(): Promise<WebhookEndpoint[]>;
9
+ create(params: WebhookEndpointInput): Promise<WebhookEndpoint>;
10
+ get(endpointId: string): Promise<WebhookEndpoint>;
11
+ update(endpointId: string, params: WebhookEndpointUpdate): Promise<WebhookEndpoint>;
12
+ delete(endpointId: string): Promise<void>;
13
+ }
14
+ export declare class WebhookDeliveries {
15
+ private readonly http;
16
+ constructor(http: HttpClient);
17
+ list(params?: WebhookDeliveryListParams): Promise<WebhookDeliveryPage>;
18
+ get(deliveryId: string): Promise<WebhookDeliveryDetail>;
19
+ redeliver(deliveryId: string): Promise<{
20
+ delivered: boolean;
21
+ httpStatus: number | null;
22
+ error: string | null;
23
+ }>;
24
+ }
25
+ //# sourceMappingURL=webhooks.d.ts.map