@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 +1 -1
- package/dist/index.js +97 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/speko-calls/SKILL.md +70 -49
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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:
|
|
1025
|
-
// first-token
|
|
1026
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
2360
|
+
version: "0.4.1",
|
|
2298
2361
|
transport: { type: "stdio" }
|
|
2299
2362
|
});
|
|
2300
2363
|
server.addTool(LookupBusinessTool);
|