supafone-labs 0.3.2 → 0.4.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/src/index.ts CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  export interface SupafoneLabsOptions {
24
24
  /** Your key from https://labs.supafone.ai/get-key.html */
25
- apiKey: string;
25
+ apiKey?: string;
26
26
  /** Override the gateway (default: the hosted cloud). */
27
27
  baseUrl?: string;
28
28
  /** Supafone app/API key for hosted agent provisioning. Defaults to apiKey. */
@@ -33,6 +33,17 @@ export interface SupafoneLabsOptions {
33
33
  timeoutMs?: number;
34
34
  /** Optional pre-obtained session token (else use login()). */
35
35
  sessionToken?: string;
36
+ /**
37
+ * Account (app.supafone.ai) auth — powers campaigns + real calls. Pass a
38
+ * JWT directly, or accountEmail + accountPassword and the client logs in
39
+ * lazily (and re-logs-in once when the token expires). With account auth
40
+ * present, apiKey becomes optional.
41
+ */
42
+ accountToken?: string;
43
+ accountEmail?: string;
44
+ accountPassword?: string;
45
+ /** Portal base for listen/monitor links (default https://app.supafone.ai). */
46
+ appUrl?: string;
36
47
  }
37
48
 
38
49
  export interface ChatMessage {
@@ -881,28 +892,44 @@ const COACH_SYSTEM =
881
892
  export class SupafoneLabs {
882
893
  readonly baseUrl: string;
883
894
  readonly supafoneApiBaseUrl: string;
895
+ readonly appUrl: string;
884
896
  private readonly apiKey: string;
885
897
  private readonly supafoneApiKey: string;
886
898
  private readonly timeoutMs: number;
887
899
  private sessionToken?: string;
900
+ private accountToken?: string;
901
+ private accountSessionToken?: string;
902
+ private readonly accountEmail?: string;
903
+ private readonly accountPassword?: string;
888
904
 
889
905
  readonly labs: LabsNamespace;
890
906
  readonly builder: BuilderNamespace;
891
907
  readonly qa: QANamespace;
892
908
  readonly optimizer: OptimizerNamespace;
909
+ readonly campaigns: CampaignsNamespace;
893
910
 
894
911
  constructor(opts: SupafoneLabsOptions) {
895
- if (!opts?.apiKey) throw new SupafoneLabsError("apiKey is required");
896
- this.apiKey = opts.apiKey;
912
+ const hasAccountAuth = !!(opts?.accountToken || (opts?.accountEmail && opts?.accountPassword));
913
+ if (!opts?.apiKey && !hasAccountAuth) {
914
+ throw new SupafoneLabsError(
915
+ "apiKey is required — or, for campaigns/calls, pass accountToken or accountEmail + accountPassword",
916
+ );
917
+ }
918
+ this.apiKey = opts.apiKey ?? "";
897
919
  this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
898
- this.supafoneApiKey = opts.supafoneApiKey ?? opts.apiKey;
920
+ this.supafoneApiKey = opts.supafoneApiKey ?? this.apiKey;
899
921
  this.supafoneApiBaseUrl = (opts.supafoneApiBaseUrl ?? DEFAULT_SUPAFONE_API_BASE).replace(/\/$/, "");
922
+ this.appUrl = (opts.appUrl ?? "https://app.supafone.ai").replace(/\/$/, "");
900
923
  this.timeoutMs = opts.timeoutMs ?? 30_000;
901
924
  this.sessionToken = opts.sessionToken;
925
+ this.accountToken = opts.accountToken;
926
+ this.accountEmail = opts.accountEmail;
927
+ this.accountPassword = opts.accountPassword;
902
928
  this.labs = new LabsNamespace(this);
903
929
  this.builder = new BuilderNamespace(this);
904
930
  this.qa = new QANamespace(this);
905
931
  this.optimizer = new OptimizerNamespace(this);
932
+ this.campaigns = new CampaignsNamespace(this);
906
933
  }
907
934
 
908
935
  /** True once login() (or a passed sessionToken) is in effect. */
@@ -967,6 +994,93 @@ export class SupafoneLabs {
967
994
  }
968
995
  }
969
996
 
997
+ /**
998
+ * Exchange the account email/password for a product-API JWT (the same login
999
+ * as app.supafone.ai). Called lazily by campaigns/calls — call directly only
1000
+ * to fail fast.
1001
+ */
1002
+ async accountLogin(email?: string, password?: string): Promise<string> {
1003
+ const useEmail = email ?? this.accountEmail;
1004
+ const usePassword = password ?? this.accountPassword;
1005
+ if (!useEmail || !usePassword) {
1006
+ throw new SupafoneLabsError(
1007
+ "Not authenticated: pass accountToken, or accountEmail + accountPassword",
1008
+ );
1009
+ }
1010
+ const body = await this.accountHttp<{ token?: string; access_token?: string }>(
1011
+ "POST",
1012
+ "/api/v1/auth/login",
1013
+ { email: useEmail, password: usePassword },
1014
+ "",
1015
+ );
1016
+ const token = body.access_token || body.token;
1017
+ if (!token) throw new SupafoneLabsError("Login succeeded but returned no token");
1018
+ this.accountSessionToken = token;
1019
+ return token;
1020
+ }
1021
+
1022
+ /**
1023
+ * @internal JSON request to the Supafone product API with the ACCOUNT JWT
1024
+ * (campaigns + real calls). A minted token that expires gets one transparent
1025
+ * re-login; an explicit accountToken is the caller's to refresh.
1026
+ */
1027
+ async requestAccountApi<T>(method: string, path: string, body?: unknown): Promise<T> {
1028
+ const token = this.accountToken || this.accountSessionToken || (await this.accountLogin());
1029
+ try {
1030
+ return await this.accountHttp<T>(method, path, body, token);
1031
+ } catch (err) {
1032
+ const expired = err instanceof SupafoneLabsError && err.status === 401;
1033
+ if (expired && !this.accountToken && this.accountEmail && this.accountPassword) {
1034
+ this.accountSessionToken = undefined;
1035
+ return this.accountHttp<T>(method, path, body, await this.accountLogin());
1036
+ }
1037
+ throw err;
1038
+ }
1039
+ }
1040
+
1041
+ /** @internal Raw product-API request with an explicit bearer ("" = none). */
1042
+ private async accountHttp<T>(method: string, path: string, body: unknown, token: string): Promise<T> {
1043
+ const ctrl = new AbortController();
1044
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
1045
+ try {
1046
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1047
+ if (token) headers.Authorization = `Bearer ${token}`;
1048
+ const res = await fetch(this.supafoneApiBaseUrl + path, {
1049
+ method,
1050
+ signal: ctrl.signal,
1051
+ headers,
1052
+ body: body === undefined ? undefined : JSON.stringify(body),
1053
+ });
1054
+ const text = await res.text();
1055
+ const parsed = text ? safeJson(text) : {};
1056
+ if (!res.ok) {
1057
+ const detail = (parsed as { detail?: string })?.detail ?? text ?? `HTTP ${res.status}`;
1058
+ throw new SupafoneLabsError(`${method} ${path}: ${detail}`, res.status, parsed);
1059
+ }
1060
+ return parsed as T;
1061
+ } finally {
1062
+ clearTimeout(timer);
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * PLACE A REAL OUTBOUND PHONE CALL: dials toNumber from the account's
1068
+ * calling provider and bridges the voice agent onto the line.
1069
+ */
1070
+ async placeCall(opts: { agentId: string; toNumber: string }): Promise<PlaceCallResult> {
1071
+ if (!opts?.agentId) throw new SupafoneLabsError("agentId is required (see listVoiceAgents())");
1072
+ if (!opts?.toNumber) throw new SupafoneLabsError("toNumber is required (E.164, e.g. +15551234567)");
1073
+ return this.requestAccountApi<PlaceCallResult>("POST", "/api/v1/phone/test-call", {
1074
+ agent_id: opts.agentId,
1075
+ to_number: opts.toNumber,
1076
+ });
1077
+ }
1078
+
1079
+ /** The account's voice agents — pick an agent id for campaigns/calls. */
1080
+ async listVoiceAgents(): Promise<{ agents: Record<string, unknown>[] }> {
1081
+ return this.requestAccountApi("GET", "/api/v1/agents");
1082
+ }
1083
+
970
1084
  /** Raw oracle completion — full control over messages and model. */
971
1085
  async oracle(req: OracleRequest): Promise<OracleResult> {
972
1086
  return this.request<OracleResult>("POST", "/v1/oracle/complete", {
@@ -1227,6 +1341,216 @@ class LiveTranscription {
1227
1341
  }
1228
1342
 
1229
1343
  /** Programmatic hosted Supafone agents, inside the Supafone API. */
1344
+ // ---------------------------------------------------------------------------
1345
+ // Campaigns — the outbound AI campaign engine the app.supafone.ai builder
1346
+ // drives, packaged. Account-JWT authenticated (accountToken / accountEmail +
1347
+ // accountPassword on the client options).
1348
+ // ---------------------------------------------------------------------------
1349
+
1350
+ export interface PlaceCallResult {
1351
+ success: boolean;
1352
+ simulated?: boolean;
1353
+ call_sid?: string | null;
1354
+ provider?: string;
1355
+ }
1356
+
1357
+ export interface CampaignRecipientInput {
1358
+ name?: string;
1359
+ phone?: string;
1360
+ email?: string;
1361
+ /** Warm-outreach consent — required before any voice/email touch. */
1362
+ outreach_consent?: string;
1363
+ [field: string]: unknown;
1364
+ }
1365
+
1366
+ export interface CampaignSummary {
1367
+ id: string;
1368
+ name: string;
1369
+ goal: string;
1370
+ status: string;
1371
+ agent_id?: string | null;
1372
+ stats?: Record<string, unknown>;
1373
+ settings?: Record<string, unknown>;
1374
+ [field: string]: unknown;
1375
+ }
1376
+
1377
+ export interface CampaignLiveCall {
1378
+ id: string;
1379
+ status: string;
1380
+ /** Portal deep link to watch this call (live transcript while in flight). */
1381
+ listen_url: string;
1382
+ [field: string]: unknown;
1383
+ }
1384
+
1385
+ export interface CampaignLiveView {
1386
+ campaign_id: string;
1387
+ in_flight: CampaignLiveCall[];
1388
+ /** Developer-portal link showing this campaign's agents/calls live. */
1389
+ portal_url: string;
1390
+ stats?: Record<string, unknown> | null;
1391
+ }
1392
+
1393
+ export interface CampaignUpdateInput {
1394
+ name?: string;
1395
+ goal?: string;
1396
+ agentId?: string;
1397
+ emailSubject?: string;
1398
+ emailBody?: string;
1399
+ cadence?: { channel: "voice" | "email"; delay_hours: number }[];
1400
+ settings?: Record<string, unknown>;
1401
+ }
1402
+
1403
+ /**
1404
+ * Typical flow:
1405
+ * ```ts
1406
+ * const sf = new Supafone({ accountEmail, accountPassword });
1407
+ * const { agents } = await sf.listVoiceAgents();
1408
+ * const { campaign } = await sf.campaigns.create({ name: "Q3 win-back", goal: "reengage", agentId: agents[0].id });
1409
+ * await sf.campaigns.applyPreset(campaign.id, "win_back");
1410
+ * await sf.campaigns.addRecipients(campaign.id, [{ name: "Jane", phone: "+15551234567", outreach_consent: "yes" }]);
1411
+ * await sf.campaigns.launch(campaign.id);
1412
+ * const live = await sf.campaigns.live(campaign.id); // in-flight calls + portal links
1413
+ * ```
1414
+ */
1415
+ class CampaignsNamespace {
1416
+ constructor(private sm: SupafoneLabs) {}
1417
+
1418
+ list(opts: { accountId?: string } = {}): Promise<{ campaigns: CampaignSummary[] }> {
1419
+ const query = opts.accountId ? `?${new URLSearchParams({ account_id: opts.accountId })}` : "";
1420
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns${query}`);
1421
+ }
1422
+
1423
+ create(opts: { name?: string; goal?: string; agentId?: string; accountId?: string } = {}): Promise<{ campaign: CampaignSummary }> {
1424
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns", compact({
1425
+ name: opts.name ?? "New campaign",
1426
+ goal: opts.goal ?? "book",
1427
+ agent_id: opts.agentId,
1428
+ account_id: opts.accountId,
1429
+ }));
1430
+ }
1431
+
1432
+ get(campaignId: string): Promise<{ campaign: CampaignSummary }> {
1433
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns/${encodeURIComponent(campaignId)}`);
1434
+ }
1435
+
1436
+ update(campaignId: string, input: CampaignUpdateInput): Promise<{ campaign: CampaignSummary }> {
1437
+ const payload = compact({
1438
+ name: input.name,
1439
+ goal: input.goal,
1440
+ agent_id: input.agentId,
1441
+ email_subject: input.emailSubject,
1442
+ email_body: input.emailBody,
1443
+ cadence: input.cadence,
1444
+ settings: input.settings,
1445
+ });
1446
+ if (!Object.keys(payload as Record<string, unknown>).length) {
1447
+ throw new SupafoneLabsError("Nothing to update — pass name, goal, agentId, emailSubject, emailBody, cadence, or settings");
1448
+ }
1449
+ return this.sm.requestAccountApi("PUT", `/api/v1/campaigns/${encodeURIComponent(campaignId)}`, payload);
1450
+ }
1451
+
1452
+ /** Add consented leads: [{name, phone, email, outreach_consent: "yes"}]. */
1453
+ addRecipients(campaignId: string, recipients: CampaignRecipientInput[]): Promise<{ added: number; stats: Record<string, unknown> }> {
1454
+ if (!Array.isArray(recipients) || !recipients.length) {
1455
+ throw new SupafoneLabsError("recipients must be a non-empty array of lead rows");
1456
+ }
1457
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/recipients`, { recipients });
1458
+ }
1459
+
1460
+ recipients(campaignId: string): Promise<{ recipients: Record<string, unknown>[] }> {
1461
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/recipients`);
1462
+ }
1463
+
1464
+ /** Starts REAL calls/emails on the cadence immediately. */
1465
+ launch(campaignId: string): Promise<{ campaign: CampaignSummary }> {
1466
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/launch`, {});
1467
+ }
1468
+
1469
+ pause(campaignId: string): Promise<{ campaign: CampaignSummary }> {
1470
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/pause`, {});
1471
+ }
1472
+
1473
+ /** Built-in playbooks + the account's saved custom presets. */
1474
+ async presets(): Promise<{ built_in: Record<string, unknown>[]; custom: Record<string, unknown>[] }> {
1475
+ const builtIn = await this.sm.requestAccountApi<{ presets: Record<string, unknown>[] }>(
1476
+ "GET",
1477
+ "/api/v1/campaigns/outbound-presets",
1478
+ );
1479
+ let custom: Record<string, unknown>[] = [];
1480
+ try {
1481
+ const mine = await this.sm.requestAccountApi<{ presets: Record<string, unknown>[] }>(
1482
+ "GET",
1483
+ "/api/v1/campaigns/custom-presets",
1484
+ );
1485
+ custom = mine.presets ?? [];
1486
+ } catch {
1487
+ /* custom presets need account scope — built-ins still return */
1488
+ }
1489
+ return { built_in: builtIn.presets ?? [], custom };
1490
+ }
1491
+
1492
+ /** Materialize a preset (goal, questions, scripts, signing doc) in one write. */
1493
+ applyPreset(campaignId: string, presetId: string): Promise<{ campaign: CampaignSummary }> {
1494
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/apply-preset`, {
1495
+ preset_id: presetId,
1496
+ });
1497
+ }
1498
+
1499
+ stats(campaignId: string): Promise<{ stats: Record<string, unknown> }> {
1500
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/stats`);
1501
+ }
1502
+
1503
+ /** The live funnel + the campaign's most recent calls (newest first). */
1504
+ activity(campaignId: string): Promise<{ stats?: Record<string, unknown>; calls?: Record<string, unknown>[] }> {
1505
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/activity`);
1506
+ }
1507
+
1508
+ /**
1509
+ * In-flight calls right now, each with a portal link to watch/listen. Poll
1510
+ * getCall(callId) (or open the link) for the transcript as it grows.
1511
+ */
1512
+ async live(campaignId: string): Promise<CampaignLiveView> {
1513
+ const activity = await this.activity(campaignId);
1514
+ const inFlight: CampaignLiveCall[] = [];
1515
+ for (const call of activity.calls ?? []) {
1516
+ const status = String((call as { status?: unknown }).status ?? "");
1517
+ if (status === "initiated" || status === "dialing" || status === "in_progress") {
1518
+ const id = String((call as { id?: unknown }).id ?? "");
1519
+ inFlight.push({
1520
+ ...(call as Record<string, unknown>),
1521
+ id,
1522
+ status,
1523
+ listen_url: `${this.sm.appUrl}/app/calls?call=${encodeURIComponent(id)}`,
1524
+ });
1525
+ }
1526
+ }
1527
+ return {
1528
+ campaign_id: campaignId,
1529
+ in_flight: inFlight,
1530
+ portal_url: `${this.sm.appUrl}/app/developer?campaign=${encodeURIComponent(campaignId)}`,
1531
+ stats: activity.stats ?? null,
1532
+ };
1533
+ }
1534
+
1535
+ /** One call — while in_progress the transcript grows on each poll. */
1536
+ getCall(callId: string): Promise<{ call: Record<string, unknown> }> {
1537
+ return this.sm.requestAccountApi("GET", `/api/v1/calls/${encodeURIComponent(callId)}`);
1538
+ }
1539
+
1540
+ /** Mint a recipient's tracked tap-to-sign link (inherits the campaign's signing PDF). */
1541
+ createSignLink(
1542
+ campaignId: string,
1543
+ recipientId: string,
1544
+ opts: { title?: string; message?: string } = {},
1545
+ ): Promise<{ link: Record<string, unknown> }> {
1546
+ return this.sm.requestAccountApi(
1547
+ "POST",
1548
+ `/api/v1/campaigns/${encodeURIComponent(campaignId)}/recipients/${encodeURIComponent(recipientId)}/sign-link`,
1549
+ compact({ title: opts.title, message: opts.message }),
1550
+ );
1551
+ }
1552
+ }
1553
+
1230
1554
  class LabsNamespace {
1231
1555
  readonly agents: LabsAgentsNamespace;
1232
1556
  readonly presets: LabsPresetsNamespace;
@@ -1656,16 +1980,6 @@ class OptimizerNamespace {
1656
1980
  standing(agent = "builder"): Promise<{ version: number; text: string }> {
1657
1981
  return this.sm.request("GET", `/v1/optimizer/standing?agent=${encodeURIComponent(agent)}`);
1658
1982
  }
1659
- /** SSR grade distribution: five nominal levels folded into a real score distribution. */
1660
- distribution(agent = "builder", limit = 500): Promise<{
1661
- agent: string; calls: number; counts: Record<string, number>;
1662
- mean_score: number; buckets: number[]; bucket_edges: number[];
1663
- }> {
1664
- return this.sm.request(
1665
- "GET",
1666
- `/v1/objective/distribution?agent=${encodeURIComponent(agent)}&limit=${limit}`,
1667
- );
1668
- }
1669
1983
  /** List the post-call reports behind the optimizer. */
1670
1984
  reports(agent = "builder", limit = 40): Promise<{ reports: unknown[] }> {
1671
1985
  return this.sm.request(