@spekoai/mcp-calls 0.3.2 → 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
@@ -45,9 +45,9 @@ backing server instead of running in-process, set `SPEKO_MCP_SERVER_URL`.
45
45
 
46
46
  | Tool | What it does |
47
47
  | --- | --- |
48
- | `lookup_business(name, location?)` | Resolve a business → dialable candidates + a signed `dial_token` per callable one. The only path that can authorize a call. |
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",
@@ -682,54 +708,69 @@ var init_twilio = __esm({
682
708
  });
683
709
 
684
710
  // ../server/dist/lookup/index.js
711
+ async function verifyAndMint(c2, cfg, bearerHash) {
712
+ let lineType = null;
713
+ let blocked = dialBlockedReason(c2.e164);
714
+ if (!blocked) {
715
+ lineType = cfg.twilio ? await carrierLineType(c2.e164, cfg.twilio) : null;
716
+ blocked = lineTypeBlockedReason(lineType);
717
+ }
718
+ if (!blocked && c2.utcOffsetMinutes == null) {
719
+ blocked = "Couldn't determine the destination's local timezone, so quiet hours (08:00-21:00 local) can't be enforced. Pass utc_offset_minutes (e.g. -300 for US Eastern, -480 for US Pacific) to proceed.";
720
+ }
721
+ if (blocked) {
722
+ return {
723
+ name: c2.name,
724
+ address: c2.address,
725
+ phone: c2.e164,
726
+ line_type: lineType,
727
+ allowed: false,
728
+ blocked_reason: blocked,
729
+ dial_token: null,
730
+ utc_offset_minutes: c2.utcOffsetMinutes
731
+ };
732
+ }
733
+ const dialToken = mintDialToken({
734
+ e164: c2.e164,
735
+ lineType,
736
+ businessName: c2.name,
737
+ utcOffsetMinutes: c2.utcOffsetMinutes,
738
+ bearerHash,
739
+ secret: cfg.dialTokenSecret
740
+ });
741
+ return {
742
+ name: c2.name,
743
+ address: c2.address,
744
+ phone: c2.e164,
745
+ line_type: lineType,
746
+ allowed: true,
747
+ blocked_reason: null,
748
+ dial_token: dialToken,
749
+ utc_offset_minutes: c2.utcOffsetMinutes
750
+ };
751
+ }
685
752
  async function lookupBusiness(input, deps) {
686
753
  if (demoEnabled()) {
687
754
  return { candidates: [demoLookupCandidate(input, deps.bearerHash)], source: "demo" };
688
755
  }
689
756
  const { cfg } = deps;
757
+ const provided = typeof input.phoneNumber === "string" ? input.phoneNumber.replace(/[^\d+]/g, "") : "";
758
+ if (provided) {
759
+ const candidate = await verifyAndMint({
760
+ name: input.name,
761
+ address: (input.location ?? "").trim(),
762
+ e164: provided,
763
+ utcOffsetMinutes: typeof input.utcOffsetMinutes === "number" ? input.utcOffsetMinutes : offsetFromE164(provided)
764
+ }, cfg, deps.bearerHash);
765
+ return { candidates: [candidate], source: "agent_provided" };
766
+ }
690
767
  if (!cfg.googlePlacesApiKey) {
691
- throw new RejectionError("Business lookup is not configured: set GOOGLE_PLACES_API_KEY on the demo server to resolve real businesses, or set SPEKO_DEMO=1 with a SPEKO_DEMO_E164 to dial a single consented target", "Add GOOGLE_PLACES_API_KEY (and optionally TWILIO_LOOKUP_SID/TOKEN) to the repo-root .env, or enable SPEKO_DEMO.");
768
+ throw new RejectionError("Business lookup has no directory configured. Either pass phone_number (the business's official number \u2014 e.g. found via web search) to lookup_business, or set GOOGLE_PLACES_API_KEY on the demo server, or set SPEKO_DEMO=1 with a SPEKO_DEMO_E164.", "Pass phone_number=<E.164> to lookup_business, or add GOOGLE_PLACES_API_KEY to the repo-root .env, or enable SPEKO_DEMO.");
692
769
  }
693
770
  const query = [input.name, input.location].filter((s) => s && String(s).trim()).join(" ");
694
771
  const places = await searchPlaces(query, cfg.googlePlacesApiKey);
695
- const candidates = await Promise.all(places.map(async (p) => {
696
- let lineType = null;
697
- let blocked = dialBlockedReason(p.e164);
698
- if (!blocked) {
699
- lineType = cfg.twilio ? await carrierLineType(p.e164, cfg.twilio) : null;
700
- blocked = lineTypeBlockedReason(lineType);
701
- }
702
- if (blocked) {
703
- return {
704
- name: p.name,
705
- address: p.address,
706
- phone: p.e164,
707
- line_type: lineType,
708
- allowed: false,
709
- blocked_reason: blocked,
710
- dial_token: null,
711
- utc_offset_minutes: p.utcOffsetMinutes
712
- };
713
- }
714
- const dialToken = mintDialToken({
715
- e164: p.e164,
716
- lineType,
717
- businessName: p.name,
718
- utcOffsetMinutes: p.utcOffsetMinutes,
719
- bearerHash: deps.bearerHash,
720
- secret: cfg.dialTokenSecret
721
- });
722
- return {
723
- name: p.name,
724
- address: p.address,
725
- phone: p.e164,
726
- line_type: lineType,
727
- allowed: true,
728
- blocked_reason: null,
729
- dial_token: dialToken,
730
- utc_offset_minutes: p.utcOffsetMinutes
731
- };
732
- }));
772
+ const fallbackOffset = typeof input.utcOffsetMinutes === "number" ? input.utcOffsetMinutes : null;
773
+ const candidates = await Promise.all(places.map((p) => verifyAndMint({ ...p, utcOffsetMinutes: p.utcOffsetMinutes ?? fallbackOffset }, cfg, deps.bearerHash)));
733
774
  return { candidates, source: "google_places" };
734
775
  }
735
776
  var init_lookup = __esm({
@@ -737,6 +778,7 @@ var init_lookup = __esm({
737
778
  "use strict";
738
779
  init_errors();
739
780
  init_dialToken();
781
+ init_timezone();
740
782
  init_demo();
741
783
  init_places();
742
784
  init_twilio();
@@ -900,7 +942,7 @@ function assessConnection(session, transcript) {
900
942
  const ccidRaw = session.phoneCall?.callControlId;
901
943
  const callControlId = typeof ccidRaw === "string" && ccidRaw.trim() ? ccidRaw : null;
902
944
  const carrierBilled = Array.isArray(session.usage) && session.usage.some(isCarrierUsage);
903
- const connected = Boolean(callControlId) || carrierBilled || answered;
945
+ const connected = answered || Boolean(callControlId) || carrierBilled;
904
946
  return { connected, answered, callControlId, carrierBilled };
905
947
  }
906
948
  var CARRIER_PROVIDERS, CARRIER_METRIC_RE;
@@ -935,7 +977,10 @@ function shapeCallSummary(input) {
935
977
  summary.status = NOT_CONNECTED_STATUS;
936
978
  summary.reason = NOT_CONNECTED_REASON;
937
979
  } else if (connected && !assessment.answered) {
980
+ summary.status = "no_answer";
938
981
  summary.reason = NO_ANSWER_REASON;
982
+ } else if (connected && assessment.answered) {
983
+ summary.status = "completed";
939
984
  }
940
985
  return summary;
941
986
  }
@@ -945,7 +990,7 @@ var init_summary = __esm({
945
990
  "use strict";
946
991
  init_constants();
947
992
  init_assess();
948
- 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).";
949
994
  NO_ANSWER_REASON = "The call connected but the other party never spoke (no answer / voicemail / hung up before responding).";
950
995
  }
951
996
  });
@@ -988,7 +1033,8 @@ async function makeCall(input, deps) {
988
1033
  const offset = typeof payload.utc_offset_minutes === "number" ? payload.utc_offset_minutes : null;
989
1034
  const quietReason = quietHoursReason(offset);
990
1035
  if (quietReason) {
991
- 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.`;
992
1038
  throw new RejectionError(quietReason, next);
993
1039
  }
994
1040
  const objectiveReason = objectiveBlockedReason(input.objective);
@@ -1005,9 +1051,10 @@ async function makeCall(input, deps) {
1005
1051
  const body = {
1006
1052
  to: e164,
1007
1053
  ...fromNumber ? { from: fromNumber } : {},
1008
- // optimizeFor=latency is best for a LIVE call: the selector keeps gpt-5 (best time-to-
1009
- // first-token) + a fast streaming STT, avoiding the multi-second dead air the other modes
1010
- // 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.
1011
1058
  intent: { language: DIAL_INTENT_LANGUAGE, optimizeFor: deps.cfg.optimizeFor },
1012
1059
  // A specific `voice` (cfg.voice) is safe ONLY because it's an ElevenLabs voice matching the
1013
1060
  // ElevenLabs TTS pin below — always verify a voice with scripts/verify-tts.mjs first. A voice
@@ -1076,43 +1123,68 @@ async function runPhoneCall(body, maxSeconds, deps, sleep) {
1076
1123
  }
1077
1124
  let elapsed = 0;
1078
1125
  let polls = 0;
1079
- while (!TERMINAL_STATUSES.has(status) && elapsed < maxSeconds) {
1126
+ let ended = false;
1127
+ while (elapsed < maxSeconds) {
1080
1128
  const interval = polls < FAST_POLLS ? FAST_POLL_SECONDS : SLOW_POLL_SECONDS;
1081
1129
  await sleep(interval * 1e3);
1082
1130
  elapsed += interval;
1083
1131
  polls += 1;
1132
+ let events;
1084
1133
  try {
1085
- const d = await deps.client.getCall(callId);
1086
- status = String(d.status ?? "").toLowerCase();
1087
- } catch (e) {
1088
- throw new AppError(e.message, {
1089
- statusCode: 502,
1090
- nextStep: `Do not dial again; the call (call_id '${callId}') may still be in progress. Check it with get_call('${callId}').`
1091
- });
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;
1092
1155
  }
1093
1156
  }
1094
- if (!TERMINAL_STATUSES.has(status)) {
1157
+ if (!ended) {
1095
1158
  return {
1096
1159
  ...baseSummary(callId, to, from),
1097
1160
  status: "timeout",
1098
1161
  duration_seconds: elapsed,
1099
1162
  connected: true,
1100
- 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."
1101
1164
  };
1102
1165
  }
1103
1166
  return finalize(callId, to, from, status, elapsed, deps);
1104
1167
  }
1105
1168
  async function finalize(callId, to, from, status, elapsed, deps) {
1169
+ const sleep = deps.sleep ?? defaultSleep;
1106
1170
  let transcript = null;
1107
1171
  let transcriptError;
1108
1172
  let outcome = null;
1109
- try {
1110
- const detail = await deps.client.getCall(callId);
1111
- transcript = detail.transcript ?? null;
1112
- const reportOutcome = detail.report?.outcome;
1113
- outcome = typeof reportOutcome === "string" && reportOutcome.trim() ? reportOutcome.trim() : extractOutcome(transcript);
1114
- } catch (e) {
1115
- 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);
1116
1188
  }
1117
1189
  let session = null;
1118
1190
  try {
@@ -1156,7 +1228,7 @@ async function callNumber(input, deps) {
1156
1228
  if (!deps.cfg.allowDirectDial) {
1157
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.");
1158
1230
  }
1159
- const e164 = typeof input.phoneNumber === "string" ? input.phoneNumber.trim() : "";
1231
+ const e164 = typeof input.phoneNumber === "string" ? input.phoneNumber.replace(/[^\d+]/g, "") : "";
1160
1232
  const blocked = dialBlockedReason(e164);
1161
1233
  if (blocked) {
1162
1234
  throw new RejectionError(blocked, "Pass a valid E.164 number (e.g. +77011234567) that you have consent to call.");
@@ -1312,8 +1384,9 @@ async function describeCall(callId, client) {
1312
1384
  const transcript = detail.transcript ?? null;
1313
1385
  const to = strField(detail.metadata, "to") ?? strField(detail.metadata, "dialedNumber");
1314
1386
  const from = strField(detail.metadata, "from");
1315
- const reportOutcome = detail.report?.outcome;
1316
- 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);
1317
1390
  let session = null;
1318
1391
  try {
1319
1392
  session = await client.getSession(callId);
@@ -1907,7 +1980,12 @@ var InProcessBackend = class {
1907
1980
  try {
1908
1981
  if (path === "/lookup") {
1909
1982
  return await core.lookupBusiness(
1910
- { name: String(b.name ?? ""), location: b.location ?? null },
1983
+ {
1984
+ name: String(b.name ?? ""),
1985
+ location: b.location ?? null,
1986
+ phoneNumber: b.phone_number ?? null,
1987
+ utcOffsetMinutes: typeof b.utc_offset_minutes === "number" ? b.utc_offset_minutes : null
1988
+ },
1911
1989
  { cfg: ctx.cfg, bearerHash: ctx.bearerHash }
1912
1990
  );
1913
1991
  }
@@ -2028,7 +2106,9 @@ function getServerClient() {
2028
2106
 
2029
2107
  // src/tools/CallNumberTool.ts
2030
2108
  var schema2 = z2.object({
2031
- 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
+ ),
2032
2112
  objective: z2.string().describe("What to say / accomplish, e.g. 'Tell Sam that John says happy birthday and misses him.'"),
2033
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."),
2034
2114
  recipient_name: z2.string().optional().describe("Who you're calling, used in the greeting (e.g. 'Sam')."),
@@ -2064,7 +2144,7 @@ function summarize(s) {
2064
2144
  }
2065
2145
  var CallNumberTool = class extends MCPTool2 {
2066
2146
  name = "call_number";
2067
- 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.";
2068
2148
  schema = schema2;
2069
2149
  annotations = {
2070
2150
  title: "Call a Number",
@@ -2077,6 +2157,8 @@ var CallNumberTool = class extends MCPTool2 {
2077
2157
  const maxWait = clamp2(input.max_duration_seconds ?? MAX_WAIT, MIN_WAIT, MAX_WAIT);
2078
2158
  const client = getServerClient();
2079
2159
  let elapsed = 0;
2160
+ void this.reportProgress(0, maxWait, "Placing the call\u2026").catch(() => {
2161
+ });
2080
2162
  const timer = setInterval(() => {
2081
2163
  elapsed += HEARTBEAT_MS / 1e3;
2082
2164
  void this.reportProgress(elapsed, maxWait, `Call in progress \u2014 ${elapsed}s elapsed`).catch(() => {
@@ -2154,11 +2236,17 @@ import { MCPTool as MCPTool5 } from "mcp-framework";
2154
2236
  import { z as z5 } from "zod";
2155
2237
  var schema5 = z5.object({
2156
2238
  name: z5.string().min(1).describe(`Business name, e.g. "Joe's Pizza".`),
2157
- location: z5.string().optional().describe("Optional city or area to disambiguate, e.g. 'New York'.")
2239
+ location: z5.string().optional().describe("Optional city or area to disambiguate, e.g. 'New York'."),
2240
+ phone_number: z5.string().optional().describe(
2241
+ "The business's official phone number in E.164 (e.g. +14155551234) if you can find it via web search. When provided, this skips the directory lookup and verifies this exact number \u2014 it's still carrier-checked as a business line before any call. Omit it to resolve by name + location instead."
2242
+ ),
2243
+ utc_offset_minutes: z5.number().int().optional().describe(
2244
+ "Destination UTC offset in minutes for quiet-hours (e.g. -300 US Eastern, -480 US Pacific, 0 UK). Pass this alongside phone_number when you know the business's region but its number isn't auto-recognized (otherwise the offset is derived from the number)."
2245
+ )
2158
2246
  });
2159
2247
  var LookupBusinessTool = class extends MCPTool5 {
2160
2248
  name = "lookup_business";
2161
- description = "Resolve a business name (plus optional location) to dialable candidates and mint a signed dial_token for each callable one. This is the only path that can authorize make_call \u2014 raw phone numbers are rejected.";
2249
+ description = "Resolve a business to dialable candidates and mint a signed dial_token for each callable one \u2014 the only path that can authorize make_call (raw phone numbers are rejected). If you can find the business's official number via web search, pass it as phone_number to skip the directory lookup; otherwise pass name (plus optional location). Either way the number is carrier-verified as a business line.";
2162
2250
  schema = schema5;
2163
2251
  annotations = {
2164
2252
  title: "Lookup Business",
@@ -2170,7 +2258,9 @@ var LookupBusinessTool = class extends MCPTool5 {
2170
2258
  async execute(input) {
2171
2259
  const out = await getServerClient().post("/lookup", {
2172
2260
  name: input.name,
2173
- location: input.location
2261
+ location: input.location,
2262
+ phone_number: input.phone_number,
2263
+ utc_offset_minutes: input.utc_offset_minutes
2174
2264
  });
2175
2265
  const candidates = out.candidates ?? [];
2176
2266
  const lines = candidates.map(
@@ -2232,6 +2322,8 @@ var MakeCallTool = class extends MCPTool6 {
2232
2322
  const maxWait = clamp3(input.max_duration_seconds ?? MAX_WAIT2, MIN_WAIT2, MAX_WAIT2);
2233
2323
  const client = getServerClient();
2234
2324
  let elapsed = 0;
2325
+ void this.reportProgress(0, maxWait, "Placing the call\u2026").catch(() => {
2326
+ });
2235
2327
  const timer = setInterval(() => {
2236
2328
  elapsed += HEARTBEAT_MS2 / 1e3;
2237
2329
  void this.reportProgress(elapsed, maxWait, `Call in progress \u2014 ${elapsed}s elapsed`).catch(() => {
@@ -2265,7 +2357,7 @@ if (cmd === "init" || cmd === "setup" || cmd === "login") {
2265
2357
  loadEnv();
2266
2358
  var server = new MCPServer({
2267
2359
  name: "speko-calls",
2268
- version: "0.3.2",
2360
+ version: "0.4.1",
2269
2361
  transport: { type: "stdio" }
2270
2362
  });
2271
2363
  server.addTool(LookupBusinessTool);