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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Create a Retell agent from a prompt file and register it in agents.json.
|
|
2
|
+
// Generalizes setup-agent.js so any scenario (or a generic agent) can be made.
|
|
3
|
+
//
|
|
4
|
+
// node setup-agent-from.js <prompt-file> <agent_name> [call_type1,call_type2,...]
|
|
5
|
+
//
|
|
6
|
+
// Example (generic agent that handles appointment_booking + general_inquiry):
|
|
7
|
+
// node setup-agent-from.js generic-prompt.md generic appointment_booking,general_inquiry
|
|
8
|
+
//
|
|
9
|
+
// Env: RETELL_API_KEY (no phone number needed)
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
|
|
13
|
+
const API_KEY = process.env.RETELL_API_KEY;
|
|
14
|
+
if (!API_KEY) { console.error("Missing RETELL_API_KEY."); process.exit(1); }
|
|
15
|
+
|
|
16
|
+
const promptFile = process.argv[2];
|
|
17
|
+
const agentName = process.argv[3];
|
|
18
|
+
const callTypes = (process.argv[4] || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
19
|
+
if (!promptFile || !agentName) {
|
|
20
|
+
console.error("Usage: node setup-agent-from.js <prompt-file> <agent_name> [call_type1,...]");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VOICE_ID = process.env.RETELL_VOICE_ID || "11labs-Adrian";
|
|
25
|
+
const AGENT_LANG = process.env.RETELL_AGENT_LANG || "en-US";
|
|
26
|
+
const VOICE_MODEL = process.env.RETELL_VOICE_MODEL || null;
|
|
27
|
+
const VOICEMAIL_TEXT = process.env.RETELL_VOICEMAIL_TEXT || "{{voicemail_message}}";
|
|
28
|
+
const BASE = "https://api.retellai.com";
|
|
29
|
+
|
|
30
|
+
function loadPrompt(file) {
|
|
31
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
32
|
+
const cut = raw.indexOf("# ---");
|
|
33
|
+
return (cut > -1 ? raw.slice(0, cut) : raw).trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Generic post-call analysis (scenario-agnostic).
|
|
37
|
+
const postCallAnalysis = [
|
|
38
|
+
{ type: "enum", name: "status",
|
|
39
|
+
description: "Final result of the call.",
|
|
40
|
+
choices: ["booked", "failed", "voicemail", "callback_needed", "escalated"] },
|
|
41
|
+
{ type: "string", name: "booked_date", description: "Agreed date, or empty." },
|
|
42
|
+
{ type: "string", name: "booked_time", description: "Agreed time, or empty." },
|
|
43
|
+
{ type: "string", name: "confirmation_ref", description: "Any confirmation name/number." },
|
|
44
|
+
{ type: "boolean", name: "accommodations_ok", description: "Were special requests accepted." },
|
|
45
|
+
{ type: "string", name: "unmet_items", description: "Anything not achieved, or why it failed." },
|
|
46
|
+
{ type: "string", name: "unanswered_questions",
|
|
47
|
+
description: "List any questions the business asked that the agent could NOT answer or had to defer to the principal (e.g. 'what type of haircut', 'preferred stylist'). Empty if none." },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
async function api(path, body) {
|
|
51
|
+
const resp = await fetch(BASE + path, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
});
|
|
56
|
+
const text = await resp.text();
|
|
57
|
+
if (!resp.ok) throw new Error(`${path} -> ${resp.status}\n${text}`);
|
|
58
|
+
return JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
(async () => {
|
|
62
|
+
console.log(`Creating LLM from ${promptFile}...`);
|
|
63
|
+
const llm = await api("/create-retell-llm", {
|
|
64
|
+
general_prompt: loadPrompt(promptFile),
|
|
65
|
+
general_tools: [
|
|
66
|
+
{ type: "end_call", name: "end_call", description: "End the call when done." },
|
|
67
|
+
{ type: "press_digit", name: "press_digit",
|
|
68
|
+
description: "Press a keypad digit to navigate an automated phone menu (IVR). Use whenever a menu instructs you to press a number to reach a department or person.",
|
|
69
|
+
delay_ms: 1500 },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
console.log(" llm_id:", llm.llm_id);
|
|
73
|
+
|
|
74
|
+
console.log("Creating agent...");
|
|
75
|
+
const agentBody = {
|
|
76
|
+
response_engine: { type: "retell-llm", llm_id: llm.llm_id },
|
|
77
|
+
voice_id: VOICE_ID,
|
|
78
|
+
agent_name: agentName,
|
|
79
|
+
language: AGENT_LANG,
|
|
80
|
+
max_call_duration_ms: 300000,
|
|
81
|
+
interruption_sensitivity: 0.9,
|
|
82
|
+
voicemail_option: { action: { type: "static_text", text: VOICEMAIL_TEXT } },
|
|
83
|
+
post_call_analysis_data: postCallAnalysis,
|
|
84
|
+
webhook_events: ["call_started", "call_ended", "call_analyzed"],
|
|
85
|
+
};
|
|
86
|
+
if (VOICE_MODEL) agentBody.voice_model = VOICE_MODEL;
|
|
87
|
+
const agent = await api("/create-agent", agentBody);
|
|
88
|
+
console.log(" agent_id:", agent.agent_id);
|
|
89
|
+
|
|
90
|
+
// Register in agents.json for the given call types.
|
|
91
|
+
const reg = fs.existsSync("agents.json") ? JSON.parse(fs.readFileSync("agents.json", "utf8")) : {};
|
|
92
|
+
for (const ct of callTypes) reg[ct] = agent.agent_id;
|
|
93
|
+
fs.writeFileSync("agents.json", JSON.stringify(reg, null, 2));
|
|
94
|
+
console.log("\nRegistered in agents.json for:", callTypes.join(", ") || "(none)");
|
|
95
|
+
console.log(JSON.stringify(reg, null, 2));
|
|
96
|
+
})().catch(e => { console.error("\nFailed:\n" + e.message); process.exit(1); });
|
package/setup-core.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// callwright — shared setup core (NON-interactive, reusable).
|
|
2
|
+
//
|
|
3
|
+
// Both the CLI wizard (init.js) and the future MCP server build on these.
|
|
4
|
+
// Nothing here reads stdin or prints prompts, so it is safe to call from an
|
|
5
|
+
// MCP tool handler (stdio-owned) as well as from the terminal wizard.
|
|
6
|
+
//
|
|
7
|
+
// Responsibilities:
|
|
8
|
+
// - verify a Retell API key (and list agents)
|
|
9
|
+
// - list phone numbers; resolve a from-number
|
|
10
|
+
// - reuse-or-create the generic agent
|
|
11
|
+
// - load/save config.json
|
|
12
|
+
// - report setup status / what's missing (for onboarding prompts)
|
|
13
|
+
// - set principal identity + facts (data the LLM gathered in chat)
|
|
14
|
+
// - guard: tell a caller (LLM) when setup is incomplete before place_call
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const paths = require("./paths");
|
|
18
|
+
|
|
19
|
+
const BASE = "https://api.retellai.com";
|
|
20
|
+
const CONFIG_PATH = paths.CONFIG_PATH;
|
|
21
|
+
const AGENTS_PATH = paths.AGENTS_PATH;
|
|
22
|
+
const DEFAULT_PROMPT_FILE = "generic-prompt.md";
|
|
23
|
+
|
|
24
|
+
// The webhook_url to bake into agents so Retell posts call_analyzed back to this
|
|
25
|
+
// server for auto-learn. Returns null if no public URL is configured.
|
|
26
|
+
function webhookUrl() {
|
|
27
|
+
const base = (process.env.CALLWRIGHT_PUBLIC_URL || "").replace(/\/+$/, "");
|
|
28
|
+
return base ? `${base}/webhook/retell` : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Generic post-call analysis (single source of truth; init/setup-agent reuse).
|
|
32
|
+
const POST_CALL_ANALYSIS = [
|
|
33
|
+
{ type: "enum", name: "status", description: "Final result of the call.",
|
|
34
|
+
choices: ["booked", "failed", "voicemail", "callback_needed", "escalated"] },
|
|
35
|
+
{ type: "string", name: "booked_date", description: "Agreed date, or empty." },
|
|
36
|
+
{ type: "string", name: "booked_time", description: "Agreed time, or empty." },
|
|
37
|
+
{ type: "string", name: "confirmation_ref", description: "Any confirmation name/number." },
|
|
38
|
+
{ type: "boolean", name: "accommodations_ok", description: "Were special requests accepted." },
|
|
39
|
+
{ type: "string", name: "unmet_items", description: "Anything not achieved, or why it failed." },
|
|
40
|
+
{ type: "string", name: "unanswered_questions",
|
|
41
|
+
description: "List any questions the business asked that the agent could NOT answer or had to defer to the principal. Empty if none." },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ---- low-level ----
|
|
45
|
+
async function apiCall(key, method, path, body) {
|
|
46
|
+
const opts = { method, headers: { Authorization: `Bearer ${key}` } };
|
|
47
|
+
if (body) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); }
|
|
48
|
+
const resp = await fetch(BASE + path, opts);
|
|
49
|
+
const text = await resp.text();
|
|
50
|
+
if (!resp.ok) throw new Error(`${method} ${path} -> ${resp.status}\n${text}`);
|
|
51
|
+
return text ? JSON.parse(text) : {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadJson(path, fallback) {
|
|
55
|
+
try { return JSON.parse(fs.readFileSync(path, "utf8")); } catch { return fallback; }
|
|
56
|
+
}
|
|
57
|
+
function loadConfig() {
|
|
58
|
+
const c = loadJson(CONFIG_PATH, {});
|
|
59
|
+
c.retell = c.retell || {};
|
|
60
|
+
c.agents = c.agents || {};
|
|
61
|
+
c.principal = c.principal || {};
|
|
62
|
+
return c;
|
|
63
|
+
}
|
|
64
|
+
function saveConfig(config) {
|
|
65
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
66
|
+
return config;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadPromptText(file = DEFAULT_PROMPT_FILE) {
|
|
70
|
+
// Volume-first: a runtime-added language's prompt persists on the volume and
|
|
71
|
+
// overrides the image; en/ja seeds ship in the image.
|
|
72
|
+
const raw = fs.readFileSync(paths.resolveAsset(file), "utf8");
|
|
73
|
+
const cut = raw.indexOf("# ---");
|
|
74
|
+
return (cut > -1 ? raw.slice(0, cut) : raw).trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- Retell infra ----
|
|
78
|
+
// List voice agents via the unified v2 endpoint (POST /v2/list-agents), reading
|
|
79
|
+
// `items` and following pagination. Replaces the deprecated GET /list-agents
|
|
80
|
+
// (removed 2026-07-31). The channel filter preserves the old voice-only behavior.
|
|
81
|
+
async function listAgents(key) {
|
|
82
|
+
if (!key) throw new Error("No RETELL_API_KEY provided.");
|
|
83
|
+
const out = [];
|
|
84
|
+
let pageKey;
|
|
85
|
+
do {
|
|
86
|
+
const body = { filter_criteria: { channel: { type: "string", op: "eq", value: "voice" } } };
|
|
87
|
+
if (pageKey) body.pagination_key = pageKey;
|
|
88
|
+
const resp = await apiCall(key, "POST", "/v2/list-agents", body);
|
|
89
|
+
const items = Array.isArray(resp) ? resp : (resp.items || []);
|
|
90
|
+
out.push(...items);
|
|
91
|
+
pageKey = (!Array.isArray(resp) && resp.has_more) ? resp.pagination_key : null;
|
|
92
|
+
} while (pageKey);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Verify a key by listing agents. Returns the agents array (throws if invalid).
|
|
97
|
+
async function verifyKey(key) {
|
|
98
|
+
return listAgents(key);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function listNumbers(key) {
|
|
102
|
+
// v2 returns unified pagination ({ items, has_more }); unwrap to an array.
|
|
103
|
+
const resp = await apiCall(key, "GET", "/v2/list-phone-numbers");
|
|
104
|
+
return Array.isArray(resp) ? resp : (resp.items || []);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Auto-resolve a from-number: keep the preferred if still present; else if exactly
|
|
108
|
+
// one exists use it; else return { needsChoice, numbers } so the caller can ask.
|
|
109
|
+
async function resolveFromNumber(key, preferred) {
|
|
110
|
+
const numbers = await listNumbers(key);
|
|
111
|
+
const have = numbers.map((n) => n.phone_number);
|
|
112
|
+
if (preferred && have.includes(preferred)) return { from_number: preferred, numbers };
|
|
113
|
+
if (numbers.length === 1) return { from_number: have[0], numbers };
|
|
114
|
+
if (numbers.length === 0) return { from_number: null, numbers, needsChoice: true, reason: "no_numbers" };
|
|
115
|
+
return { from_number: null, numbers, needsChoice: true, reason: "multiple" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Reuse an existing generic agent or create one. Returns { agent_id, created }.
|
|
119
|
+
async function ensureGenericAgent(key, { agents, defaultId, promptFile = DEFAULT_PROMPT_FILE, voiceId } = {}) {
|
|
120
|
+
agents = agents || (await verifyKey(key));
|
|
121
|
+
const lc = (a) => (a.agent_name || "").toLowerCase();
|
|
122
|
+
// Canonical English default is an agent named exactly "generic".
|
|
123
|
+
const exact = agents.find((a) => lc(a) === "generic");
|
|
124
|
+
// Honor an existing default ONLY if it's still valid AND not being shadowed by
|
|
125
|
+
// a canonical "generic" (self-heals a default mistakenly set to e.g. generic_ja).
|
|
126
|
+
if (defaultId && agents.some((a) => a.agent_id === defaultId)) {
|
|
127
|
+
if (!exact || defaultId === exact.agent_id) {
|
|
128
|
+
return { agent_id: defaultId, created: false, reused: true };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Prefer exact English "generic"; avoid language-suffixed variants (generic_ja).
|
|
132
|
+
const byName =
|
|
133
|
+
exact ||
|
|
134
|
+
agents.find((a) => /generic/i.test(lc(a)) && !/_(ja|jp|es|fr|de|zh|ko|pt|it)$/i.test(lc(a))) ||
|
|
135
|
+
agents.find((a) => /generic/i.test(lc(a)));
|
|
136
|
+
if (byName) return { agent_id: byName.agent_id, created: false, reused: true, matchedByName: true };
|
|
137
|
+
|
|
138
|
+
const llm = await apiCall(key, "POST", "/create-retell-llm", {
|
|
139
|
+
general_prompt: loadPromptText(promptFile),
|
|
140
|
+
// Phone etiquette: wait for the callee to answer ("Hello?") before the agent
|
|
141
|
+
// introduces itself. begin_after_user_silence_ms is a fallback if they pick
|
|
142
|
+
// up silently (or an IVR doesn't greet) so we don't wait forever.
|
|
143
|
+
start_speaker: "user",
|
|
144
|
+
begin_message: "",
|
|
145
|
+
begin_after_user_silence_ms: 8000,
|
|
146
|
+
general_tools: [
|
|
147
|
+
{ type: "end_call", name: "end_call", description: "End the call when done." },
|
|
148
|
+
{ type: "press_digit", name: "press_digit",
|
|
149
|
+
description: "Press a keypad digit to navigate an automated phone menu (IVR).",
|
|
150
|
+
delay_ms: 1500 },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
const agent = await apiCall(key, "POST", "/create-agent", {
|
|
154
|
+
response_engine: { type: "retell-llm", llm_id: llm.llm_id },
|
|
155
|
+
voice_id: voiceId || process.env.RETELL_VOICE_ID || "11labs-Brian",
|
|
156
|
+
agent_name: "generic",
|
|
157
|
+
language: "en-US",
|
|
158
|
+
max_call_duration_ms: 600000,
|
|
159
|
+
interruption_sensitivity: 0.9,
|
|
160
|
+
voicemail_option: { action: { type: "static_text", text: "{{voicemail_message}}" } },
|
|
161
|
+
post_call_analysis_data: POST_CALL_ANALYSIS,
|
|
162
|
+
webhook_events: ["call_started", "call_ended", "call_analyzed"],
|
|
163
|
+
...(webhookUrl() ? { webhook_url: webhookUrl() } : {}),
|
|
164
|
+
});
|
|
165
|
+
return { agent_id: agent.agent_id, created: true, llm_id: llm.llm_id };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Detect language-variant generic agents by name convention "generic_<lang>"
|
|
169
|
+
// (e.g. generic_ja -> { ja: agent_id }). Lets the MCP/CLI route lang -> agent.
|
|
170
|
+
function detectLanguageAgents(agents) {
|
|
171
|
+
const out = {};
|
|
172
|
+
for (const a of agents || []) {
|
|
173
|
+
const m = (a.agent_name || "").toLowerCase().match(/^generic[_-]([a-z]{2,3})$/);
|
|
174
|
+
if (m) out[m[1]] = a.agent_id;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Persist resolved infra into config + keep agents.json default in sync.
|
|
180
|
+
function applyInfra(config, { from_number, agent_id, by_lang } = {}) {
|
|
181
|
+
if (from_number) config.retell.from_number = from_number;
|
|
182
|
+
if (agent_id) {
|
|
183
|
+
config.agents.default = agent_id;
|
|
184
|
+
const reg = loadJson(AGENTS_PATH, {});
|
|
185
|
+
reg.default = agent_id;
|
|
186
|
+
fs.writeFileSync(AGENTS_PATH, JSON.stringify(reg, null, 2));
|
|
187
|
+
}
|
|
188
|
+
if (by_lang && Object.keys(by_lang).length) {
|
|
189
|
+
config.agents.by_lang = { ...(config.agents.by_lang || {}), ...by_lang };
|
|
190
|
+
}
|
|
191
|
+
return config;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- principal grounding ----
|
|
195
|
+
// Merge identity + facts the LLM gathered (in chat) or the wizard collected.
|
|
196
|
+
function setPrincipal(config, { name, callback_number, facts } = {}) {
|
|
197
|
+
config.principal = config.principal || {};
|
|
198
|
+
if (name != null) config.principal.name = name;
|
|
199
|
+
if (callback_number != null) config.principal.callback_number = callback_number;
|
|
200
|
+
if (facts && typeof facts === "object") {
|
|
201
|
+
config.principal.facts = { ...(config.principal.facts || {}), ...facts };
|
|
202
|
+
}
|
|
203
|
+
return config;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Per-call values that are NOT durable identity — never suggest saving these to
|
|
207
|
+
// the standing profile (they belong to a single call).
|
|
208
|
+
const EPHEMERAL_FACT_KEYS = new Set([
|
|
209
|
+
"confirmation_number", "confirmation_ref", "confirmation", "booking_ref", "booking_number",
|
|
210
|
+
"reservation_number", "reservation_ref", "otp", "one_time_code", "verification_code", "code", "pin",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
// Lazy-save nudge: durable PII furnished for THIS call that the standing profile
|
|
214
|
+
// doesn't already have, so the host LLM can offer to save it once (via configure)
|
|
215
|
+
// instead of re-asking on every future call. Returns [{ key, type:'new'|'changed' }].
|
|
216
|
+
// Excludes anonymous calls and obviously one-off values (confirmation numbers, OTPs).
|
|
217
|
+
function unsavedDurableFacts(job, config = loadConfig()) {
|
|
218
|
+
const out = [];
|
|
219
|
+
const p = (job && job.principal) || {};
|
|
220
|
+
if (p.anonymous) return out;
|
|
221
|
+
const standing = (config && config.principal) || {};
|
|
222
|
+
const standingFacts = standing.facts || {};
|
|
223
|
+
if (p.name && !standing.name) out.push({ key: "name", type: "new" });
|
|
224
|
+
if (p.callback_number && !standing.callback_number) out.push({ key: "callback_number", type: "new" });
|
|
225
|
+
const facts = p.facts || {};
|
|
226
|
+
for (const [k, v] of Object.entries(facts)) {
|
|
227
|
+
if (v == null || v === "") continue;
|
|
228
|
+
if (EPHEMERAL_FACT_KEYS.has(String(k).toLowerCase())) continue;
|
|
229
|
+
const saved = standingFacts[k];
|
|
230
|
+
if (saved == null || saved === "") out.push({ key: k, type: "new" });
|
|
231
|
+
else if (String(saved) !== String(v)) out.push({ key: k, type: "changed" });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- status / guard (drives onboarding prompts) ----
|
|
237
|
+
// Report what's configured and what's missing, split by infra vs principal.
|
|
238
|
+
function setupStatus(config = loadConfig()) {
|
|
239
|
+
const infraMissing = [];
|
|
240
|
+
if (!config.retell?.from_number) infraMissing.push("retell.from_number");
|
|
241
|
+
if (!(config.agents?.default)) infraMissing.push("agents.default");
|
|
242
|
+
|
|
243
|
+
const basicsMissing = [];
|
|
244
|
+
if (!config.principal?.name) basicsMissing.push("name");
|
|
245
|
+
if (!config.principal?.callback_number) basicsMissing.push("callback_number");
|
|
246
|
+
|
|
247
|
+
const factKeys = Object.keys(config.principal?.facts || {});
|
|
248
|
+
return {
|
|
249
|
+
infra_ready: infraMissing.length === 0,
|
|
250
|
+
basics_ready: basicsMissing.length === 0,
|
|
251
|
+
ready: infraMissing.length === 0 && basicsMissing.length === 0,
|
|
252
|
+
infra_missing: infraMissing,
|
|
253
|
+
basics_missing: basicsMissing,
|
|
254
|
+
have: {
|
|
255
|
+
from_number: config.retell?.from_number || null,
|
|
256
|
+
default_agent: config.agents?.default || null,
|
|
257
|
+
name: config.principal?.name || null,
|
|
258
|
+
callback_number: config.principal?.callback_number || null,
|
|
259
|
+
fact_keys: factKeys,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Guard for place_call: returns null when OK, or a structured needs_setup object
|
|
265
|
+
// instructing the host LLM how to onboard the user (ask basics, call configure).
|
|
266
|
+
function guardPlaceCall(config = loadConfig()) {
|
|
267
|
+
const s = setupStatus(config);
|
|
268
|
+
if (s.ready) return null;
|
|
269
|
+
const missing = [...s.infra_missing, ...s.basics_missing];
|
|
270
|
+
const instructions = [];
|
|
271
|
+
if (!s.infra_ready) {
|
|
272
|
+
instructions.push(
|
|
273
|
+
"Infrastructure is not set up. Call run_provisioning (or have the user run `node init.js`) " +
|
|
274
|
+
"to verify the Retell key, select a phone number, and create the generic agent."
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (!s.basics_ready) {
|
|
278
|
+
instructions.push(
|
|
279
|
+
"Basic principal grounding is missing. Ask the user, in chat, for: " +
|
|
280
|
+
s.basics_missing.join(" and ") +
|
|
281
|
+
" (name is used when a call becomes a booking; callback_number is shared only if a business asks). " +
|
|
282
|
+
"Optionally offer to save a primary/service address. Then call configure with those values."
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
needs_setup: true,
|
|
287
|
+
missing,
|
|
288
|
+
status: s,
|
|
289
|
+
instruction: instructions.join(" "),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
BASE, CONFIG_PATH, AGENTS_PATH, DEFAULT_PROMPT_FILE, POST_CALL_ANALYSIS,
|
|
295
|
+
apiCall, loadConfig, saveConfig, loadPromptText, webhookUrl,
|
|
296
|
+
verifyKey, listAgents, listNumbers, resolveFromNumber, ensureGenericAgent, applyInfra,
|
|
297
|
+
detectLanguageAgents, setPrincipal, setupStatus, guardPlaceCall,
|
|
298
|
+
unsavedDurableFacts,
|
|
299
|
+
};
|
package/update-prompt.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Update an existing Retell agent's LLM prompt from a prompt file.
|
|
2
|
+
// node update-prompt.js <agent_id> <prompt-file>
|
|
3
|
+
// Env: RETELL_API_KEY
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const API_KEY = process.env.RETELL_API_KEY;
|
|
6
|
+
if (!API_KEY) { console.error("Missing RETELL_API_KEY."); process.exit(1); }
|
|
7
|
+
|
|
8
|
+
const agentId = process.argv[2];
|
|
9
|
+
const promptFile = process.argv[3];
|
|
10
|
+
if (!agentId || !promptFile) {
|
|
11
|
+
console.error("Usage: node update-prompt.js <agent_id> <prompt-file>");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const BASE = "https://api.retellai.com";
|
|
15
|
+
|
|
16
|
+
function loadPrompt(file) {
|
|
17
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
18
|
+
const cut = raw.indexOf("# ---");
|
|
19
|
+
return (cut > -1 ? raw.slice(0, cut) : raw).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function get(path) {
|
|
23
|
+
const resp = await fetch(BASE + path, {
|
|
24
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
25
|
+
});
|
|
26
|
+
const text = await resp.text();
|
|
27
|
+
if (!resp.ok) throw new Error(`${path} -> ${resp.status}\n${text}`);
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function patch(path, body) {
|
|
32
|
+
const resp = await fetch(BASE + path, {
|
|
33
|
+
method: "PATCH",
|
|
34
|
+
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
});
|
|
37
|
+
const text = await resp.text();
|
|
38
|
+
if (!resp.ok) throw new Error(`${path} -> ${resp.status}\n${text}`);
|
|
39
|
+
return JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
(async () => {
|
|
43
|
+
const agent = await get(`/get-agent/${agentId}`);
|
|
44
|
+
const llmId = agent.response_engine?.llm_id;
|
|
45
|
+
if (!llmId) throw new Error("Agent has no retell-llm engine; cannot update prompt.");
|
|
46
|
+
console.log("agent:", agentId, "-> llm:", llmId);
|
|
47
|
+
|
|
48
|
+
// Preserve existing tools; ensure a press_digit tool exists for IVR navigation.
|
|
49
|
+
const llm = await get(`/get-retell-llm/${llmId}`);
|
|
50
|
+
const tools = Array.isArray(llm.general_tools) ? llm.general_tools.slice() : [];
|
|
51
|
+
if (!tools.some(t => t.type === "press_digit")) {
|
|
52
|
+
tools.push({
|
|
53
|
+
type: "press_digit",
|
|
54
|
+
name: "press_digit",
|
|
55
|
+
description: "Press a keypad digit to navigate an automated phone menu (IVR). Use whenever a menu instructs you to press a number to reach a department or person.",
|
|
56
|
+
delay_ms: 1500,
|
|
57
|
+
});
|
|
58
|
+
console.log(" + added press_digit tool");
|
|
59
|
+
}
|
|
60
|
+
if (!tools.some(t => t.type === "end_call")) {
|
|
61
|
+
tools.push({ type: "end_call", name: "end_call", description: "End the call when done." });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await patch(`/update-retell-llm/${llmId}`, {
|
|
65
|
+
general_prompt: loadPrompt(promptFile),
|
|
66
|
+
general_tools: tools,
|
|
67
|
+
// Phone etiquette: wait for the callee to answer before introducing.
|
|
68
|
+
start_speaker: "user",
|
|
69
|
+
begin_message: "",
|
|
70
|
+
begin_after_user_silence_ms: 8000,
|
|
71
|
+
});
|
|
72
|
+
console.log("✅ Prompt + tools + etiquette updated from", promptFile);
|
|
73
|
+
console.log(" tools:", tools.map(t => t.type).join(", "));
|
|
74
|
+
|
|
75
|
+
// Ensure the agent leaves a CONTEXTFUL voicemail (who + why + callback),
|
|
76
|
+
// driven by the per-call {{voicemail_message}} dynamic variable.
|
|
77
|
+
const vmText = "{{voicemail_message}}";
|
|
78
|
+
const currentVm = agent.voicemail_option?.action?.text;
|
|
79
|
+
if (currentVm !== vmText) {
|
|
80
|
+
await patch(`/update-agent/${agentId}`, {
|
|
81
|
+
voicemail_option: { action: { type: "static_text", text: vmText } },
|
|
82
|
+
});
|
|
83
|
+
console.log("✅ Voicemail set to contextful {{voicemail_message}}");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Point the agent's webhook at this server (auto-learn) if a public URL is set.
|
|
87
|
+
const base = (process.env.CALLWRIGHT_PUBLIC_URL || "").replace(/\/+$/, "");
|
|
88
|
+
if (base) {
|
|
89
|
+
const url = `${base}/webhook/retell`;
|
|
90
|
+
if (agent.webhook_url !== url) {
|
|
91
|
+
await patch(`/update-agent/${agentId}`, {
|
|
92
|
+
webhook_url: url,
|
|
93
|
+
webhook_events: ["call_started", "call_ended", "call_analyzed"],
|
|
94
|
+
});
|
|
95
|
+
console.log("✅ webhook_url set ->", url);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})().catch(e => { console.error("\nFailed:\n" + e.message); process.exit(1); });
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Retell Webhook → Resend Email Notifier
|
|
2
|
+
|
|
3
|
+
**Serverless**, scale-to-zero webhook handler. Runs ONLY when Retell posts a completed call, then emails you a status summary. No always-on server, no database.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When a call completes:
|
|
8
|
+
- Retell fires `call_analyzed` webhook → this function
|
|
9
|
+
- Verifies signature, extracts outcome (status, booking details, reply, transcript)
|
|
10
|
+
- Emails you a formatted summary via Resend
|
|
11
|
+
|
|
12
|
+
Email looks like:
|
|
13
|
+
```
|
|
14
|
+
✅ [booked] Eclips Salon & Day Spa — Haircut appointment
|
|
15
|
+
|
|
16
|
+
The agent successfully booked a men's haircut for Phil at 10:30 AM on Saturday, July 18th.
|
|
17
|
+
|
|
18
|
+
Status booked
|
|
19
|
+
Business Eclips Salon & Day Spa
|
|
20
|
+
Booked date Saturday, July 18th
|
|
21
|
+
Booked time 10:30 AM
|
|
22
|
+
Duration 50s
|
|
23
|
+
Call ID call_d8cc81394abc94ab43ac3518eed
|
|
24
|
+
|
|
25
|
+
[Transcript]
|
|
26
|
+
Agent: Hi, I'm an AI assistant calling on behalf of Phil...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Deploy to Vercel (one-time, 2 minutes)
|
|
30
|
+
|
|
31
|
+
### 1. Push this folder to a Git repo
|
|
32
|
+
```bash
|
|
33
|
+
cd webhook
|
|
34
|
+
git init
|
|
35
|
+
git add .
|
|
36
|
+
git commit -m "retell webhook handler"
|
|
37
|
+
# push to GitHub/GitLab
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. Import to Vercel
|
|
41
|
+
- Go to [vercel.com/new](https://vercel.com/new)
|
|
42
|
+
- Import your repo
|
|
43
|
+
- Root directory: `webhook`
|
|
44
|
+
- Framework preset: Other
|
|
45
|
+
|
|
46
|
+
### 3. Set environment variables (in Vercel project settings)
|
|
47
|
+
```
|
|
48
|
+
RETELL_API_KEY = key_xxx... # from Retell dashboard
|
|
49
|
+
RESEND_API_KEY = re_xxx... # from resend.com
|
|
50
|
+
EMAIL_FROM = calls@yourdomain.com # must be verified in Resend
|
|
51
|
+
EMAIL_TO = you@example.com
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 4. Deploy
|
|
55
|
+
Vercel auto-deploys. You'll get a URL like:
|
|
56
|
+
```
|
|
57
|
+
https://virtuphil-webhook.vercel.app/api/retell-webhook
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 5. Register the webhook with Retell
|
|
61
|
+
|
|
62
|
+
**Option A: Account-level (all agents)**
|
|
63
|
+
- Go to Retell dashboard → Settings → Webhooks
|
|
64
|
+
- Paste your Vercel URL
|
|
65
|
+
- Save
|
|
66
|
+
|
|
67
|
+
**Option B: Per-agent (selective)**
|
|
68
|
+
Use `update-postcall.js` or the setup scripts with `webhook_url`:
|
|
69
|
+
```javascript
|
|
70
|
+
// in setup-agent-from.js, add before api("/create-agent"):
|
|
71
|
+
const WEBHOOK_URL = process.env.WEBHOOK_URL || null;
|
|
72
|
+
// then in the agent body:
|
|
73
|
+
webhook_url: WEBHOOK_URL,
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Test it
|
|
77
|
+
|
|
78
|
+
Place a call (any of your existing scripts), wait ~30s for analysis, check your inbox.
|
|
79
|
+
|
|
80
|
+
## Cost
|
|
81
|
+
|
|
82
|
+
- **Vercel**: free tier = 100GB-hrs/mo (plenty for webhook spikes)
|
|
83
|
+
- **Resend**: free tier = 3,000 emails/mo
|
|
84
|
+
- **Total**: $0 unless you place thousands of calls/month
|
|
85
|
+
|
|
86
|
+
## Local testing (optional)
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install
|
|
90
|
+
# mock a Retell payload
|
|
91
|
+
node -e "
|
|
92
|
+
const h = require('./api/retell-webhook.js');
|
|
93
|
+
const call = {
|
|
94
|
+
call_id: 'test',
|
|
95
|
+
retell_llm_dynamic_variables: { business_name: 'Test Co', objective: 'test' },
|
|
96
|
+
call_analysis: { call_successful: true, custom_analysis_data: { status: 'booked' } }
|
|
97
|
+
};
|
|
98
|
+
console.log(h.buildEmail(call));
|
|
99
|
+
"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
Once deployed, you have **optional proactive email** on every call — but the MCP server still works standalone (pull-on-demand from Retell when you ask in chat). Best of both.
|