@spekoai/mcp-calls 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,7 @@ backing server instead of running in-process, set `SPEKO_MCP_SERVER_URL`.
47
47
  | --- | --- |
48
48
  | `lookup_business(name, location?, phone_number?)` | Resolve a business → dialable candidates + a signed `dial_token` per callable one (the only path that can authorize a call). Pass `phone_number` (E.164 — e.g. found via the agent's web search) to skip the directory lookup; still carrier-verified as a business line. |
49
49
  | `make_call(dial_token, objective, caller_name, context?)` | Place the disclosed, objective-scoped call; wait for it to finish; return the `OUTCOME` + transcript. Honest `connected`/`answered`/`not_connected`. |
50
- | `call_number(phone_number, objective, caller_name)` | Disclosed PERSONAL call to a specific number (e.g. a friend) — mobiles allowed. On by default (set `SPEKO_ALLOW_DIRECT_DIAL=0` to restrict to business lines). |
50
+ | `call_number(phone_number, objective, caller_name)` | Disclosed call to a number you have or found via web search (business or personal) — the default path once you have the number. Mobiles allowed. On by default (`SPEKO_ALLOW_DIRECT_DIAL=0` restricts to business lines). |
51
51
  | `get_call(call_id)` | Read-only: re-check a call's status, `OUTCOME`, and transcript. Never dials. |
52
52
  | `check_call_readiness()` | Read-only preflight: auth, credit balance, outbound caller-ID. Never dials. |
53
53
 
package/dist/index.js CHANGED
@@ -147,13 +147,32 @@ var init_client = __esm({
147
147
  */
148
148
  async getSession(sessionId) {
149
149
  const resp = await fetch(`${this.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
150
- headers: { accept: "application/json", authorization: `Bearer ${this.apiKey}` }
150
+ headers: { accept: "application/json", authorization: `Bearer ${this.apiKey}` },
151
+ signal: AbortSignal.timeout(3e4)
151
152
  });
152
153
  if (!resp.ok) {
153
154
  throw new SpekoApiError(`GET /v1/sessions/${sessionId} failed`, resp.status, "session_fetch_failed");
154
155
  }
155
156
  return await resp.json();
156
157
  }
158
+ /**
159
+ * Raw `GET /v1/calls/{id}/events` — the call's event timeline. We poll this to find
160
+ * the AUTHORITATIVE end of the call (`room_finished`), because the call `status` can
161
+ * flip to "failed" early (a first-audio SLA timeout) while the call is still live and
162
+ * a full conversation follows. Returns a best-effort array (each event carries an
163
+ * `event_type`); an empty array on an unexpected shape.
164
+ */
165
+ async getEvents(callId) {
166
+ const resp = await fetch(`${this.baseUrl}/v1/calls/${encodeURIComponent(callId)}/events`, {
167
+ headers: { accept: "application/json", authorization: `Bearer ${this.apiKey}` },
168
+ signal: AbortSignal.timeout(3e4)
169
+ });
170
+ if (!resp.ok) {
171
+ throw new SpekoApiError(`GET /v1/calls/${callId}/events failed`, resp.status, "events_fetch_failed");
172
+ }
173
+ const body = await resp.json();
174
+ return Array.isArray(body.events) ? body.events : [];
175
+ }
157
176
  };
158
177
  }
159
178
  });
@@ -195,7 +214,7 @@ var init_errors = __esm({
195
214
  });
196
215
 
197
216
  // ../server/dist/constants.js
198
- var MAX_CALL_SECONDS, MIN_CALL_SECONDS, FAST_POLLS, FAST_POLL_SECONDS, SLOW_POLL_SECONDS, STUB_DIAL_STATUS, NOT_PLACED_STATUS, NOT_CONNECTED_STATUS, MIN_CALL_BALANCE_USD, TERMINAL_STATUSES, OUTCOME_MARKER, DIAL_INTENT_LANGUAGE, DIAL_STT_KEYWORDS, MAX_CALLER_NAME_CHARS, OBJECTIVE_MIN_CHARS, E164_RE, ALLOWED_LINE_TYPES, US_PREMIUM_RE, EMERGENCY_NUMBERS, OBJECTIVE_BLOCK_RE, DIAL_TOKEN_DEFAULT_TTL_SECONDS, DIAL_TOKEN_SECRET_ENV, QUIET_START_HOUR, QUIET_END_HOUR, MAKE_CALL_NEXT_STEP, MAKE_CALL_DIAL_NEXT_STEP, CHECK_READINESS_NEXT_STEP, AUTH_NEXT_STEP;
217
+ var MAX_CALL_SECONDS, MIN_CALL_SECONDS, FAST_POLLS, FAST_POLL_SECONDS, SLOW_POLL_SECONDS, STUB_DIAL_STATUS, NOT_PLACED_STATUS, NOT_CONNECTED_STATUS, MIN_CALL_BALANCE_USD, HARD_TERMINAL_STATUSES, ROOM_END_EVENTS, HARD_FAILURE_EVENTS, OUTCOME_MARKER, BARE_OUTCOME_RE, DIAL_INTENT_LANGUAGE, DIAL_STT_KEYWORDS, MAX_CALLER_NAME_CHARS, OBJECTIVE_MIN_CHARS, E164_RE, ALLOWED_LINE_TYPES, US_PREMIUM_RE, EMERGENCY_NUMBERS, OBJECTIVE_BLOCK_RE, DIAL_TOKEN_DEFAULT_TTL_SECONDS, DIAL_TOKEN_SECRET_ENV, QUIET_START_HOUR, QUIET_END_HOUR, MAKE_CALL_NEXT_STEP, MAKE_CALL_DIAL_NEXT_STEP, CHECK_READINESS_NEXT_STEP, AUTH_NEXT_STEP;
199
218
  var init_constants = __esm({
200
219
  "../server/dist/constants.js"() {
201
220
  "use strict";
@@ -208,19 +227,20 @@ var init_constants = __esm({
208
227
  NOT_PLACED_STATUS = "not_placed";
209
228
  NOT_CONNECTED_STATUS = "not_connected";
210
229
  MIN_CALL_BALANCE_USD = 0.5;
211
- TERMINAL_STATUSES = /* @__PURE__ */ new Set([
230
+ HARD_TERMINAL_STATUSES = /* @__PURE__ */ new Set([
212
231
  "completed",
213
232
  "ended",
214
- "failed",
215
233
  "no_answer",
216
234
  "no-answer",
217
235
  "busy",
218
236
  "canceled",
219
237
  "cancelled",
220
- "error",
221
238
  "hangup"
222
239
  ]);
240
+ ROOM_END_EVENTS = /* @__PURE__ */ new Set(["room_finished", "call.end_tool.completed"]);
241
+ HARD_FAILURE_EVENTS = /* @__PURE__ */ new Set(["agent.dispatch_failed", "sip.dial_failed"]);
223
242
  OUTCOME_MARKER = "OUTCOME:";
243
+ BARE_OUTCOME_RE = /^(failed|abandoned|completed?|error|no[_-]?answer|busy|canceled|cancelled|ended|success|unknown|in[_-]?progress|dialing)$/i;
224
244
  DIAL_INTENT_LANGUAGE = "en";
225
245
  DIAL_STT_KEYWORDS = ["reservation", "table for", "tonight", "8 PM"];
226
246
  MAX_CALLER_NAME_CHARS = 80;
@@ -457,6 +477,12 @@ var init_timezone = __esm({
457
477
  "925": "America/Los_Angeles",
458
478
  "949": "America/Los_Angeles",
459
479
  "971": "America/Los_Angeles",
480
+ // Bay Area / NorCal overlays (628=SF, 669=San Jose, 341=Oakland) + Central Valley (209/279)
481
+ "628": "America/Los_Angeles",
482
+ "669": "America/Los_Angeles",
483
+ "341": "America/Los_Angeles",
484
+ "209": "America/Los_Angeles",
485
+ "279": "America/Los_Angeles",
460
486
  // Mountain (Phoenix = no DST)
461
487
  "303": "America/Denver",
462
488
  "385": "America/Denver",
@@ -916,7 +942,7 @@ function assessConnection(session, transcript) {
916
942
  const ccidRaw = session.phoneCall?.callControlId;
917
943
  const callControlId = typeof ccidRaw === "string" && ccidRaw.trim() ? ccidRaw : null;
918
944
  const carrierBilled = Array.isArray(session.usage) && session.usage.some(isCarrierUsage);
919
- const connected = Boolean(callControlId) || carrierBilled || answered;
945
+ const connected = answered || Boolean(callControlId) || carrierBilled;
920
946
  return { connected, answered, callControlId, carrierBilled };
921
947
  }
922
948
  var CARRIER_PROVIDERS, CARRIER_METRIC_RE;
@@ -951,7 +977,10 @@ function shapeCallSummary(input) {
951
977
  summary.status = NOT_CONNECTED_STATUS;
952
978
  summary.reason = NOT_CONNECTED_REASON;
953
979
  } else if (connected && !assessment.answered) {
980
+ summary.status = "no_answer";
954
981
  summary.reason = NO_ANSWER_REASON;
982
+ } else if (connected && assessment.answered) {
983
+ summary.status = "completed";
955
984
  }
956
985
  return summary;
957
986
  }
@@ -961,7 +990,7 @@ var init_summary = __esm({
961
990
  "use strict";
962
991
  init_constants();
963
992
  init_assess();
964
- NOT_CONNECTED_REASON = "The session and AI agent started, but no telephony leg reached the carrier (callControlId null, no carrier minutes) \u2014 the phone never rang.";
993
+ NOT_CONNECTED_REASON = "No real two-way call took place \u2014 the AI agent started but the other party was never heard (no answer, voicemail, or the call did not truly connect).";
965
994
  NO_ANSWER_REASON = "The call connected but the other party never spoke (no answer / voicemail / hung up before responding).";
966
995
  }
967
996
  });
@@ -1004,7 +1033,8 @@ async function makeCall(input, deps) {
1004
1033
  const offset = typeof payload.utc_offset_minutes === "number" ? payload.utc_offset_minutes : null;
1005
1034
  const quietReason = quietHoursReason(offset);
1006
1035
  if (quietReason) {
1007
- const next = offset == null ? MAKE_CALL_NEXT_STEP : "Wait until destination business hours (08:00-21:00 local time) and run make_call again.";
1036
+ const direct = deps.allowAnyLineType === true;
1037
+ const next = offset == null ? direct ? "Re-run call_number with utc_offset_minutes for the destination's city (e.g. -420 US Pacific summer, -300 US Eastern)." : MAKE_CALL_NEXT_STEP : `Wait until destination business hours (08:00-21:00 local time) and run ${direct ? "call_number" : "make_call"} again.`;
1008
1038
  throw new RejectionError(quietReason, next);
1009
1039
  }
1010
1040
  const objectiveReason = objectiveBlockedReason(input.objective);
@@ -1021,9 +1051,10 @@ async function makeCall(input, deps) {
1021
1051
  const body = {
1022
1052
  to: e164,
1023
1053
  ...fromNumber ? { from: fromNumber } : {},
1024
- // optimizeFor=latency is best for a LIVE call: the selector keeps gpt-5 (best time-to-
1025
- // first-token) + a fast streaming STT, avoiding the multi-second dead air the other modes
1026
- // route to. Benchmark-driven via Speko's selector.
1054
+ // optimizeFor=latency is best for a LIVE call: it routes to a fast streaming STT + a low
1055
+ // time-to-first-token LLM, avoiding the multi-second dead air the balanced/accuracy modes
1056
+ // introduce. The actual LLM/TTS/STT models are pinned below via constraints
1057
+ // (cfg.llmPin / cfg.ttsPin / cfg.sttPin), not left to the selector.
1027
1058
  intent: { language: DIAL_INTENT_LANGUAGE, optimizeFor: deps.cfg.optimizeFor },
1028
1059
  // A specific `voice` (cfg.voice) is safe ONLY because it's an ElevenLabs voice matching the
1029
1060
  // ElevenLabs TTS pin below — always verify a voice with scripts/verify-tts.mjs first. A voice
@@ -1092,43 +1123,68 @@ async function runPhoneCall(body, maxSeconds, deps, sleep) {
1092
1123
  }
1093
1124
  let elapsed = 0;
1094
1125
  let polls = 0;
1095
- while (!TERMINAL_STATUSES.has(status) && elapsed < maxSeconds) {
1126
+ let ended = false;
1127
+ while (elapsed < maxSeconds) {
1096
1128
  const interval = polls < FAST_POLLS ? FAST_POLL_SECONDS : SLOW_POLL_SECONDS;
1097
1129
  await sleep(interval * 1e3);
1098
1130
  elapsed += interval;
1099
1131
  polls += 1;
1132
+ let events;
1100
1133
  try {
1101
- const d = await deps.client.getCall(callId);
1102
- status = String(d.status ?? "").toLowerCase();
1103
- } catch (e) {
1104
- throw new AppError(e.message, {
1105
- statusCode: 502,
1106
- nextStep: `Do not dial again; the call (call_id '${callId}') may still be in progress. Check it with get_call('${callId}').`
1107
- });
1134
+ events = await deps.client.getEvents(callId);
1135
+ } catch {
1136
+ try {
1137
+ const d = await deps.client.getCall(callId);
1138
+ status = String(d.status ?? "").toLowerCase();
1139
+ } catch (e) {
1140
+ throw new AppError(e.message, {
1141
+ statusCode: 502,
1142
+ nextStep: `Do not dial again; the call (call_id '${callId}') may still be in progress. Check it with get_call('${callId}').`
1143
+ });
1144
+ }
1145
+ if (HARD_TERMINAL_STATUSES.has(status)) {
1146
+ ended = true;
1147
+ break;
1148
+ }
1149
+ continue;
1150
+ }
1151
+ const types = new Set(events.map((e) => String(e.event_type ?? e.type ?? "").toLowerCase()));
1152
+ if ([...ROOM_END_EVENTS].some((t) => types.has(t)) || [...HARD_FAILURE_EVENTS].some((t) => types.has(t))) {
1153
+ ended = true;
1154
+ break;
1108
1155
  }
1109
1156
  }
1110
- if (!TERMINAL_STATUSES.has(status)) {
1157
+ if (!ended) {
1111
1158
  return {
1112
1159
  ...baseSummary(callId, to, from),
1113
1160
  status: "timeout",
1114
1161
  duration_seconds: elapsed,
1115
1162
  connected: true,
1116
- reason: "Reached the wait limit before the call reached a terminal state; it may still be in progress."
1163
+ reason: "Reached the wait limit before the call ended; it may still be in progress."
1117
1164
  };
1118
1165
  }
1119
1166
  return finalize(callId, to, from, status, elapsed, deps);
1120
1167
  }
1121
1168
  async function finalize(callId, to, from, status, elapsed, deps) {
1169
+ const sleep = deps.sleep ?? defaultSleep;
1122
1170
  let transcript = null;
1123
1171
  let transcriptError;
1124
1172
  let outcome = null;
1125
- try {
1126
- const detail = await deps.client.getCall(callId);
1127
- transcript = detail.transcript ?? null;
1128
- const reportOutcome = detail.report?.outcome;
1129
- outcome = typeof reportOutcome === "string" && reportOutcome.trim() ? reportOutcome.trim() : extractOutcome(transcript);
1130
- } catch (e) {
1131
- transcriptError = e.message;
1173
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1174
+ try {
1175
+ const detail = await deps.client.getCall(callId);
1176
+ transcript = detail.transcript ?? null;
1177
+ const reportOutcome = typeof detail.report?.outcome === "string" ? detail.report.outcome.trim() : "";
1178
+ const substantive = reportOutcome && !BARE_OUTCOME_RE.test(reportOutcome) ? reportOutcome : "";
1179
+ outcome = substantive || extractOutcome(transcript);
1180
+ transcriptError = void 0;
1181
+ } catch (e) {
1182
+ transcriptError = e.message;
1183
+ }
1184
+ if (extractReply(transcript) !== null)
1185
+ break;
1186
+ if (attempt < 2)
1187
+ await sleep(3e3);
1132
1188
  }
1133
1189
  let session = null;
1134
1190
  try {
@@ -1172,7 +1228,7 @@ async function callNumber(input, deps) {
1172
1228
  if (!deps.cfg.allowDirectDial) {
1173
1229
  throw new RejectionError("Direct dialing has been turned off on this deployment (SPEKO_ALLOW_DIRECT_DIAL is set to off), so call_number is disabled and cannot place this call. (Direct dialing is on by default.)", "To call a business, use lookup_business + make_call instead. To use call_number, unset SPEKO_ALLOW_DIRECT_DIAL (or set it to 1) in the MCP's env and restart, then retry.");
1174
1230
  }
1175
- const e164 = typeof input.phoneNumber === "string" ? input.phoneNumber.trim() : "";
1231
+ const e164 = typeof input.phoneNumber === "string" ? input.phoneNumber.replace(/[^\d+]/g, "") : "";
1176
1232
  const blocked = dialBlockedReason(e164);
1177
1233
  if (blocked) {
1178
1234
  throw new RejectionError(blocked, "Pass a valid E.164 number (e.g. +77011234567) that you have consent to call.");
@@ -1328,8 +1384,9 @@ async function describeCall(callId, client) {
1328
1384
  const transcript = detail.transcript ?? null;
1329
1385
  const to = strField(detail.metadata, "to") ?? strField(detail.metadata, "dialedNumber");
1330
1386
  const from = strField(detail.metadata, "from");
1331
- const reportOutcome = detail.report?.outcome;
1332
- const outcome = typeof reportOutcome === "string" && reportOutcome.trim() ? reportOutcome.trim() : extractOutcome(transcript);
1387
+ const reportOutcome = typeof detail.report?.outcome === "string" ? detail.report.outcome.trim() : "";
1388
+ const substantive = reportOutcome && !BARE_OUTCOME_RE.test(reportOutcome) ? reportOutcome : "";
1389
+ const outcome = substantive || extractOutcome(transcript);
1333
1390
  let session = null;
1334
1391
  try {
1335
1392
  session = await client.getSession(callId);
@@ -2049,7 +2106,9 @@ function getServerClient() {
2049
2106
 
2050
2107
  // src/tools/CallNumberTool.ts
2051
2108
  var schema2 = z2.object({
2052
- phone_number: z2.string().describe("Number to call, E.164 (e.g. +77011234567). A real number the user has consent to call."),
2109
+ phone_number: z2.string().describe(
2110
+ "Number to call in full international E.164 \u2014 leading + and country code (e.g. +14152857117, NOT (415) 285-7117). A number the user asked you to call or explicitly provided."
2111
+ ),
2053
2112
  objective: z2.string().describe("What to say / accomplish, e.g. 'Tell Sam that John says happy birthday and misses him.'"),
2054
2113
  caller_name: z2.string().describe("Name of the human the call is on behalf of (1-80 chars); spoken in the AI-disclosure opening."),
2055
2114
  recipient_name: z2.string().optional().describe("Who you're calling, used in the greeting (e.g. 'Sam')."),
@@ -2085,7 +2144,7 @@ function summarize(s) {
2085
2144
  }
2086
2145
  var CallNumberTool = class extends MCPTool2 {
2087
2146
  name = "call_number";
2088
- description = "Place a disclosed PERSONAL call to a specific phone number (e.g. a friend) \u2014 NOT a business lookup. Available by default. Every call opens with the non-removable AI disclosure, and quiet hours + the no-sell/no-spam screen still apply (mobiles are allowed here, unlike make_call). Use lookup_business + make_call for businesses; use this only for a number the user explicitly provides and has consent to call.";
2147
+ description = "Place a disclosed call to a phone number you HAVE or FOUND (e.g. via web search) \u2014 the DEFAULT path for calling any business or person. Works with just the user's Speko key, no extra setup. Every call opens with the non-removable AI disclosure; quiet hours and the no-sell/no-spam screen still apply (mobiles allowed). lookup_business + make_call is the OPTIONAL verified-directory path (it needs the server's carrier/directory keys); prefer call_number when you already have or found the number. Only dial a number the user asked you to call or explicitly provided \u2014 never one you invented.";
2089
2148
  schema = schema2;
2090
2149
  annotations = {
2091
2150
  title: "Call a Number",
@@ -2098,6 +2157,8 @@ var CallNumberTool = class extends MCPTool2 {
2098
2157
  const maxWait = clamp2(input.max_duration_seconds ?? MAX_WAIT, MIN_WAIT, MAX_WAIT);
2099
2158
  const client = getServerClient();
2100
2159
  let elapsed = 0;
2160
+ void this.reportProgress(0, maxWait, "Placing the call\u2026").catch(() => {
2161
+ });
2101
2162
  const timer = setInterval(() => {
2102
2163
  elapsed += HEARTBEAT_MS / 1e3;
2103
2164
  void this.reportProgress(elapsed, maxWait, `Call in progress \u2014 ${elapsed}s elapsed`).catch(() => {
@@ -2261,6 +2322,8 @@ var MakeCallTool = class extends MCPTool6 {
2261
2322
  const maxWait = clamp3(input.max_duration_seconds ?? MAX_WAIT2, MIN_WAIT2, MAX_WAIT2);
2262
2323
  const client = getServerClient();
2263
2324
  let elapsed = 0;
2325
+ void this.reportProgress(0, maxWait, "Placing the call\u2026").catch(() => {
2326
+ });
2264
2327
  const timer = setInterval(() => {
2265
2328
  elapsed += HEARTBEAT_MS2 / 1e3;
2266
2329
  void this.reportProgress(elapsed, maxWait, `Call in progress \u2014 ${elapsed}s elapsed`).catch(() => {
@@ -2294,7 +2357,7 @@ if (cmd === "init" || cmd === "setup" || cmd === "login") {
2294
2357
  loadEnv();
2295
2358
  var server = new MCPServer({
2296
2359
  name: "speko-calls",
2297
- version: "0.4.0",
2360
+ version: "0.4.1",
2298
2361
  transport: { type: "stdio" }
2299
2362
  });
2300
2363
  server.addTool(LookupBusinessTool);