@agenticmail/core 0.9.22 → 0.9.24

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/dist/index.js CHANGED
@@ -1109,14 +1109,14 @@ var StalwartAdmin = class {
1109
1109
  if (!isValidDomain(domain)) {
1110
1110
  throw new Error(`Invalid domain format: "${domain}"`);
1111
1111
  }
1112
- const { readFileSync: readFileSync10, writeFileSync: writeFileSync11 } = await import("fs");
1113
- const { homedir: homedir13 } = await import("os");
1114
- const { join: join16 } = await import("path");
1115
- const configPath = join16(homedir13(), ".agenticmail", "stalwart.toml");
1112
+ const { readFileSync: readFileSync11, writeFileSync: writeFileSync12 } = await import("fs");
1113
+ const { homedir: homedir14 } = await import("os");
1114
+ const { join: join17 } = await import("path");
1115
+ const configPath = join17(homedir14(), ".agenticmail", "stalwart.toml");
1116
1116
  try {
1117
- let config = readFileSync10(configPath, "utf-8");
1117
+ let config = readFileSync11(configPath, "utf-8");
1118
1118
  config = config.replace(/^hostname\s*=\s*"[^"]*"/m, `hostname = "${escapeTomlString(domain)}"`);
1119
- writeFileSync11(configPath, config);
1119
+ writeFileSync12(configPath, config);
1120
1120
  console.log(`[Stalwart] Updated hostname to "${domain}" in stalwart.toml`);
1121
1121
  } catch (err) {
1122
1122
  throw new Error(`Failed to set config server.hostname=${domain}`);
@@ -1125,15 +1125,15 @@ var StalwartAdmin = class {
1125
1125
  // --- DKIM ---
1126
1126
  /** Path to the host-side stalwart.toml (mounted read-only into container) */
1127
1127
  get configPath() {
1128
- const { homedir: homedir13 } = __require("os");
1129
- const { join: join16 } = __require("path");
1130
- return join16(homedir13(), ".agenticmail", "stalwart.toml");
1128
+ const { homedir: homedir14 } = __require("os");
1129
+ const { join: join17 } = __require("path");
1130
+ return join17(homedir14(), ".agenticmail", "stalwart.toml");
1131
1131
  }
1132
1132
  /** Path to host-side DKIM key directory */
1133
1133
  get dkimDir() {
1134
- const { homedir: homedir13 } = __require("os");
1135
- const { join: join16 } = __require("path");
1136
- return join16(homedir13(), ".agenticmail");
1134
+ const { homedir: homedir14 } = __require("os");
1135
+ const { join: join17 } = __require("path");
1136
+ return join17(homedir14(), ".agenticmail");
1137
1137
  }
1138
1138
  /**
1139
1139
  * Create/reuse a DKIM signing key for a domain.
@@ -1234,12 +1234,12 @@ var StalwartAdmin = class {
1234
1234
  * This bypasses the need for a PTR record on the sending IP.
1235
1235
  */
1236
1236
  async configureOutboundRelay(config) {
1237
- const { readFileSync: readFileSync10, writeFileSync: writeFileSync11 } = await import("fs");
1238
- const { homedir: homedir13 } = await import("os");
1239
- const { join: join16 } = await import("path");
1237
+ const { readFileSync: readFileSync11, writeFileSync: writeFileSync12 } = await import("fs");
1238
+ const { homedir: homedir14 } = await import("os");
1239
+ const { join: join17 } = await import("path");
1240
1240
  const routeName = config.routeName ?? "gmail";
1241
- const tomlPath = join16(homedir13(), ".agenticmail", "stalwart.toml");
1242
- let toml = readFileSync10(tomlPath, "utf-8");
1241
+ const tomlPath = join17(homedir14(), ".agenticmail", "stalwart.toml");
1242
+ let toml = readFileSync11(tomlPath, "utf-8");
1243
1243
  toml = toml.replace(/\n\[queue\.route\.gmail\][\s\S]*?(?=\n\[|$)/, "");
1244
1244
  toml = toml.replace(/\n\[queue\.strategy\][\s\S]*?(?=\n\[|$)/, "");
1245
1245
  const safeRouteName = routeName.replace(/[^a-zA-Z0-9_-]/g, "");
@@ -1259,7 +1259,7 @@ auth.secret = "${escapeTomlString(config.password)}"
1259
1259
  route = [ { if = "is_local_domain('', rcpt_domain)", then = "'local'" },
1260
1260
  { else = "'${safeRouteName}'" } ]
1261
1261
  `;
1262
- writeFileSync11(tomlPath, toml, "utf-8");
1262
+ writeFileSync12(tomlPath, toml, "utf-8");
1263
1263
  await this.restartContainer();
1264
1264
  }
1265
1265
  };
@@ -7460,12 +7460,12 @@ var GatewayManager = class {
7460
7460
  zone = await this.cfClient.createZone(domain);
7461
7461
  }
7462
7462
  const existingRecords = await this.cfClient.listDnsRecords(zone.id);
7463
- const { homedir: homedir13 } = await import("os");
7464
- const backupDir = join4(homedir13(), ".agenticmail");
7463
+ const { homedir: homedir14 } = await import("os");
7464
+ const backupDir = join4(homedir14(), ".agenticmail");
7465
7465
  const backupPath = join4(backupDir, `dns-backup-${domain}-${Date.now()}.json`);
7466
- const { writeFileSync: writeFileSync11, mkdirSync: mkdirSync12 } = await import("fs");
7467
- mkdirSync12(backupDir, { recursive: true });
7468
- writeFileSync11(backupPath, JSON.stringify({
7466
+ const { writeFileSync: writeFileSync12, mkdirSync: mkdirSync13 } = await import("fs");
7467
+ mkdirSync13(backupDir, { recursive: true });
7468
+ writeFileSync12(backupPath, JSON.stringify({
7469
7469
  domain,
7470
7470
  zoneId: zone.id,
7471
7471
  backedUpAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -13874,6 +13874,284 @@ var AgentMemoryManager = class {
13874
13874
  };
13875
13875
  }
13876
13876
  };
13877
+
13878
+ // src/skills/registry.ts
13879
+ import { existsSync as existsSync14, mkdirSync as mkdirSync12, readFileSync as readFileSync10, readdirSync as readdirSync3, statSync as statSync3, writeFileSync as writeFileSync11 } from "fs";
13880
+ import { join as join16, dirname as dirname3, basename as basename2 } from "path";
13881
+ import { fileURLToPath } from "url";
13882
+ import { homedir as homedir13 } from "os";
13883
+ function builtInDir() {
13884
+ const here = dirname3(fileURLToPath(import.meta.url));
13885
+ return join16(here, "built-in");
13886
+ }
13887
+ function userDir() {
13888
+ const dir2 = join16(homedir13(), ".agenticmail", "skills");
13889
+ try {
13890
+ if (!existsSync14(dir2)) mkdirSync12(dir2, { recursive: true });
13891
+ } catch {
13892
+ }
13893
+ return dir2;
13894
+ }
13895
+ var cache = { ts: 0, byId: null };
13896
+ var CACHE_TTL_MS = 5e3;
13897
+ function loadAllSkillsFromDisk() {
13898
+ const all = /* @__PURE__ */ new Map();
13899
+ const dirs = [builtInDir(), userDir()];
13900
+ for (const dir2 of dirs) {
13901
+ if (!existsSync14(dir2)) continue;
13902
+ let entries;
13903
+ try {
13904
+ entries = readdirSync3(dir2);
13905
+ } catch {
13906
+ continue;
13907
+ }
13908
+ for (const entry of entries) {
13909
+ if (!entry.endsWith(".json")) continue;
13910
+ const fullPath = join16(dir2, entry);
13911
+ try {
13912
+ const st = statSync3(fullPath);
13913
+ if (!st.isFile()) continue;
13914
+ const raw = readFileSync10(fullPath, "utf-8");
13915
+ const parsed = JSON.parse(raw);
13916
+ const errors = validateSkill(parsed);
13917
+ if (errors.length > 0) {
13918
+ console.warn(`[skills] ${entry} invalid, skipping: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`);
13919
+ continue;
13920
+ }
13921
+ all.set(parsed.id, parsed);
13922
+ } catch (err) {
13923
+ console.warn(`[skills] could not load ${fullPath}: ${err.message}`);
13924
+ }
13925
+ }
13926
+ }
13927
+ return all;
13928
+ }
13929
+ function ensureLoaded() {
13930
+ const now = Date.now();
13931
+ if (cache.byId && now - cache.ts < CACHE_TTL_MS) return cache.byId;
13932
+ const fresh = loadAllSkillsFromDisk();
13933
+ cache.byId = fresh;
13934
+ cache.ts = now;
13935
+ return fresh;
13936
+ }
13937
+ function invalidateSkillCache() {
13938
+ cache.byId = null;
13939
+ cache.ts = 0;
13940
+ }
13941
+ function validateSkill(s) {
13942
+ const errs = [];
13943
+ if (!s || typeof s !== "object" || Array.isArray(s)) {
13944
+ return [{ path: "$", message: "skill must be a JSON object" }];
13945
+ }
13946
+ const sk = s;
13947
+ const requireString = (key, minLen = 1) => {
13948
+ const v = sk[key];
13949
+ if (typeof v !== "string" || v.length < minLen) {
13950
+ errs.push({ path: key, message: `must be a non-empty string` });
13951
+ }
13952
+ };
13953
+ const requireArray = (key, minLen = 1) => {
13954
+ const v = sk[key];
13955
+ if (!Array.isArray(v) || v.length < minLen) {
13956
+ errs.push({ path: key, message: `must be a non-empty array` });
13957
+ }
13958
+ };
13959
+ requireString("id");
13960
+ if (typeof sk.id === "string" && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(sk.id)) {
13961
+ errs.push({ path: "id", message: "must be lowercase-hyphenated slug (a-z, 0-9, -)" });
13962
+ }
13963
+ requireString("name");
13964
+ requireString("version");
13965
+ requireString("description");
13966
+ requireString("category");
13967
+ const validCategories = [
13968
+ "negotiation",
13969
+ "customer-service",
13970
+ "reservations",
13971
+ "medical-admin",
13972
+ "legal-admin",
13973
+ "finance-admin",
13974
+ "real-estate",
13975
+ "travel",
13976
+ "subscription",
13977
+ "home-services",
13978
+ "social",
13979
+ "civic",
13980
+ "employment",
13981
+ "debt-collection",
13982
+ "other"
13983
+ ];
13984
+ if (typeof sk.category === "string" && !validCategories.includes(sk.category)) {
13985
+ errs.push({ path: "category", message: `unknown category "${sk.category}"; must be one of: ${validCategories.join(", ")}` });
13986
+ }
13987
+ if (!Array.isArray(sk.tags)) errs.push({ path: "tags", message: "must be an array of strings" });
13988
+ if (sk.disclaimer !== null && typeof sk.disclaimer !== "string") {
13989
+ errs.push({ path: "disclaimer", message: "must be a string or null" });
13990
+ }
13991
+ if (!sk.context || typeof sk.context !== "object") {
13992
+ errs.push({ path: "context", message: "must be an object" });
13993
+ } else {
13994
+ const ctx = sk.context;
13995
+ if (typeof ctx.when_to_use !== "string") errs.push({ path: "context.when_to_use", message: "must be a string" });
13996
+ if (!Array.isArray(ctx.preconditions)) errs.push({ path: "context.preconditions", message: "must be an array" });
13997
+ if (typeof ctx.estimated_call_duration_minutes !== "number") errs.push({ path: "context.estimated_call_duration_minutes", message: "must be a number" });
13998
+ }
13999
+ requireArray("principles", 2);
14000
+ if (!sk.phrases || typeof sk.phrases !== "object") errs.push({ path: "phrases", message: "must be an object of {key: phrase}" });
14001
+ if (!Array.isArray(sk.tactics) || sk.tactics.length === 0) {
14002
+ errs.push({ path: "tactics", message: "must be a non-empty array" });
14003
+ } else {
14004
+ sk.tactics.forEach((t, i) => {
14005
+ if (!t || typeof t !== "object") {
14006
+ errs.push({ path: `tactics[${i}]`, message: "must be an object" });
14007
+ return;
14008
+ }
14009
+ const tactic = t;
14010
+ if (typeof tactic.name !== "string") errs.push({ path: `tactics[${i}].name`, message: "must be a string" });
14011
+ if (typeof tactic.when !== "string") errs.push({ path: `tactics[${i}].when`, message: "must be a string" });
14012
+ if (typeof tactic.script !== "string" || tactic.script.length < 5) {
14013
+ errs.push({ path: `tactics[${i}].script`, message: "must be a non-empty string (>= 5 chars)" });
14014
+ }
14015
+ });
14016
+ }
14017
+ requireArray("boundaries", 1);
14018
+ if (!Array.isArray(sk.success_signals)) errs.push({ path: "success_signals", message: "must be an array" });
14019
+ if (!Array.isArray(sk.failure_signals)) errs.push({ path: "failure_signals", message: "must be an array" });
14020
+ if (!sk.exit_strategy || typeof sk.exit_strategy !== "object") {
14021
+ errs.push({ path: "exit_strategy", message: "must be an object" });
14022
+ } else {
14023
+ const xs = sk.exit_strategy;
14024
+ if (typeof xs.on_success !== "string") errs.push({ path: "exit_strategy.on_success", message: "must be a string" });
14025
+ if (typeof xs.on_failure !== "string") errs.push({ path: "exit_strategy.on_failure", message: "must be a string" });
14026
+ }
14027
+ if (!Array.isArray(sk.required_user_info)) errs.push({ path: "required_user_info", message: "must be an array" });
14028
+ if (typeof sk.contributed_by !== "string") errs.push({ path: "contributed_by", message: "must be a string" });
14029
+ return errs;
14030
+ }
14031
+ function summarize(s) {
14032
+ return {
14033
+ id: s.id,
14034
+ name: s.name,
14035
+ category: s.category,
14036
+ tags: s.tags,
14037
+ description: s.description,
14038
+ version: s.version,
14039
+ disclaimer_required: !!s.disclaimer,
14040
+ estimated_call_duration_minutes: s.context.estimated_call_duration_minutes
14041
+ };
14042
+ }
14043
+ function listSkills(opts = {}) {
14044
+ const all = Array.from(ensureLoaded().values());
14045
+ const filtered = all.filter((s) => {
14046
+ if (opts.category && s.category !== opts.category) return false;
14047
+ if (opts.tag && !s.tags.includes(opts.tag.toLowerCase())) return false;
14048
+ return true;
14049
+ });
14050
+ return filtered.sort((a, b) => a.name.localeCompare(b.name)).map(summarize);
14051
+ }
14052
+ function searchSkills(query, limit = 20) {
14053
+ const q = query.toLowerCase().trim();
14054
+ if (!q) return listSkills();
14055
+ const all = Array.from(ensureLoaded().values());
14056
+ const scored = [];
14057
+ for (const s of all) {
14058
+ let score = 0;
14059
+ if (s.id.toLowerCase().includes(q)) score += 10;
14060
+ if (s.name.toLowerCase().includes(q)) score += 8;
14061
+ if (s.tags.some((t) => t.toLowerCase().includes(q))) score += 5;
14062
+ if (s.category.toLowerCase().includes(q)) score += 4;
14063
+ if (s.description.toLowerCase().includes(q)) score += 3;
14064
+ if (s.principles.some((p) => p.toLowerCase().includes(q))) score += 2;
14065
+ if (Object.values(s.phrases).some((p) => p.toLowerCase().includes(q))) score += 2;
14066
+ if (s.tactics.some((t) => t.script.toLowerCase().includes(q) || t.name.toLowerCase().includes(q))) score += 2;
14067
+ if (score > 0) scored.push({ skill: s, score });
14068
+ }
14069
+ scored.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
14070
+ return scored.slice(0, limit).map((x) => summarize(x.skill));
14071
+ }
14072
+ function loadSkill(id) {
14073
+ return ensureLoaded().get(id) ?? null;
14074
+ }
14075
+ function saveUserSkill(skill) {
14076
+ const errors = validateSkill(skill);
14077
+ if (errors.length > 0) {
14078
+ throw new Error(`skill validation failed: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`);
14079
+ }
14080
+ const dir2 = userDir();
14081
+ const path2 = join16(dir2, `${skill.id}.json`);
14082
+ const now = (/* @__PURE__ */ new Date()).toISOString();
14083
+ const out = {
14084
+ ...skill,
14085
+ created_at: skill.created_at ?? now,
14086
+ updated_at: now
14087
+ };
14088
+ writeFileSync11(path2, JSON.stringify(out, null, 2), "utf-8");
14089
+ invalidateSkillCache();
14090
+ return { path: path2 };
14091
+ }
14092
+ function userSkillsDir() {
14093
+ return userDir();
14094
+ }
14095
+
14096
+ // src/skills/index.ts
14097
+ function renderSkillAsPrompt(skill) {
14098
+ const lines = [];
14099
+ lines.push(`=== SKILL LOADED: ${skill.name} (v${skill.version}) ===`);
14100
+ lines.push(`Category: ${skill.category} Tags: ${skill.tags.join(", ")}`);
14101
+ lines.push("");
14102
+ lines.push(skill.description);
14103
+ lines.push("");
14104
+ if (skill.disclaimer) {
14105
+ lines.push("REQUIRED DISCLAIMER (recite at start of the substantive turn):");
14106
+ lines.push(` "${skill.disclaimer}"`);
14107
+ lines.push("");
14108
+ }
14109
+ lines.push("WHEN TO USE THIS:");
14110
+ lines.push(` ${skill.context.when_to_use}`);
14111
+ if (skill.context.preconditions.length > 0) {
14112
+ lines.push("Preconditions:");
14113
+ for (const p of skill.context.preconditions) lines.push(` - ${p}`);
14114
+ }
14115
+ lines.push("");
14116
+ lines.push("PRINCIPLES:");
14117
+ for (const p of skill.principles) lines.push(` - ${p}`);
14118
+ lines.push("");
14119
+ if (Object.keys(skill.phrases).length > 0) {
14120
+ lines.push("PHRASES (paraphrase to match your voice; keep the structural move):");
14121
+ for (const [k, v] of Object.entries(skill.phrases)) lines.push(` [${k}] "${v}"`);
14122
+ lines.push("");
14123
+ }
14124
+ if (skill.tactics.length > 0) {
14125
+ lines.push("TACTICS (try in order; fall back to next on failure):");
14126
+ skill.tactics.forEach((t, i) => {
14127
+ lines.push(` ${i + 1}. ${t.name}`);
14128
+ lines.push(` When: ${t.when}`);
14129
+ lines.push(` Script: "${t.script}"`);
14130
+ });
14131
+ lines.push("");
14132
+ }
14133
+ if (skill.boundaries.length > 0) {
14134
+ lines.push("HARD BOUNDARIES \u2014 do not cross:");
14135
+ for (const b of skill.boundaries) lines.push(` - ${b}`);
14136
+ lines.push("");
14137
+ }
14138
+ lines.push("SUCCESS SIGNALS:");
14139
+ for (const s of skill.success_signals) lines.push(` - ${s}`);
14140
+ lines.push("");
14141
+ lines.push("FAILURE SIGNALS \u2014 when these appear, escalate or exit:");
14142
+ for (const s of skill.failure_signals) lines.push(` - ${s}`);
14143
+ lines.push("");
14144
+ lines.push("EXIT:");
14145
+ lines.push(` On success: ${skill.exit_strategy.on_success}`);
14146
+ lines.push(` On failure: ${skill.exit_strategy.on_failure}`);
14147
+ if (skill.exit_strategy.follow_ups && skill.exit_strategy.follow_ups.length > 0) {
14148
+ lines.push(" Follow-ups (after the call):");
14149
+ for (const f of skill.exit_strategy.follow_ups) lines.push(` - ${f}`);
14150
+ }
14151
+ lines.push("");
14152
+ lines.push("=== END SKILL ===");
14153
+ return lines.join("\n");
14154
+ }
13877
14155
  export {
13878
14156
  AGENT_ROLES,
13879
14157
  ASK_OPERATOR_TOOL,
@@ -14018,6 +14296,7 @@ export {
14018
14296
  getTelegramWebhookInfo,
14019
14297
  hostSessionStoragePath,
14020
14298
  inferPhoneRegion,
14299
+ invalidateSkillCache,
14021
14300
  isInternalEmail,
14022
14301
  isLoopbackMailHost,
14023
14302
  isOperatorReplySender,
@@ -14026,7 +14305,9 @@ export {
14026
14305
  isTelegramChatAllowed,
14027
14306
  isTelegramStopCommand,
14028
14307
  isValidPhoneNumber,
14308
+ listSkills,
14029
14309
  loadHostSession,
14310
+ loadSkill,
14030
14311
  mapProviderSmsStatus,
14031
14312
  nextTelegramOffset,
14032
14313
  normalizeAddress,
@@ -14051,6 +14332,7 @@ export {
14051
14332
  redactSecret,
14052
14333
  redactSmsConfig,
14053
14334
  redactTelegramConfig,
14335
+ renderSkillAsPrompt,
14054
14336
  requireBinary,
14055
14337
  requireWhisperModel,
14056
14338
  resolveConfig,
@@ -14059,8 +14341,10 @@ export {
14059
14341
  sanitizeEmail,
14060
14342
  saveConfig,
14061
14343
  saveHostSession,
14344
+ saveUserSkill,
14062
14345
  scanOutboundEmail,
14063
14346
  scoreEmail,
14347
+ searchSkills,
14064
14348
  sendTelegramMessage,
14065
14349
  setOperatorEmail,
14066
14350
  setTelegramWebhook,
@@ -14073,10 +14357,12 @@ export {
14073
14357
  threadIdFor,
14074
14358
  tokenize,
14075
14359
  tryJoin,
14360
+ userSkillsDir,
14076
14361
  validateApiUrl,
14077
14362
  validatePhoneMissionPolicy,
14078
14363
  validatePhoneMissionStart,
14079
14364
  validatePhoneTransportProfile,
14365
+ validateSkill,
14080
14366
  validateTwilioSignature,
14081
14367
  webSearch
14082
14368
  };
@@ -0,0 +1,111 @@
1
+ {
2
+ "id": "airline-change-or-refund",
3
+ "name": "Change or Refund an Airline Reservation",
4
+ "version": "1.0.0",
5
+ "category": "travel",
6
+ "tags": ["airline", "flight", "refund", "rebook", "travel-disruption"],
7
+ "description": "Call an airline to change a booking, request a refund, or rebook around a cancelled / delayed flight. Surface fare rules, fee waivers, and travel credits that don't show in the app.",
8
+ "disclaimer": null,
9
+ "context": {
10
+ "when_to_use": "User needs to change a flight, get refunded for a cancelled flight, or rebook after a disruption. Use this when the airline's app / website doesn't expose the option the user needs, or when a fee waiver is plausible (weather, schedule change > 1 hour, illness with documentation, bereavement).",
11
+ "preconditions": [
12
+ "Confirmation number / record locator (6 letters and numbers).",
13
+ "Passenger's full name as shown on the ticket (matches photo ID).",
14
+ "Loyalty number, if any.",
15
+ "Original itinerary + the desired new itinerary (date / time / cities).",
16
+ "Reason for the change (schedule change, weather, personal — affects which waivers apply).",
17
+ "Whether the user wants a refund, travel credit, or a rebooked itinerary."
18
+ ],
19
+ "estimated_call_duration_minutes": 30
20
+ },
21
+ "principles": [
22
+ "Loyalty status matters. Lead with the elite number (Platinum, 1K, etc) if the user has one — different reps, different rules.",
23
+ "If the AIRLINE caused the disruption (cancellation, schedule change > 1 hour, equipment swap), the rules are MUCH more in the user's favour. Lead with the disruption fact.",
24
+ "Travel credits and refunds are different. A 'refund' goes back to the original payment; a 'travel credit' is airline-only money with an expiry. Be explicit about which you're asking for.",
25
+ "Fee waivers are sometimes discretionary — a calm, polite request from a long-tenured customer gets them more often than a demanding one.",
26
+ "Schedule change protections (DOT rules for US flights with significant schedule changes) often entitle a refund EVEN ON A NON-REFUNDABLE FARE. Don't accept 'non-refundable' as final.",
27
+ "If the first rep can't help, hold for a supervisor or call back later (different rep, different mood, sometimes different answer)."
28
+ ],
29
+ "phrases": {
30
+ "opener_with_status": "Hi, I'm calling about confirmation [PNR] for [passenger name]. I'm a [status] member — number [loyalty]. I need to [change / refund / rebook] this trip.",
31
+ "opener_without_status": "Hi, I'm calling about confirmation [PNR] for [passenger name]. I need to [change / refund / rebook] this trip.",
32
+ "lead_with_disruption": "Before we look at fees — this is because of [airline's schedule change / cancellation / equipment swap / weather waiver]. Can we look at change-fee or refund options under that policy first?",
33
+ "request_specific_rebook": "I'd like to move to [airline] flight [number] on [date] departing [time] — same routing. What's the difference in fare?",
34
+ "request_refund": "Given [reason], I'd like to refund the unused portion to the original form of payment. What's the refund amount and the processing time?",
35
+ "ask_credit_alternative": "If a refund isn't possible, what's the travel-credit option — value, expiry date, transferable?",
36
+ "ask_fee_waiver": "Is there any way to waive the change fee? I'm a long-time customer and this trip is for [brief reason].",
37
+ "stall_thinking": "Let me think about that for one second.",
38
+ "stall_research": "Hold on, I want to confirm something with the passenger before I commit.",
39
+ "graceful_close": "Thank you. To confirm: [new itinerary], confirmation [number], total [amount] charged or refund of [amount] to [method] arriving in [days]. I'll watch for the email."
40
+ },
41
+ "tactics": [
42
+ {
43
+ "name": "Lead with PNR + loyalty status",
44
+ "when": "First substantive turn.",
45
+ "script": "Use `opener_with_status` if the user has any elite status (even bronze). Status changes the rep's screen colour — they treat the call differently.",
46
+ "priority": 1
47
+ },
48
+ {
49
+ "name": "Frame the call as a disruption claim, not a customer change",
50
+ "when": "The airline caused (or contributed to) the change.",
51
+ "script": "Use `lead_with_disruption`. Cancellations, schedule changes > 60 min (for US carriers), equipment swaps that affect seat assignments, all unlock different waivers."
52
+ },
53
+ {
54
+ "name": "Ask for refund first, accept credit as fallback",
55
+ "when": "User has a clear preference for refund.",
56
+ "script": "Use `request_refund` first. If denied, use `ask_credit_alternative`. Credits should be accepted only if the user pre-authorised credits-OK."
57
+ },
58
+ {
59
+ "name": "Politely ask for a fee waiver",
60
+ "when": "A change fee is being quoted on a personal change (not a disruption).",
61
+ "script": "Use `ask_fee_waiver`. Don't make it a demand — make it a question with a tone of 'help me out if you can'."
62
+ },
63
+ {
64
+ "name": "Escalate to a supervisor",
65
+ "when": "Front-line rep says no on something a supervisor might say yes to (fee waiver, fare-difference reduction, specific seat reassignment).",
66
+ "script": "Polite: 'I appreciate you checking. Could we get a supervisor's read on this? Not because you've done anything wrong — I just want to make sure we've explored the policies before I commit.'"
67
+ },
68
+ {
69
+ "name": "Hang up and call back",
70
+ "when": "Call has gone in circles and the supervisor also said no.",
71
+ "script": "End politely, wait an hour, call back. Different rep, sometimes different answer. This is a known industry quirk; don't rage about it, just try again."
72
+ }
73
+ ],
74
+ "boundaries": [
75
+ "Do NOT commit to a fare difference > $X without explicit user authorisation. Use `ask_operator` for big numbers.",
76
+ "Do NOT accept a travel credit instead of a refund unless the user pre-authorised credit-OK.",
77
+ "Do NOT agree to non-refundable rules being final on a disruption-caused change — push back politely.",
78
+ "Do NOT share the user's full credit card on a recorded line if avoidable; the airline usually has the card on file.",
79
+ "Do NOT make up reasons to claim a waiver (bereavement, illness) — if the user doesn't have actual documentation, don't use those framings."
80
+ ],
81
+ "success_signals": [
82
+ "Rep waives a change fee.",
83
+ "Rep offers a refund to the original payment method.",
84
+ "Rep finds a flight in the user's preferred window.",
85
+ "Rep notes elite-status protections that exceed the base fare's rules."
86
+ ],
87
+ "failure_signals": [
88
+ "Rep + supervisor both decline waiver requests on a disruption-caused change (try calling back).",
89
+ "Rep can find no flight in the user's window for any price the user pre-authorised.",
90
+ "Refund processing time quoted at > 60 days (probably wrong — escalate)."
91
+ ],
92
+ "exit_strategy": {
93
+ "on_success": "Read back: new itinerary (flight numbers, dates, times, routing), confirmation number, total charged or refund amount + method + processing time. Thank the rep. Confirm email is coming.",
94
+ "on_failure": "Note exactly what was offered and what was refused. Thank the rep. Report back so the user can decide to call back, file a DOT complaint (US), or accept the loss.",
95
+ "follow_ups": [
96
+ "Email the user: new itinerary, confirmation number, refund / credit details, processing time.",
97
+ "Add the new flights to the user's calendar.",
98
+ "If a credit was issued, calendar the expiry date.",
99
+ "If a refund was promised, calendar a 14-day check that it actually arrived."
100
+ ]
101
+ },
102
+ "required_user_info": [
103
+ "Confirmation number (PNR) + passenger name as on the ticket",
104
+ "Loyalty number + status, if any",
105
+ "Original itinerary + desired new itinerary",
106
+ "Reason for the change (disruption, personal, etc)",
107
+ "Outcome preference: refund / credit / rebook (with max fare-difference if rebook)",
108
+ "Max fare difference the user will authorise without callback"
109
+ ],
110
+ "contributed_by": "AgenticMail core team"
111
+ }
@@ -0,0 +1,99 @@
1
+ {
2
+ "id": "book-medical-appointment",
3
+ "name": "Book a Medical / Dental Appointment",
4
+ "version": "1.0.0",
5
+ "category": "medical-admin",
6
+ "tags": ["doctor", "dentist", "appointment", "scheduling", "insurance"],
7
+ "description": "Call a medical or dental office to book an appointment, verify they take the operator's insurance, capture the prep instructions, and confirm the time + location.",
8
+ "disclaimer": "I'm an AI assistant calling on behalf of [patient name]. I'm not a medical professional and cannot answer clinical questions — those will be addressed by the patient directly at the appointment.",
9
+ "context": {
10
+ "when_to_use": "User needs to book a routine appointment (cleaning, annual physical, follow-up visit, vaccination, etc). Not for symptom triage or emergencies — for those, the user should call directly or use the office's nurse line.",
11
+ "preconditions": [
12
+ "Patient's full name, date of birth, and insurance information (carrier + member ID + group number).",
13
+ "Reason for visit in plain language ('annual cleaning', 'follow-up on lab results', 'new-patient visit').",
14
+ "Date / time preferences with a flex window.",
15
+ "Existing patient or new patient? (Affects intake time required.)"
16
+ ],
17
+ "estimated_call_duration_minutes": 10
18
+ },
19
+ "principles": [
20
+ "Lead with the reason for visit and the patient's status (existing vs new). Front-desk staff route the call entirely based on those two facts.",
21
+ "Verify insurance BEFORE confirming the appointment. 'Do you take [carrier]?' is a free question; finding out at the appointment that they don't is expensive.",
22
+ "Get every detail in writing if possible — confirmation email, text message, or patient portal entry. Verbal-only confirmations are forgotten by reception.",
23
+ "Ask for prep instructions (fasting, paperwork, ID, insurance card) — these are easy to forget and embarrassing to discover at check-in.",
24
+ "Do NOT discuss symptoms or clinical details. You're an AI; the patient will discuss medical specifics at the appointment."
25
+ ],
26
+ "phrases": {
27
+ "open_disclaimer": "I'm an AI assistant calling on behalf of [patient name]. I'm not a medical professional, so I won't be able to discuss symptoms or clinical details — but I can handle scheduling.",
28
+ "opener_existing": "I'd like to book an appointment for [patient name] for [reason]. The date of birth on file is [DOB]. Existing patient.",
29
+ "opener_new": "I'd like to book a new-patient appointment for [patient name] for [reason]. They've never been seen at this practice.",
30
+ "verify_insurance": "Before we lock anything in — can you confirm you accept [insurance carrier], plan name [plan]? Member ID is [ID].",
31
+ "ask_prep": "What should the patient bring or do to prepare? Anything to bring (ID, insurance card, prior records)? Any fasting or medication restrictions?",
32
+ "ask_confirmation_method": "Will you send a text or email confirmation? What's the best way to confirm we're on the schedule?",
33
+ "request_callback_number": "And the best number to reach the patient if anything changes is [phone]. Could you note that on the file?",
34
+ "graceful_close_success": "Thank you so much. So we're set for [day] at [time] at [location] — and [prep summary]. Have a good day.",
35
+ "graceful_close_no_availability": "I understand. I'll talk to [patient name] and call back to look at later dates. Thanks for checking."
36
+ },
37
+ "tactics": [
38
+ {
39
+ "name": "Disclaimer + reason for visit + patient status",
40
+ "when": "First substantive turn.",
41
+ "script": "Lead with the disclaimer (`open_disclaimer`), then immediately give reason + existing/new status (`opener_existing` or `opener_new`). Saves the receptionist from extracting it.",
42
+ "priority": 1
43
+ },
44
+ {
45
+ "name": "Verify insurance before booking",
46
+ "when": "Receptionist offers a time slot.",
47
+ "script": "Use `verify_insurance` BEFORE accepting the slot. If they don't take the insurance, ask if they're in-network with any other carriers the patient might have, then exit politely if not."
48
+ },
49
+ {
50
+ "name": "Get prep instructions before hanging up",
51
+ "when": "Appointment is confirmed.",
52
+ "script": "Use `ask_prep`. Common items: photo ID, insurance card, list of medications, fasting (lab work), arrive 15 min early for paperwork (new patient)."
53
+ },
54
+ {
55
+ "name": "Get the confirmation method in writing",
56
+ "when": "Receptionist is wrapping up.",
57
+ "script": "Use `ask_confirmation_method`. Some offices use a portal, some text, some don't confirm at all (in which case set a reminder yourself)."
58
+ },
59
+ {
60
+ "name": "Read back the full appointment details",
61
+ "when": "Before ending the call.",
62
+ "script": "Patient's name + date + time + location + provider name + prep summary. Receptionists appreciate this — it catches their typos, not yours."
63
+ }
64
+ ],
65
+ "boundaries": [
66
+ "Do NOT discuss the patient's symptoms, medical history, or any clinical information. You're a scheduler, not a clinician.",
67
+ "Do NOT share the patient's full SSN, even if asked — DOB + insurance ID is enough for routine scheduling.",
68
+ "Do NOT commit to an appointment without verifying insurance (unless the patient is paying cash and the office is informed).",
69
+ "Do NOT book without explicit slot acceptance — if multiple slots are offered, pick the user-pre-stated preferred one; if none match, ask the user via `ask_operator`."
70
+ ],
71
+ "success_signals": [
72
+ "Office confirms insurance acceptance.",
73
+ "Office confirms appointment with a date, time, location, and provider name.",
74
+ "Office confirms a method for written confirmation."
75
+ ],
76
+ "failure_signals": [
77
+ "Office doesn't take the insurance.",
78
+ "Office has no availability in the user's window.",
79
+ "Office requires a referral the patient doesn't have."
80
+ ],
81
+ "exit_strategy": {
82
+ "on_success": "Read back date / time / location / provider / prep. Thank the receptionist. Set a calendar event for the patient.",
83
+ "on_failure": "Note the obstacle (no in-network, no availability, referral required). Thank the receptionist. Report back so the operator can pick a different provider or get the referral first.",
84
+ "follow_ups": [
85
+ "Email or message the operator: date, time, location, provider, prep list, confirmation number / portal entry.",
86
+ "Calendar the appointment.",
87
+ "Calendar a 24-hour reminder."
88
+ ]
89
+ },
90
+ "required_user_info": [
91
+ "Patient's full name + date of birth",
92
+ "Insurance carrier, member ID, group number, plan name",
93
+ "Reason for visit (plain language)",
94
+ "Existing patient or new patient",
95
+ "Date / time preferences + flex window",
96
+ "Best callback phone number for the patient"
97
+ ],
98
+ "contributed_by": "AgenticMail core team"
99
+ }