callwright 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +16 -0
- package/.env.example +21 -0
- package/Dockerfile +18 -0
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/config.example.json +18 -0
- package/dispatch-core.js +371 -0
- package/dispatch.js +55 -0
- package/docs/add-language-spec.md +207 -0
- package/docs/profile-learning-spec.md +134 -0
- package/examples/job.appointment-confirmation.json +24 -0
- package/examples/job.general-inquiry.ja.json +16 -0
- package/examples/job.reservation.json +19 -0
- package/fly.toml +35 -0
- package/generic-prompt.ja.md +125 -0
- package/generic-prompt.md +149 -0
- package/get-call.js +70 -0
- package/grounding.example.json +17 -0
- package/init.js +159 -0
- package/lang-core.js +476 -0
- package/lang-phrases.json +29 -0
- package/learn-core.js +257 -0
- package/learn.js +65 -0
- package/notify-core.js +113 -0
- package/package.json +77 -0
- package/paths.js +37 -0
- package/place_call.schema.json +173 -0
- package/providers/README.md +116 -0
- package/retry-core.js +121 -0
- package/scenario-profiles.json +112 -0
- package/server.js +876 -0
- package/setup-agent-from.js +96 -0
- package/setup-core.js +299 -0
- package/update-prompt.js +98 -0
- package/webhook/README.md +104 -0
- package/webhook/api/retell-webhook.js +111 -0
- package/webhook/package.json +13 -0
- package/webhook/vercel.json +9 -0
package/server.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// callwright — MCP server (hosted, single-user).
|
|
3
|
+
//
|
|
4
|
+
// Streamable HTTP MCP server exposing the call-placing tools. Single-user:
|
|
5
|
+
// protected by one bearer token (MCP_AUTH_TOKEN). Your Retell key lives in the
|
|
6
|
+
// host env (RETELL_API_KEY) and never leaves the server.
|
|
7
|
+
//
|
|
8
|
+
// Tools:
|
|
9
|
+
// get_setup_status - what's configured / missing (drives onboarding)
|
|
10
|
+
// configure - set identity/facts/report-to (+ optional provisioning)
|
|
11
|
+
// list_scenarios - known scenario profiles
|
|
12
|
+
// place_call - DRY-RUN by default; pass confirm:true to actually dial
|
|
13
|
+
// get_call_outcome - fetch a call's outcome + transcript by id
|
|
14
|
+
// list_recent_calls - recent calls for this agent/workspace
|
|
15
|
+
// list_retries - recent automatic call retries (no-answer re-dials)
|
|
16
|
+
// configure_notifications - set up optional 'text me when done' SMS
|
|
17
|
+
// learn_from_call - enrich a profile OR stage a new-scenario candidate
|
|
18
|
+
// list_candidates - staged scenario candidates (pending new profiles)
|
|
19
|
+
// create_profile - create a new scenario profile (propose-and-confirm)
|
|
20
|
+
// add_scenario_alias - merge a call_type into an existing profile
|
|
21
|
+
// reject_candidate - drop a noise/one-off scenario candidate
|
|
22
|
+
// list_voices - browse Retell voices (for adding a language)
|
|
23
|
+
// list_languages - languages this server can call in (en base + added)
|
|
24
|
+
// add_language - add a new call language at runtime (translate -> agent)
|
|
25
|
+
// verify_language - quality-gate an added language (review card + live call)
|
|
26
|
+
// update_language - change an added language (prompt/phrases/voice/...)
|
|
27
|
+
// remove_language - unregister an added language (+ optional agent delete)
|
|
28
|
+
//
|
|
29
|
+
// Env:
|
|
30
|
+
// RETELL_API_KEY (required) - Retell secret key
|
|
31
|
+
// MCP_AUTH_TOKEN (required for remote) - bearer token clients must send
|
|
32
|
+
// PORT (default 8787)
|
|
33
|
+
// RETELL_FROM_NUMBER / config.json (from-number); agents.json/config (agent)
|
|
34
|
+
// CALLWRIGHT_PUBLIC_URL (optional) - this server's public URL, used to set the
|
|
35
|
+
// agents' webhook_url so Retell posts call_analyzed back for
|
|
36
|
+
// auto-learn (POST /webhook/retell, signature-verified)
|
|
37
|
+
|
|
38
|
+
const express = require("express");
|
|
39
|
+
const fs = require("fs");
|
|
40
|
+
const path = require("path");
|
|
41
|
+
const { z } = require("zod");
|
|
42
|
+
|
|
43
|
+
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
44
|
+
const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
45
|
+
|
|
46
|
+
const core = require("./dispatch-core");
|
|
47
|
+
const setup = require("./setup-core");
|
|
48
|
+
const lang = require("./lang-core");
|
|
49
|
+
const learn = require("./learn-core");
|
|
50
|
+
const retry = require("./retry-core");
|
|
51
|
+
const notify = require("./notify-core");
|
|
52
|
+
const paths = require("./paths");
|
|
53
|
+
|
|
54
|
+
const RETELL_BASE = "https://api.retellai.com";
|
|
55
|
+
const PORT = parseInt(process.env.PORT || "8787", 10);
|
|
56
|
+
const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN || "";
|
|
57
|
+
const RETELL_KEY = process.env.RETELL_API_KEY || "";
|
|
58
|
+
// Public base URL of THIS server (e.g. https://virtuphil.fly.dev), used to set
|
|
59
|
+
// the agents' webhook_url so Retell posts call_analyzed back here for auto-learn.
|
|
60
|
+
const PUBLIC_URL = (process.env.CALLWRIGHT_PUBLIC_URL || "").replace(/\/+$/, "");
|
|
61
|
+
const WEBHOOK_PATH = "/webhook/retell";
|
|
62
|
+
|
|
63
|
+
// ---- helpers ----
|
|
64
|
+
const text = (s) => ({ content: [{ type: "text", text: typeof s === "string" ? s : JSON.stringify(s, null, 2) }] });
|
|
65
|
+
const errText = (s) => ({ content: [{ type: "text", text: typeof s === "string" ? s : JSON.stringify(s, null, 2) }], isError: true });
|
|
66
|
+
|
|
67
|
+
async function retell(method, path, body) {
|
|
68
|
+
const opts = { method, headers: { Authorization: `Bearer ${RETELL_KEY}` } };
|
|
69
|
+
if (body) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); }
|
|
70
|
+
const resp = await fetch(RETELL_BASE + path, opts);
|
|
71
|
+
const t = await resp.text();
|
|
72
|
+
if (!resp.ok) throw new Error(`Retell ${method} ${path} -> ${resp.status}: ${t}`);
|
|
73
|
+
return t ? JSON.parse(t) : {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function summarizeCall(call) {
|
|
77
|
+
const a = call.call_analysis || {};
|
|
78
|
+
return {
|
|
79
|
+
call_id: call.call_id,
|
|
80
|
+
dashboard: `https://dashboard.retellai.com/call-history?history=${call.call_id}`,
|
|
81
|
+
status: call.call_status,
|
|
82
|
+
disconnect: call.disconnection_reason,
|
|
83
|
+
duration_s: call.duration_ms ? Math.round(call.duration_ms / 1000) : null,
|
|
84
|
+
successful: a.call_successful ?? null,
|
|
85
|
+
summary: a.call_summary || null,
|
|
86
|
+
analysis: a.custom_analysis_data || {},
|
|
87
|
+
transcript: call.transcript || null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- profile + candidate IO (volume-resident mutable state) ----
|
|
92
|
+
function loadProfiles() { try { return JSON.parse(fs.readFileSync(core.PROFILES_PATH, "utf8")); } catch { return {}; } }
|
|
93
|
+
function saveProfiles(p) { fs.writeFileSync(core.PROFILES_PATH, JSON.stringify(p, null, 2) + "\n"); }
|
|
94
|
+
function loadCandidates() { try { return JSON.parse(fs.readFileSync(paths.CANDIDATES_PATH, "utf8")); } catch { return {}; } }
|
|
95
|
+
function saveCandidates(c) { fs.writeFileSync(paths.CANDIDATES_PATH, JSON.stringify(c, null, 2) + "\n"); }
|
|
96
|
+
function loadRetries() { try { return JSON.parse(fs.readFileSync(paths.RETRIES_PATH, "utf8")); } catch { return []; } }
|
|
97
|
+
function saveRetries(r) { fs.writeFileSync(paths.RETRIES_PATH, JSON.stringify(r, null, 2) + "\n"); }
|
|
98
|
+
function appendRetry(entry) {
|
|
99
|
+
const log = loadRetries();
|
|
100
|
+
log.unshift(entry);
|
|
101
|
+
saveRetries(log.slice(0, 200)); // keep the ledger bounded
|
|
102
|
+
}
|
|
103
|
+
function updateRetry(predicate, patch) {
|
|
104
|
+
const log = loadRetries();
|
|
105
|
+
const i = log.findIndex(predicate);
|
|
106
|
+
if (i >= 0) { log[i] = { ...log[i], ...patch }; saveRetries(log); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- build a fresh McpServer with all tools registered ----
|
|
110
|
+
function buildServer() {
|
|
111
|
+
const server = new McpServer(
|
|
112
|
+
{ name: "callwright", version: "1.0.0" },
|
|
113
|
+
{ capabilities: { tools: {} } }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
server.registerTool(
|
|
117
|
+
"get_setup_status",
|
|
118
|
+
{
|
|
119
|
+
title: "Get setup status",
|
|
120
|
+
description: "Report what callwright has configured (from-number, agent, your name/callback, fact keys) and what's missing. Call this first if you're unsure whether setup is complete; if basics are missing, ask the user for them and call configure.",
|
|
121
|
+
inputSchema: {},
|
|
122
|
+
},
|
|
123
|
+
async () => text(setup.setupStatus())
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.registerTool(
|
|
127
|
+
"configure",
|
|
128
|
+
{
|
|
129
|
+
title: "Configure principal + (optional) provision infra",
|
|
130
|
+
description: "Save the user's standing profile so calls need less per-call input. Set name and callback_number (the always-safe basics). Optionally save standing facts (durable PII like service_address, member_id — a STORE the LLM later draws minimal subsets from). This is also the SAVE TARGET for the lazy-save nudge: when place_call returns a save_suggestion (durable facts not yet stored), offer to save them and, if the user agrees, call configure with those facts so they're not re-asked next time. Set run_provisioning=true to verify the Retell key, pick the phone number, and create the generic agent if missing. Gather values from the user in chat before calling.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
name: z.string().optional().describe("Name used when a call becomes a booking."),
|
|
133
|
+
callback_number: z.string().optional().describe("E.164. Shared only if a business asks."),
|
|
134
|
+
facts: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
135
|
+
.describe("Durable standing facts (snake_case keys). Merged into the store."),
|
|
136
|
+
run_provisioning: z.boolean().optional().describe("If true, verify key + select number + ensure generic agent."),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
async ({ name, callback_number, facts, run_provisioning }) => {
|
|
140
|
+
const config = setup.loadConfig();
|
|
141
|
+
if (run_provisioning) {
|
|
142
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server; cannot provision.");
|
|
143
|
+
const agents = await setup.verifyKey(RETELL_KEY);
|
|
144
|
+
const res = await setup.resolveFromNumber(RETELL_KEY, config.retell?.from_number);
|
|
145
|
+
if (!res.from_number && res.needsChoice) {
|
|
146
|
+
return errText({
|
|
147
|
+
note: "Could not auto-select a phone number.",
|
|
148
|
+
reason: res.reason,
|
|
149
|
+
numbers: res.numbers.map((n) => n.phone_number),
|
|
150
|
+
instruction: "Tell the user to buy a number (if none) or set RETELL_FROM_NUMBER / config.retell.from_number to one of the listed numbers, then retry.",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const agentRes = await setup.ensureGenericAgent(RETELL_KEY, { agents, defaultId: config.agents?.default });
|
|
154
|
+
const byLang = setup.detectLanguageAgents(agents);
|
|
155
|
+
setup.applyInfra(config, { from_number: res.from_number, agent_id: agentRes.agent_id, by_lang: byLang });
|
|
156
|
+
}
|
|
157
|
+
setup.setPrincipal(config, { name, callback_number, facts });
|
|
158
|
+
setup.saveConfig(config);
|
|
159
|
+
return text({ saved: true, status: setup.setupStatus(config) });
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"list_scenarios",
|
|
165
|
+
{
|
|
166
|
+
title: "List scenario profiles",
|
|
167
|
+
description: "List known scenario profiles (recommended details, default flexibility, aliases). Use to learn what grounding a given call type benefits from before constructing place_call.",
|
|
168
|
+
inputSchema: {},
|
|
169
|
+
},
|
|
170
|
+
async () => {
|
|
171
|
+
const profiles = loadProfiles();
|
|
172
|
+
const candidates = loadCandidates();
|
|
173
|
+
const pending = Object.entries(candidates)
|
|
174
|
+
.map(([name, c]) => ({ scenario: name, seen: c.count, ready: c.count >= 2 && !c.proposed }))
|
|
175
|
+
.filter((c) => c.seen > 0);
|
|
176
|
+
return text({ profiles, pending_candidates: pending.length ? pending : undefined });
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
server.registerTool(
|
|
181
|
+
"place_call",
|
|
182
|
+
{
|
|
183
|
+
title: "Place an outbound phone call",
|
|
184
|
+
description:
|
|
185
|
+
"Place a call on the user's behalf. DRY-RUN by default: returns a read-back (including exactly which personal data types will be available to the agent) and does NOT dial. Review it with the user, then call again with confirm:true to actually place the call. DATA MINIMIZATION: in principal.facts include ONLY what THIS call needs; put PII in principal.facts (shared only if asked), not in scenario_details (spoken proactively). For a truly nameless general inquiry set principal.anonymous:true. LANGUAGE: the agent SPEAKS the text fields you provide verbatim. When the call is not in English (set lang accordingly), you MUST write every spoken field in that language — request.summary, request.opening_ask, scenario_details values, preferences, must_confirm, and any constraints. Do NOT write them in English for a non-English call. Fire-and-forget: returns a call_id; read the outcome later with get_call_outcome.",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
call_type: z.string().describe("Open-ended scenario id, e.g. 'haircut', 'restaurant_reservation', 'appointment_confirmation', 'general_inquiry'."),
|
|
188
|
+
target: z.object({
|
|
189
|
+
business_name: z.string(),
|
|
190
|
+
phone_number: z.string().describe("E.164, e.g. +15555550199."),
|
|
191
|
+
timezone: z.string().optional(),
|
|
192
|
+
}),
|
|
193
|
+
principal: z.object({
|
|
194
|
+
name: z.string().optional(),
|
|
195
|
+
callback_number: z.string().optional(),
|
|
196
|
+
anonymous: z.boolean().optional().describe("True = nameless call (general inquiry); suppresses config backfill."),
|
|
197
|
+
facts: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
198
|
+
.describe("MINIMAL per-call subset of PII the agent shares only if asked."),
|
|
199
|
+
}).optional(),
|
|
200
|
+
request: z.object({
|
|
201
|
+
summary: z.string().describe("Short internal objective for logging/profiles (e.g. 'Availability inquiry for 2, Fri 18:00'). NOT spoken verbatim — keep it brief; the spoken line is opening_ask."),
|
|
202
|
+
opening_ask: z.string().describe("The exact sentence the agent SPEAKS to state why it's calling. Write a complete, natural, polite sentence in the call's language (Japanese if lang='ja'). It is placed between a warm greeting and the AI disclosure, so do NOT include a greeting or the AI disclosure here — just the purpose/ask. Example (ja): '金曜日18時に2名で空席があるか伺えますでしょうか。' Example (en): 'I'm calling to ask whether you have a table for two this Friday at 6 PM.'"),
|
|
203
|
+
party_size: z.number().int().optional(),
|
|
204
|
+
max_party_size: z.number().int().optional(),
|
|
205
|
+
preferred: z.object({ date: z.string().describe("YYYY-MM-DD"), time: z.string().describe("HH:MM 24h") }),
|
|
206
|
+
acceptable_windows: z.array(z.object({
|
|
207
|
+
date: z.string(), earliest: z.string(), latest: z.string(),
|
|
208
|
+
})).optional(),
|
|
209
|
+
}),
|
|
210
|
+
scenario_details: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
|
|
211
|
+
.describe("NON-PII call specifics, spoken proactively (service_type, occasion). No PII here."),
|
|
212
|
+
preferences: z.array(z.string()).optional(),
|
|
213
|
+
constraints: z.array(z.string()).optional(),
|
|
214
|
+
must_confirm: z.array(z.string()).optional(),
|
|
215
|
+
overrides: z.object({
|
|
216
|
+
time_flexibility_minutes: z.number().int().optional(),
|
|
217
|
+
on_voicemail: z.enum(["leave_message", "hang_up"]).optional().describe("What to do if voicemail answers. Default leave_message. Voicemail is NOT auto-retried."),
|
|
218
|
+
retry: z.object({
|
|
219
|
+
max_retries: z.number().int().min(0).max(5).optional().describe("How many times to RE-DIAL if the call isn't answered. DEFAULT 1. Only set above 1 if the USER specifically asks for more attempts — repeated calls are spammy. Set 0 to disable retry."),
|
|
220
|
+
on: z.array(z.enum(["no_answer", "busy", "failed"])).optional().describe("Which non-answer outcomes trigger a retry. Default ['no_answer','busy','failed']."),
|
|
221
|
+
delay_seconds: z.number().int().min(0).max(600).optional().describe("Best-effort delay before the re-dial (default 60s)."),
|
|
222
|
+
}).optional().describe("Retry-on-no-answer policy. Defaults to exactly 1 retry; do not raise max_retries without an explicit user request."),
|
|
223
|
+
}).optional(),
|
|
224
|
+
notify: z.object({
|
|
225
|
+
sms: z.boolean().optional().describe("Set true ONLY if the user asks to be notified/texted when this call is done. Default false — results are pulled on demand via get_call_outcome."),
|
|
226
|
+
to: z.string().optional().describe("The USER'S OWN number to text (E.164). If omitted, the saved notify number is used; if none is saved, ask the user for their number first."),
|
|
227
|
+
}).optional().describe("Optional per-call 'text me when it's done'. Off by default (pull-based)."),
|
|
228
|
+
lang: z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "must be a BCP-47 language code, e.g. 'en', 'ja', 'fr', 'zh-CN'").optional().describe("Language to conduct the call in, as a BCP-47 code (e.g. 'en', 'ja', 'fr'). The call routes to the matching language agent (registered in config.agents.by_lang) and composes opening/voicemail text in that language. Currently fully supported: 'en' and 'ja'; other codes only work once a language agent has been added for them (see add_language). Default 'en'. If you omit it for a +81 number, the server assumes 'ja'."),
|
|
229
|
+
test_to: z.string().optional().describe("Route the actual dial to this number (testing) while keeping the real business number in the read-back."),
|
|
230
|
+
confirm: z.boolean().optional().describe("Must be true to actually dial. Omit/false = dry-run read-back only."),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
async (args) => {
|
|
234
|
+
const { lang: langArg, test_to = null, confirm = false, ...job } = args;
|
|
235
|
+
// Language: use the explicit arg if given; otherwise infer a safe default
|
|
236
|
+
// from the target country code (+81 -> Japanese) so a Japanese call still
|
|
237
|
+
// routes correctly even if the model forgets to set lang. Defaults to en.
|
|
238
|
+
let lang = langArg;
|
|
239
|
+
if (!lang) {
|
|
240
|
+
const num = String(job.target?.phone_number || "");
|
|
241
|
+
lang = num.startsWith("+81") ? "ja" : "en";
|
|
242
|
+
}
|
|
243
|
+
// A language is supported if it's English (the default agent) or a language
|
|
244
|
+
// agent is registered for it. Guard against silently dialing a non-English
|
|
245
|
+
// call with the English agent (the enum is now open to any BCP-47 code).
|
|
246
|
+
const cfg = setup.loadConfig();
|
|
247
|
+
const langSupported =
|
|
248
|
+
lang === "en" || Boolean(cfg.agents?.by_lang?.[lang.toLowerCase().split("-")[0]]);
|
|
249
|
+
const langWarning = langSupported ? undefined : {
|
|
250
|
+
unsupported_language: lang,
|
|
251
|
+
note: `No language agent is registered for "${lang}". The call would run with the English agent. Add the language first (see add_language) or choose a supported language (en, ja).`,
|
|
252
|
+
};
|
|
253
|
+
// Resolve the SMS-notify target up-front so it round-trips with the call and
|
|
254
|
+
// shows in the read-back. Falls back to the saved notify number / callback.
|
|
255
|
+
let notifyWarning;
|
|
256
|
+
if (job.notify?.sms) {
|
|
257
|
+
const to = job.notify.to || cfg.notify?.sms?.to || cfg.principal?.callback_number || "";
|
|
258
|
+
job.notify = { sms: true, to };
|
|
259
|
+
if (!to) notifyWarning = "notify.sms was requested but no number is known — ask the user for their mobile number (or pass notify.to).";
|
|
260
|
+
}
|
|
261
|
+
// Lazy-save nudge: durable facts on this call not yet in the standing
|
|
262
|
+
// profile, so the LLM can offer to save them once (configure) — never
|
|
263
|
+
// re-asking next time. Pure check against config; no PII values echoed.
|
|
264
|
+
const unsaved = setup.unsavedDurableFacts(job, cfg);
|
|
265
|
+
const saveSuggestion = unsaved.length ? {
|
|
266
|
+
unsaved_facts: unsaved,
|
|
267
|
+
instruction: "After this call, offer to save the DURABLE items (e.g. name, callback_number, service_address, member_id) to the user's profile via configure, so they aren't re-entered next time. Skip anything that's specific to this one call. Only save with the user's ok.",
|
|
268
|
+
} : undefined;
|
|
269
|
+
// Onboarding guard before any dialing.
|
|
270
|
+
const guard = setup.guardPlaceCall();
|
|
271
|
+
if (guard && confirm) return errText(guard);
|
|
272
|
+
// Refuse to actually dial an unsupported language (would be the wrong agent).
|
|
273
|
+
if (langWarning && confirm) return errText(langWarning);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const result = await core.placeCall(job, {
|
|
277
|
+
lang, go: confirm, testTo: test_to, key: RETELL_KEY,
|
|
278
|
+
});
|
|
279
|
+
if (!result.ok) return errText({ validation_errors: result.errors });
|
|
280
|
+
if (result.dryRun) {
|
|
281
|
+
return text({
|
|
282
|
+
dry_run: true,
|
|
283
|
+
read_back: result.readback.join("\n"),
|
|
284
|
+
next: "Review with the user. To place this call, call place_call again with the same args plus confirm:true.",
|
|
285
|
+
setup_warning: guard || undefined,
|
|
286
|
+
language_warning: langWarning,
|
|
287
|
+
notify_warning: notifyWarning,
|
|
288
|
+
save_suggestion: saveSuggestion,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return text({
|
|
292
|
+
placed: true,
|
|
293
|
+
call_id: result.call.call_id,
|
|
294
|
+
dashboard: `https://dashboard.retellai.com/call-history?history=${result.call.call_id}`,
|
|
295
|
+
read_back: result.readback.join("\n"),
|
|
296
|
+
next: "Fire-and-forget. Use get_call_outcome with this call_id in ~1 minute.",
|
|
297
|
+
save_suggestion: saveSuggestion,
|
|
298
|
+
});
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return errText(String(e.message || e));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
server.registerTool(
|
|
306
|
+
"get_call_outcome",
|
|
307
|
+
{
|
|
308
|
+
title: "Get call outcome",
|
|
309
|
+
description: "Fetch a call's outcome, post-call analysis, and transcript by call_id. Use after place_call (give it ~30-60s to complete + analyze).",
|
|
310
|
+
inputSchema: { call_id: z.string() },
|
|
311
|
+
},
|
|
312
|
+
async ({ call_id }) => {
|
|
313
|
+
try {
|
|
314
|
+
const call = await retell("GET", `/v2/get-call/${call_id}`);
|
|
315
|
+
return text(summarizeCall(call));
|
|
316
|
+
} catch (e) { return errText(String(e.message || e)); }
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
server.registerTool(
|
|
321
|
+
"list_recent_calls",
|
|
322
|
+
{
|
|
323
|
+
title: "List recent calls",
|
|
324
|
+
description: "List recent calls in this Retell workspace (most recent first).",
|
|
325
|
+
inputSchema: { limit: z.number().int().min(1).max(50).optional() },
|
|
326
|
+
},
|
|
327
|
+
async ({ limit = 10 }) => {
|
|
328
|
+
try {
|
|
329
|
+
// v3 list-calls: unified pagination -> read `items` (not a top-level array).
|
|
330
|
+
const resp = await retell("POST", "/v3/list-calls", { limit, sort_order: "descending" });
|
|
331
|
+
const arr = Array.isArray(resp) ? resp : (resp.items || resp.calls || []);
|
|
332
|
+
return text(arr.map((c) => ({
|
|
333
|
+
call_id: c.call_id, status: c.call_status, to: c.to_number,
|
|
334
|
+
when: c.start_timestamp, status_detail: c.call_analysis?.custom_analysis_data?.status,
|
|
335
|
+
})));
|
|
336
|
+
} catch (e) { return errText(String(e.message || e)); }
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
server.registerTool(
|
|
341
|
+
"list_retries",
|
|
342
|
+
{
|
|
343
|
+
title: "List call retries",
|
|
344
|
+
description: "Show recent automatic call retries (no-answer/busy/failed re-dials), most recent first. Retries default to at most 1 per call and never auto-escalate. Each entry shows the original call, the attempt number/cap, the new call_id (if placed), and status (scheduled/placed/failed).",
|
|
345
|
+
inputSchema: { limit: z.number().int().min(1).max(100).optional() },
|
|
346
|
+
},
|
|
347
|
+
async ({ limit = 20 }) => {
|
|
348
|
+
const log = loadRetries().slice(0, limit);
|
|
349
|
+
return text({ retries: log, note: log.length ? undefined : "No retries yet." });
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
server.registerTool(
|
|
354
|
+
"configure_notifications",
|
|
355
|
+
{
|
|
356
|
+
title: "Configure SMS notifications",
|
|
357
|
+
description: "Set up the optional 'text me when the call is done' feature. Saves the user's mobile number as the default notify target, provisions the one-time SMS summary agent if needed, and (with test:true) sends a test SMS now so the user can confirm delivery. Per-call SMS is still opt-in via place_call notify.sms — this just stores the number + infra. NOTE: the Retell from-number must be SMS-capable (KYC-gated).",
|
|
358
|
+
inputSchema: {
|
|
359
|
+
sms_to: z.string().optional().describe("The user's mobile number (E.164) to text by default."),
|
|
360
|
+
from_number: z.string().optional().describe("Override the SMS from-number (defaults to the call from-number). Must be SMS-capable."),
|
|
361
|
+
provision: z.boolean().optional().describe("If true, create the SMS summary agent now (otherwise it's created lazily on first send)."),
|
|
362
|
+
test: z.boolean().optional().describe("If true, send a test SMS to sms_to right now to verify delivery."),
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
async ({ sms_to, from_number, provision, test: doTest }) => {
|
|
366
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server.");
|
|
367
|
+
const config = setup.loadConfig();
|
|
368
|
+
config.notify = config.notify || {};
|
|
369
|
+
config.notify.sms = config.notify.sms || {};
|
|
370
|
+
if (sms_to) config.notify.sms.to = sms_to;
|
|
371
|
+
if (from_number) config.notify.sms.from_number = from_number;
|
|
372
|
+
try {
|
|
373
|
+
if (provision || doTest) {
|
|
374
|
+
const api = (m, p, b) => setup.apiCall(RETELL_KEY, m, p, b);
|
|
375
|
+
await notify.ensureSmsAgent(api, config.notify.sms);
|
|
376
|
+
}
|
|
377
|
+
setup.saveConfig(config);
|
|
378
|
+
let test_result;
|
|
379
|
+
if (doTest) {
|
|
380
|
+
const to = sms_to || config.notify.sms.to;
|
|
381
|
+
if (!to) return errText("Provide sms_to to send a test.");
|
|
382
|
+
const fakeCall = {
|
|
383
|
+
call_id: "test_notification",
|
|
384
|
+
to_number: "+10000000000",
|
|
385
|
+
retell_llm_dynamic_variables: { business_name: "Callwright", objective: "notification test" },
|
|
386
|
+
call_analysis: { call_successful: true, custom_analysis_data: { status: "completed" } },
|
|
387
|
+
};
|
|
388
|
+
const r = await notify.notifyCall(fakeCall, { config, key: RETELL_KEY, to, force: true });
|
|
389
|
+
if (r.configChanged) setup.saveConfig(config);
|
|
390
|
+
test_result = r.sent ? { sent: true, to: r.to, preview: r.summary } : { sent: false, reason: r.reason, error: r.error, hint: r.hint };
|
|
391
|
+
}
|
|
392
|
+
return text({
|
|
393
|
+
saved: true,
|
|
394
|
+
notify_sms: { to: config.notify.sms.to || null, from_number: config.notify.sms.from_number || config.retell?.from_number || null, agent_provisioned: !!config.notify.sms.chat_agent_id },
|
|
395
|
+
test_result,
|
|
396
|
+
note: "Per-call SMS is opt-in: set notify.sms:true on place_call (e.g. 'text me when it's done').",
|
|
397
|
+
});
|
|
398
|
+
} catch (e) { return errText(String(e.message || e)); }
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
server.registerTool(
|
|
403
|
+
"learn_from_call",
|
|
404
|
+
{
|
|
405
|
+
title: "Learn from a call",
|
|
406
|
+
description: "Improve the system from a completed call. If the call_type matches a profile, it's ENRICHED (deferred questions become recommended_details, value-baked ones rejected). If it has NO profile, the (generalized) scenario is STAGED as a candidate; once seen >=2 times it becomes a PROPOSAL you can review and create via create_profile. Creation is never silent — it's proposed for you to approve (or merge into an existing profile with add_scenario_alias).",
|
|
407
|
+
inputSchema: {
|
|
408
|
+
call_id: z.string(),
|
|
409
|
+
call_type: z.string().describe("The scenario id for this call (e.g. 'haircut', 'hotel_transport_inquiry')."),
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
async ({ call_id, call_type }) => {
|
|
413
|
+
try {
|
|
414
|
+
const call = await retell("GET", `/v2/get-call/${call_id}`);
|
|
415
|
+
const profiles = loadProfiles();
|
|
416
|
+
const candidates = loadCandidates();
|
|
417
|
+
// Explicit/manual learning is trusted: promote a field on first sight.
|
|
418
|
+
const r = learn.applyLearning(call, { profiles, candidates, callType: call_type, minCount: 1, N: 2 });
|
|
419
|
+
if (r.mode === "skip") {
|
|
420
|
+
return text({ learned: false, reason: r.reason, note: r.reason === "no_gaps" ? "No unanswered-question gaps detected. Nothing to learn." : "No call_type to learn against." });
|
|
421
|
+
}
|
|
422
|
+
if (r.mode === "enriched") {
|
|
423
|
+
saveProfiles(profiles);
|
|
424
|
+
return text({
|
|
425
|
+
mode: "enriched", profile: r.profile, gaps: r.gaps, added_fields: r.added,
|
|
426
|
+
note: r.added.length ? `Future ${r.profile} calls will collect: ${r.added.join(", ")}.` : "No new fields (already covered or value-baked).",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
// staged
|
|
430
|
+
saveCandidates(candidates);
|
|
431
|
+
const out = {
|
|
432
|
+
mode: "staged", scenario: r.scenario, generalized_from: r.generalized_from,
|
|
433
|
+
gaps: r.gaps, seen: r.seen, ready_to_propose: r.ready_to_propose,
|
|
434
|
+
};
|
|
435
|
+
if (r.ready_to_propose) {
|
|
436
|
+
out.proposal = r.proposal;
|
|
437
|
+
out.next = "Review the proposal. If it's a genuinely new scenario, call create_profile to add it. If it's really the same as an existing profile, call add_scenario_alias instead (merge, don't splinter).";
|
|
438
|
+
} else {
|
|
439
|
+
out.next = `Seen ${r.seen}x; will propose once seen >=2 times. (Anti-noise guard.)`;
|
|
440
|
+
}
|
|
441
|
+
return text(out);
|
|
442
|
+
} catch (e) { return errText(String(e.message || e)); }
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
server.registerTool(
|
|
447
|
+
"list_candidates",
|
|
448
|
+
{
|
|
449
|
+
title: "List scenario candidates",
|
|
450
|
+
description: "Show staged scenario candidates (unmatched call types seen but not yet a profile) with their occurrence counts, variant names, and the questions agents had to defer. Candidates seen >=2 times are 'ready' to propose as a new profile (create_profile) or merge into an existing one (add_scenario_alias).",
|
|
451
|
+
inputSchema: {},
|
|
452
|
+
},
|
|
453
|
+
async () => {
|
|
454
|
+
const candidates = loadCandidates();
|
|
455
|
+
const view = Object.entries(candidates).map(([name, c]) => ({
|
|
456
|
+
scenario: name,
|
|
457
|
+
seen: c.count,
|
|
458
|
+
ready: c.count >= 2 && !c.proposed,
|
|
459
|
+
proposed: !!c.proposed,
|
|
460
|
+
variants: c.variants,
|
|
461
|
+
deferred_fields: Object.fromEntries(Object.entries(c.questions || {}).map(([k, q]) => [k, { count: q.count, mapped: q.mapped }])),
|
|
462
|
+
examples: c.examples,
|
|
463
|
+
}));
|
|
464
|
+
return text({ candidates: view, note: view.length ? undefined : "No candidates staged yet." });
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
server.registerTool(
|
|
469
|
+
"create_profile",
|
|
470
|
+
{
|
|
471
|
+
title: "Create a scenario profile",
|
|
472
|
+
description: "Create a NEW scenario profile (propose-and-confirm — call this only after reviewing a learn_from_call proposal with the user). GUARDS: the name must be a GENERAL scenario (not instance-shaped like 'dinner_for_2'); recommended_details must be GENERALIZED field keys ('destination', not 'destination_haneda') — values belong in the per-call job, never the profile. If the scenario is really the same as an existing profile, use add_scenario_alias instead. Clears the matching candidate on success.",
|
|
473
|
+
inputSchema: {
|
|
474
|
+
name: z.string().describe("snake_case general scenario id, e.g. 'hotel_transport_inquiry'."),
|
|
475
|
+
recommended_details: z.record(z.string(), z.string()).describe("Map of generalized field key -> human description of what to collect (schema, NOT values)."),
|
|
476
|
+
aliases: z.array(z.string()).optional().describe("Alternate names that should match this scenario (e.g. 'hire_car', 'airport_transfer')."),
|
|
477
|
+
default_flex_minutes: z.number().int().optional().describe("Default +/- time flexibility for this scenario."),
|
|
478
|
+
must_confirm: z.array(z.string()).optional().describe("Fields to read back at the end of a booking."),
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
async ({ name, recommended_details, aliases = [], default_flex_minutes, must_confirm }) => {
|
|
482
|
+
const profiles = loadProfiles();
|
|
483
|
+
const v = learn.validateNewProfile(name, profiles, recommended_details || {});
|
|
484
|
+
if (v.collision) return errText({ error: `"${name}" already maps to profile "${v.collision}". Use add_scenario_alias to merge instead of creating a duplicate.` });
|
|
485
|
+
if (!v.ok) return errText({ error: "Profile rejected by anti-splintering guards.", details: v.errors });
|
|
486
|
+
const n = name.toLowerCase().trim();
|
|
487
|
+
const profile = { recommended_details: recommended_details || {} };
|
|
488
|
+
if (aliases.length) profile.aliases = aliases.map((a) => a.toLowerCase());
|
|
489
|
+
if (typeof default_flex_minutes === "number") profile.default_flex_minutes = default_flex_minutes;
|
|
490
|
+
if (must_confirm && must_confirm.length) profile.must_confirm = must_confirm;
|
|
491
|
+
profiles[n] = profile;
|
|
492
|
+
saveProfiles(profiles);
|
|
493
|
+
// Clear the candidate (by generalized name) now that it's a real profile.
|
|
494
|
+
const candidates = loadCandidates();
|
|
495
|
+
const { general } = learn.generalizeCallType(n);
|
|
496
|
+
let cleared = false;
|
|
497
|
+
for (const key of [n, general]) { if (candidates[key]) { delete candidates[key]; cleared = true; } }
|
|
498
|
+
if (cleared) saveCandidates(candidates);
|
|
499
|
+
return text({ created: true, profile: n, recommended_details: profile.recommended_details, aliases: profile.aliases || [], candidate_cleared: cleared });
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
server.registerTool(
|
|
504
|
+
"add_scenario_alias",
|
|
505
|
+
{
|
|
506
|
+
title: "Add an alias to a scenario profile",
|
|
507
|
+
description: "Merge a call_type into an EXISTING profile by adding it as an alias (the anti-splintering 'merge, don't create' path). Use when a candidate / new call_type is really the same scenario as a profile you already have. Clears the matching candidate.",
|
|
508
|
+
inputSchema: {
|
|
509
|
+
profile: z.string().describe("The existing profile to merge into (e.g. 'hotel_transport_inquiry')."),
|
|
510
|
+
alias: z.string().describe("The call_type / name to add as an alias (e.g. 'hire_car')."),
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
async ({ profile, alias }) => {
|
|
514
|
+
const profiles = loadProfiles();
|
|
515
|
+
const key = learn.matchProfile(profiles, profile) || (profiles[String(profile).toLowerCase()] ? String(profile).toLowerCase() : null);
|
|
516
|
+
if (!key) return errText({ error: `No profile "${profile}" found. Use list_scenarios to see existing profiles, or create_profile to make a new one.` });
|
|
517
|
+
const a = String(alias).toLowerCase().trim();
|
|
518
|
+
const prof = profiles[key];
|
|
519
|
+
prof.aliases = prof.aliases || [];
|
|
520
|
+
if (!prof.aliases.includes(a) && a !== key) prof.aliases.push(a);
|
|
521
|
+
saveProfiles(profiles);
|
|
522
|
+
const candidates = loadCandidates();
|
|
523
|
+
const { general } = learn.generalizeCallType(a);
|
|
524
|
+
let cleared = false;
|
|
525
|
+
for (const k of [a, general]) { if (candidates[k]) { delete candidates[k]; cleared = true; } }
|
|
526
|
+
if (cleared) saveCandidates(candidates);
|
|
527
|
+
return text({ merged: true, profile: key, aliases: prof.aliases, candidate_cleared: cleared });
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
server.registerTool(
|
|
532
|
+
"reject_candidate",
|
|
533
|
+
{
|
|
534
|
+
title: "Reject a scenario candidate",
|
|
535
|
+
description: "Discard a staged scenario candidate that is noise or a one-off (not a real recurring scenario). Removes it from the candidates store.",
|
|
536
|
+
inputSchema: { scenario: z.string().describe("The candidate scenario name to drop (from list_candidates).") },
|
|
537
|
+
},
|
|
538
|
+
async ({ scenario }) => {
|
|
539
|
+
const candidates = loadCandidates();
|
|
540
|
+
const key = candidates[scenario] ? scenario : learn.generalizeCallType(scenario).general;
|
|
541
|
+
if (!candidates[key]) return errText({ error: `No candidate "${scenario}".` });
|
|
542
|
+
delete candidates[key];
|
|
543
|
+
saveCandidates(candidates);
|
|
544
|
+
return text({ rejected: true, scenario: key });
|
|
545
|
+
}
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
server.registerTool(
|
|
549
|
+
"list_voices",
|
|
550
|
+
{
|
|
551
|
+
title: "List Retell voices",
|
|
552
|
+
description: "List available Retell voices (for picking a voice when adding a language). Optionally filter by accent (e.g. 'French', 'Japanese') or a free-text query (name/provider/accent). NOTE: Retell voices have an accent, not a hard language field, and most are multilingual — accent is a hint, not a guarantee. Validate the final choice with a verification call.",
|
|
553
|
+
inputSchema: {
|
|
554
|
+
accent: z.string().optional().describe("Filter by accent substring, e.g. 'French', 'Spanish', 'Japanese'."),
|
|
555
|
+
query: z.string().optional().describe("Free-text filter over voice name / provider / accent."),
|
|
556
|
+
limit: z.number().int().positive().optional().describe("Max voices to return (default 40)."),
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
async ({ accent, query, limit }) => {
|
|
560
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server.");
|
|
561
|
+
const r = await lang.listVoices(RETELL_KEY, { accent, query, limit });
|
|
562
|
+
return r.error ? errText(r) : text(r);
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
server.registerTool(
|
|
567
|
+
"list_languages",
|
|
568
|
+
{
|
|
569
|
+
title: "List call languages",
|
|
570
|
+
description: "List the call languages this server can conduct calls in: the built-in English base plus any added languages (with their agent, voice, Retell language code, and whether prompt/phrase assets are on the volume). Use to see what's available before place_call or add_language.",
|
|
571
|
+
inputSchema: {},
|
|
572
|
+
},
|
|
573
|
+
async () => text(lang.listLanguages(setup.loadConfig()))
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
server.registerTool(
|
|
577
|
+
"add_language",
|
|
578
|
+
{
|
|
579
|
+
title: "Add a call language",
|
|
580
|
+
description: "Add a NEW call language at runtime (no redeploy). Two-phase: call with just lang + display_name to get back the base prompt + English phrase block to TRANSLATE; then call again with prompt_text + phrases (translated, preserving every {{variable}} and {placeholder}) to create the language agent. Pick a native-accent voice_id via list_voices (optional — defaults to the English house voice, which you should override for best quality). English cannot be added (it's the built-in base). After it succeeds, run a verification call before production.",
|
|
581
|
+
inputSchema: {
|
|
582
|
+
lang: z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "BCP-47 code, e.g. 'fr', 'es', 'pt-BR'").describe("BCP-47 language code to add (e.g. 'fr', 'es', 'de', 'pt-BR')."),
|
|
583
|
+
display_name: z.string().describe("Human name of the language, e.g. 'French'."),
|
|
584
|
+
prompt_text: z.string().optional().describe("The FULL agent prompt translated into the new language. Omit on the first call to receive the base prompt to translate. Must keep all {{variables}}."),
|
|
585
|
+
phrases: z.record(z.string(), z.any()).optional().describe("The translated phrase block (same keys as the returned base_phrases). Omit on the first call. Must keep all {placeholder} tokens."),
|
|
586
|
+
voice_id: z.string().optional().describe("A Retell voice_id that suits the language (from list_voices). Defaults to the English house voice if omitted — override for native quality."),
|
|
587
|
+
language: z.string().optional().describe("Retell agent language code override (e.g. 'fr-CA'). Defaults to a sensible value for the language."),
|
|
588
|
+
stt_mode: z.string().optional().describe("Speech-to-text mode (default 'accurate')."),
|
|
589
|
+
interruption_sensitivity: z.number().min(0).max(1).optional().describe("0..1 (default 0.5; lower = less likely to interrupt, like Japanese)."),
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
async (args) => {
|
|
593
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server; cannot add a language.");
|
|
594
|
+
const r = await lang.addLanguage(RETELL_KEY, args);
|
|
595
|
+
return r.error ? errText(r) : text(r);
|
|
596
|
+
}
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
server.registerTool(
|
|
600
|
+
"verify_language",
|
|
601
|
+
{
|
|
602
|
+
title: "Verify a call language",
|
|
603
|
+
description: "Quality-gate a newly added language before production. DRY-RUN by default: returns a review card with the EXACT spoken opening (greeting + your ask + AI disclosure) and voicemail message — composed by the language's agent — plus a native-review checklist. To HEAR it live, pass to:<your phone> and confirm:true and the agent will call you so you can judge accent, etiquette, and the disclosure by ear. For the truest review, pass target-language sample_summary and sample_opening_ask.",
|
|
604
|
+
inputSchema: {
|
|
605
|
+
lang: z.string().describe("The registered language code to verify (e.g. 'fr')."),
|
|
606
|
+
sample_summary: z.string().optional().describe("A sample call objective IN THE TARGET LANGUAGE (improves the review fidelity)."),
|
|
607
|
+
sample_opening_ask: z.string().optional().describe("A sample spoken opening ask IN THE TARGET LANGUAGE. If omitted, the language's own fallback phrasing is used."),
|
|
608
|
+
to: z.string().optional().describe("Your own phone number (E.164) to receive the live verification call. Defaults to your configured callback_number."),
|
|
609
|
+
confirm: z.boolean().optional().describe("Must be true (with a resolvable 'to') to actually place the live call. Omit = dry-run review card only."),
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
async ({ lang: langArg, sample_summary, sample_opening_ask, to, confirm = false }) => {
|
|
613
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server.");
|
|
614
|
+
const { primary, valid } = lang.normalizeLang(langArg);
|
|
615
|
+
if (!valid) return errText(`Invalid language code "${langArg}".`);
|
|
616
|
+
const config = setup.loadConfig();
|
|
617
|
+
const meta = config.languages?.[primary];
|
|
618
|
+
const agentId = config.agents?.by_lang?.[primary] || meta?.agent_id;
|
|
619
|
+
if (primary !== "en" && !agentId) {
|
|
620
|
+
return errText(`Language "${primary}" is not registered. Add it first with add_language.`);
|
|
621
|
+
}
|
|
622
|
+
const job = lang.buildVerificationJob(primary, { sample_summary, sample_opening_ask });
|
|
623
|
+
let result;
|
|
624
|
+
try {
|
|
625
|
+
result = await core.placeCall(job, { lang: primary, go: false, key: RETELL_KEY });
|
|
626
|
+
} catch (e) { return errText(String(e.message || e)); }
|
|
627
|
+
if (!result.ok) return errText({ validation_errors: result.errors });
|
|
628
|
+
const card = lang.verificationCard(result.composed, {
|
|
629
|
+
lang: primary,
|
|
630
|
+
display_name: meta?.display_name || primary,
|
|
631
|
+
agent_id: agentId || result.agentId,
|
|
632
|
+
voice_id: meta?.voice_id || null,
|
|
633
|
+
language: meta?.language || null,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const dialTo = to || config.principal?.callback_number || null;
|
|
637
|
+
if (!confirm) {
|
|
638
|
+
return text({
|
|
639
|
+
review: card,
|
|
640
|
+
live_call: dialTo
|
|
641
|
+
? `To hear it, call verify_language again with to:"${dialTo}" and confirm:true.`
|
|
642
|
+
: "To hear it live, provide to:<your phone> and confirm:true (no callback_number is configured).",
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
if (!dialTo) return errText("No 'to' number provided and no callback_number configured; cannot place the live verification call.");
|
|
646
|
+
try {
|
|
647
|
+
const dial = await core.placeCall(
|
|
648
|
+
{ ...job, target: { business_name: "Verification call", phone_number: dialTo } },
|
|
649
|
+
{ lang: primary, go: true, key: RETELL_KEY }
|
|
650
|
+
);
|
|
651
|
+
return text({
|
|
652
|
+
review: card,
|
|
653
|
+
placed: true,
|
|
654
|
+
call_id: dial.call.call_id,
|
|
655
|
+
to: dialTo,
|
|
656
|
+
next: "Answer the call and judge the opening, AI disclosure, accent, and etiquette by ear. Fetch the transcript with get_call_outcome. Fix anything via update_language.",
|
|
657
|
+
});
|
|
658
|
+
} catch (e) { return errText(String(e.message || e)); }
|
|
659
|
+
}
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
server.registerTool(
|
|
663
|
+
"update_language",
|
|
664
|
+
{
|
|
665
|
+
title: "Update a call language",
|
|
666
|
+
description: "Change an already-added language: replace its prompt_text and/or phrases (re-translated), and/or change its voice_id, Retell language code, interruption_sensitivity, or stt_mode. Only the provided fields change. Use list_languages to see what's registered.",
|
|
667
|
+
inputSchema: {
|
|
668
|
+
lang: z.string().describe("The registered language code to update (e.g. 'fr')."),
|
|
669
|
+
prompt_text: z.string().optional().describe("Replacement prompt (must keep all {{variables}})."),
|
|
670
|
+
phrases: z.record(z.string(), z.any()).optional().describe("Replacement phrase block (must keep all {placeholder} tokens)."),
|
|
671
|
+
voice_id: z.string().optional().describe("New Retell voice_id (validated against list_voices)."),
|
|
672
|
+
language: z.string().optional().describe("New Retell language code."),
|
|
673
|
+
interruption_sensitivity: z.number().min(0).max(1).optional(),
|
|
674
|
+
stt_mode: z.string().optional(),
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
async (args) => {
|
|
678
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server.");
|
|
679
|
+
const r = await lang.updateLanguage(RETELL_KEY, args);
|
|
680
|
+
return r.error ? errText(r) : text(r);
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
server.registerTool(
|
|
685
|
+
"remove_language",
|
|
686
|
+
{
|
|
687
|
+
title: "Remove a call language",
|
|
688
|
+
description: "Unregister an added language and delete its volume prompt + phrase assets. By default the Retell agent is LEFT in your dashboard (set delete_agent:true to delete it too). English cannot be removed.",
|
|
689
|
+
inputSchema: {
|
|
690
|
+
lang: z.string().describe("The registered language code to remove (e.g. 'fr')."),
|
|
691
|
+
delete_agent: z.boolean().optional().describe("If true, also delete the Retell agent + LLM. Default false (unregister locally only)."),
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
async (args) => {
|
|
695
|
+
if (!RETELL_KEY) return errText("RETELL_API_KEY is not set on the server.");
|
|
696
|
+
const r = await lang.removeLanguage(RETELL_KEY, args);
|
|
697
|
+
return r.error ? errText(r) : text(r);
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
return server;
|
|
702
|
+
}
|
|
703
|
+
const app = express();
|
|
704
|
+
app.use(express.json({ limit: "1mb" }));
|
|
705
|
+
|
|
706
|
+
// Single-user auth. Accept the token via either the Authorization header
|
|
707
|
+
// (Claude Desktop / Cursor / Claude Code / ChatGPT) OR a `?key=` query param
|
|
708
|
+
// (claude.ai web, whose connector form only takes a URL). If MCP_AUTH_TOKEN is
|
|
709
|
+
// unset, allow only loopback.
|
|
710
|
+
function authOk(req) {
|
|
711
|
+
if (!AUTH_TOKEN) {
|
|
712
|
+
const ip = req.ip || req.socket?.remoteAddress || "";
|
|
713
|
+
return ip.includes("127.0.0.1") || ip.includes("::1");
|
|
714
|
+
}
|
|
715
|
+
const h = req.headers["authorization"] || "";
|
|
716
|
+
if (h === `Bearer ${AUTH_TOKEN}`) return true;
|
|
717
|
+
const q = req.query?.key || req.query?.token;
|
|
718
|
+
return q === AUTH_TOKEN;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
app.get("/health", (_req, res) => res.json({ ok: true, service: "callwright-mcp" }));
|
|
722
|
+
|
|
723
|
+
// ---- Retell webhook -> AUTO-LEARN (deterministic; no MCP auth, signature-verified) ----
|
|
724
|
+
// When Retell finishes analyzing a call it POSTs here. We run the SAME learning
|
|
725
|
+
// heuristics as the learn_from_call tool, but autonomously and with the stricter
|
|
726
|
+
// minCount:2 promotion guard (a field must recur before it's added; a new
|
|
727
|
+
// scenario only ever STAGES — creation stays a propose-and-confirm human step).
|
|
728
|
+
function verifyRetellWebhook(req) {
|
|
729
|
+
if (process.env.WEBHOOK_SKIP_VERIFY === "1") return true;
|
|
730
|
+
try {
|
|
731
|
+
const Retell = require("retell-sdk").Retell || require("retell-sdk").default || require("retell-sdk");
|
|
732
|
+
const sig = req.headers["x-retell-signature"];
|
|
733
|
+
return Retell.verify(JSON.stringify(req.body), RETELL_KEY, sig);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
console.error("webhook signature verify failed:", e.message);
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
app.post(WEBHOOK_PATH, async (req, res) => {
|
|
741
|
+
if (!verifyRetellWebhook(req)) return res.status(401).json({ error: "invalid signature" });
|
|
742
|
+
const { event, call } = req.body || {};
|
|
743
|
+
|
|
744
|
+
// ---- call_ended -> retry-on-no-answer (deterministic, bounded) ----
|
|
745
|
+
// Default policy is exactly 1 retry; the attempt counter rides in the call's
|
|
746
|
+
// dynamic vars so the chain self-terminates and we never exceed max_retries.
|
|
747
|
+
if (event === "call_ended") {
|
|
748
|
+
try {
|
|
749
|
+
const d = retry.decideRetry(call);
|
|
750
|
+
if (!d.retry) return res.status(200).json({ retry: false, reason: d.reason, category: d.category });
|
|
751
|
+
const entry = {
|
|
752
|
+
at: new Date().toISOString(),
|
|
753
|
+
parent_call_id: call.call_id,
|
|
754
|
+
to: call.to_number,
|
|
755
|
+
business: call.retell_llm_dynamic_variables?.business_name || call.to_number,
|
|
756
|
+
category: d.category,
|
|
757
|
+
attempt: d.attempt,
|
|
758
|
+
max_retries: d.maxRetries,
|
|
759
|
+
delay_seconds: d.delaySeconds,
|
|
760
|
+
status: "scheduled",
|
|
761
|
+
};
|
|
762
|
+
appendRetry(entry);
|
|
763
|
+
// Best-effort delayed re-dial while the machine is warm (scale-to-zero may
|
|
764
|
+
// drop a pending timer; that's the accepted tradeoff for no extra infra).
|
|
765
|
+
const fire = async () => {
|
|
766
|
+
try {
|
|
767
|
+
const newCall = await core.redialFromCall(call, { key: RETELL_KEY, attempt: d.attempt });
|
|
768
|
+
updateRetry((e) => e.parent_call_id === entry.parent_call_id && e.attempt === entry.attempt && e.status === "scheduled",
|
|
769
|
+
{ status: "placed", new_call_id: newCall.call_id });
|
|
770
|
+
console.log(`retry placed: ${call.call_id} -> ${newCall.call_id} (attempt ${d.attempt}/${d.maxRetries}, ${d.category})`);
|
|
771
|
+
} catch (e) {
|
|
772
|
+
updateRetry((x) => x.parent_call_id === entry.parent_call_id && x.attempt === entry.attempt && x.status === "scheduled",
|
|
773
|
+
{ status: "failed", error: e.message });
|
|
774
|
+
console.error("retry redial failed:", e.message);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
if (d.delaySeconds > 0) setTimeout(fire, d.delaySeconds * 1000).unref?.();
|
|
778
|
+
else fire();
|
|
779
|
+
return res.status(200).json({ retry: true, attempt: d.attempt, max_retries: d.maxRetries, in_seconds: d.delaySeconds, category: d.category });
|
|
780
|
+
} catch (e) {
|
|
781
|
+
console.error("retry handler error:", e.message);
|
|
782
|
+
return res.status(200).json({ retry: false, error: e.message });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (event !== "call_analyzed") return res.status(200).json({ skipped: event || "no_event" });
|
|
787
|
+
|
|
788
|
+
// SMS notify (per-call opt-in) — independent of learning. Fire-and-respond:
|
|
789
|
+
// we don't block the 200 on it, but we persist any provisioned agent to config.
|
|
790
|
+
const notifyResult = (async () => {
|
|
791
|
+
try {
|
|
792
|
+
if (!notify.shouldNotify(call).notify) return { sent: false, reason: "not_requested" };
|
|
793
|
+
const config = setup.loadConfig();
|
|
794
|
+
const r = await notify.notifyCall(call, { config, key: RETELL_KEY });
|
|
795
|
+
if (r.configChanged) setup.saveConfig(config);
|
|
796
|
+
if (r.sent) console.log(`SMS notify sent to ${r.to} for call ${call.call_id}`);
|
|
797
|
+
else console.log(`SMS notify skipped (${r.reason}${r.error ? ": " + r.error : ""})`);
|
|
798
|
+
return r;
|
|
799
|
+
} catch (e) { console.error("notify error:", e.message); return { sent: false, error: e.message }; }
|
|
800
|
+
})();
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
const profiles = loadProfiles();
|
|
804
|
+
const candidates = loadCandidates();
|
|
805
|
+
const r = learn.applyLearning(call, { profiles, candidates, minCount: 2, N: 2 });
|
|
806
|
+
const notified = await notifyResult;
|
|
807
|
+
const notify_sms = notified.sent ? { sent: true, to: notified.to } : (notified.reason === "not_requested" ? undefined : { sent: false, reason: notified.reason, error: notified.error });
|
|
808
|
+
if (r.mode === "enriched") {
|
|
809
|
+
if (!r.skipped) saveProfiles(profiles);
|
|
810
|
+
return res.status(200).json({ auto_learned: "enriched", profile: r.profile, added: r.added, skipped: r.skipped || false, notify_sms });
|
|
811
|
+
}
|
|
812
|
+
if (r.mode === "staged") {
|
|
813
|
+
saveCandidates(candidates);
|
|
814
|
+
return res.status(200).json({ auto_learned: "staged", scenario: r.scenario, seen: r.seen, ready_to_propose: r.ready_to_propose, notify_sms });
|
|
815
|
+
}
|
|
816
|
+
return res.status(200).json({ auto_learned: false, reason: r.reason, notify_sms });
|
|
817
|
+
} catch (e) {
|
|
818
|
+
console.error("auto-learn error:", e.message);
|
|
819
|
+
await notifyResult;
|
|
820
|
+
return res.status(200).json({ auto_learned: false, error: e.message }); // 200 so Retell doesn't retry forever
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// STATELESS MCP: a fresh server + transport per request, no session map.
|
|
825
|
+
// This is deliberate — the machine scales to zero and restarts on deploy, and
|
|
826
|
+
// an in-memory session map does NOT survive a restart (that caused "worked
|
|
827
|
+
// once, then generic error" failures). Stateless means every tool call is
|
|
828
|
+
// self-contained, so sleep/restart between calls is irrelevant. Our tools are
|
|
829
|
+
// request/response (no long-lived server->client streams), so we lose nothing.
|
|
830
|
+
app.post("/mcp", async (req, res) => {
|
|
831
|
+
if (!authOk(req)) {
|
|
832
|
+
return res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, message: "unauthorized" }, id: null });
|
|
833
|
+
}
|
|
834
|
+
const server = buildServer();
|
|
835
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
836
|
+
res.on("close", () => { try { transport.close(); server.close(); } catch {} });
|
|
837
|
+
try {
|
|
838
|
+
await server.connect(transport);
|
|
839
|
+
await transport.handleRequest(req, res, req.body);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
console.error("MCP request error:", e?.message || e);
|
|
842
|
+
if (!res.headersSent) {
|
|
843
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "internal error" }, id: null });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// In stateless mode there are no sessions to stream/terminate.
|
|
849
|
+
app.get("/mcp", (_req, res) =>
|
|
850
|
+
res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless server)." }, id: null }));
|
|
851
|
+
app.delete("/mcp", (_req, res) =>
|
|
852
|
+
res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (stateless server)." }, id: null }));
|
|
853
|
+
|
|
854
|
+
// Seed mutable state onto the persistent volume on first boot (cross-platform,
|
|
855
|
+
// no shell script). config.json + agents.json are created at runtime by
|
|
856
|
+
// `configure`; scenario-profiles.json ships with defaults, so seed it.
|
|
857
|
+
function seedState() {
|
|
858
|
+
const dataDir = paths.DATA_DIR;
|
|
859
|
+
if (!dataDir || dataDir === ".") return;
|
|
860
|
+
try { fs.mkdirSync(dataDir, { recursive: true }); } catch {}
|
|
861
|
+
const dest = path.join(dataDir, "scenario-profiles.json");
|
|
862
|
+
const src = path.join(__dirname, "scenario-profiles.json");
|
|
863
|
+
if (!fs.existsSync(dest) && fs.existsSync(src)) {
|
|
864
|
+
fs.copyFileSync(src, dest);
|
|
865
|
+
console.log("seeded scenario-profiles.json ->", dest);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (require.main === module) {
|
|
870
|
+
seedState();
|
|
871
|
+
if (!RETELL_KEY) console.warn("⚠ RETELL_API_KEY not set — calls will fail until it is.");
|
|
872
|
+
if (!AUTH_TOKEN) console.warn("⚠ MCP_AUTH_TOKEN not set — only loopback clients allowed.");
|
|
873
|
+
app.listen(PORT, () => console.log(`callwright MCP listening on :${PORT} (POST /mcp)`));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
module.exports = { app, buildServer };
|