@voicelayer/sdk 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,23 @@ That's what this SDK does. One declaration:
44
44
 
45
45
  ```ts
46
46
  import { defineAgent, defineProcess } from '@voicelayer/sdk';
47
- import { salesforce } from '@voicelayer/connector-salesforce';
47
+
48
+ // A connector is any object with this shape — write one for whatever system
49
+ // you already use. Its methods land on `ctx.connectors.<name>`.
50
+ const crm = {
51
+ __connector: true as const,
52
+ name: 'crm',
53
+ api: {
54
+ createClaim: async (data: Record<string, unknown>) => {
55
+ const res = await fetch('https://your-crm.example.com/claims', {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json' },
58
+ body: JSON.stringify(data),
59
+ });
60
+ return (await res.json()) as { id: string };
61
+ },
62
+ },
63
+ };
48
64
 
49
65
  export default await defineAgent({
50
66
  name: 'fnol',
@@ -62,7 +78,7 @@ export default await defineAgent({
62
78
  },
63
79
  completeWhen: 'all-required-captured',
64
80
  onComplete: async (data, ctx) => {
65
- const claim = await ctx.connectors.salesforce.createClaim(data);
81
+ const claim = await ctx.connectors.crm.createClaim(data);
66
82
  await ctx.say(`Your claim number is ${claim.id}. You will receive a text shortly.`);
67
83
  },
68
84
  }),
@@ -87,10 +103,8 @@ export default await defineAgent({
87
103
  piiFields: ['ssn', 'dob'],
88
104
  },
89
105
 
90
- // 5. Connectors — auto-wired tools, available to the LLM and in ctx.
91
- connectors: {
92
- salesforce: salesforce({ objects: ['Claim', 'Account'] }),
93
- },
106
+ // 5. Connectors — available on ctx, and as LLM tools when `expose: true`.
107
+ connectors: { crm },
94
108
 
95
109
  // 6. Compliance — opt-in posture, defaults are safe.
96
110
  compliance: ['tcpa'],
@@ -131,10 +145,13 @@ defineAgent({
131
145
  if (text.includes('refund')) ctx.metadata.flagged = true;
132
146
  },
133
147
  async onFieldCaptured(field, value, ctx) {
134
- if (field === 'claimNumber') await ctx.connectors.salesforce.lookup(value);
148
+ if (field === 'claimNumber') await ctx.connectors.crm.lookup(value);
135
149
  },
136
150
  async onCallEnd(outcome, ctx) {
137
- await ctx.connectors.slack.send(`#claims`, `Call ${ctx.call.id} ended: ${outcome.reason}`);
151
+ await fetch('https://your-api.example.com/call-ended', {
152
+ method: 'POST',
153
+ body: JSON.stringify({ callId: ctx.call.id, reason: outcome.reason }),
154
+ });
138
155
  },
139
156
  });
140
157
  ```
@@ -148,7 +165,7 @@ defineAgent({
148
165
  // You own the entire conversation loop.
149
166
  await ctx.say('Hi! What is your policy number?');
150
167
  const policy = await ctx.ask();
151
- const account = await ctx.connectors.salesforce.lookupPolicy(policy);
168
+ const account = await ctx.connectors.crm.lookupPolicy(policy);
152
169
  // ... do whatever ...
153
170
  },
154
171
  });
@@ -240,50 +257,53 @@ export default await defineAgent({
240
257
 
241
258
  Works with OpenAI, Azure, Together, Groq, vLLM, or a [LiteLLM](https://docs.litellm.ai) proxy in front of anything. The URL is `https`-only and SSRF-guarded; under the hood it reuses the proven LiveKit OpenAI client.
242
259
 
243
- **3. A private endpoint (tunnel)** — keep your brain behind your firewall. Run the one-container `vl-brain` daemon — it dials *out* to us (no inbound ports) and LiteLLM is baked in to normalize any provider. The connector then shows up as a voice agent in the dashboard (its LLM is your brain; STT/TTS still swappable) — test it in the Playground, attach a number. No SDK code required. See the [`vl-brain` daemon guide](../../apps/connector-daemon/README.md).
260
+ **3. A private endpoint (tunnel)** — keep your brain behind your firewall. Run the one-container `vl-brain` daemon — it dials *out* to us (no inbound ports) and LiteLLM is baked in to normalize any provider. The connector then shows up as a voice agent in the dashboard (its LLM is your brain; STT/TTS still swappable) — test it in the Playground, attach a number. No SDK code required. See the [`vl-brain` daemon guide](https://vlayers.ai/docs/brain-connectors).
244
261
 
245
262
  ---
246
263
 
247
264
  ## Connectors
248
265
 
249
- Connectors are **typed integrations** with external systems. They show up on `ctx.connectors` and (optionally) as LLM tools.
266
+ Connectors are **typed integrations** with external systems. They show up on `ctx.connectors` and, when `expose: true`, every method also becomes an LLM-callable tool automatically.
267
+
268
+ There is no connector registry to install from — a connector is a plain object, so you write one against whatever API you already have:
250
269
 
251
270
  ```ts
252
- import { salesforce, twilio, slack, zendesk, webhook } from '@voicelayer/connectors';
271
+ import { defineAgent, type ConnectorInstance } from '@voicelayer/sdk';
272
+
273
+ const crm = {
274
+ __connector: true as const,
275
+ name: 'crm',
276
+ // With `expose: true`, the LLM can call `crm_lookupPolicy` on its own.
277
+ expose: true,
278
+ api: {
279
+ lookupPolicy: async ({ policyNumber }: { policyNumber: string }) => {
280
+ const res = await fetch(`https://your-crm.example.com/policies/${policyNumber}`, {
281
+ headers: { authorization: `Bearer ${process.env.CRM_TOKEN}` },
282
+ });
283
+ return res.json();
284
+ },
285
+ },
286
+ } satisfies ConnectorInstance;
253
287
 
254
288
  defineAgent({
255
289
  // ...
256
- connectors: {
257
- crm: salesforce({ objects: ['Claim'] }),
258
- sms: twilio(),
259
- alerts: slack({ channel: '#claims' }),
260
- custom: webhook({ url: 'https://your-api.example.com/voicelayer' }),
261
- },
290
+ connectors: { crm },
262
291
  });
263
292
  ```
264
293
 
265
- Available connectors: `salesforce`, `twilio`, `slack`, `zendesk`, `webhook`, `epic` (HIPAA-scoped), `servicenow`. More on the roadmap.
294
+ Each method receives `(args, ctx)`, so you can reach call state from inside a connector. When a `ToolRouter` is active, exposed connector methods are dispatched through Action Guard and audited like any other tool.
266
295
 
267
296
  ---
268
297
 
269
- ## Testing locally
298
+ ## Running locally
270
299
 
271
- ```ts
272
- import { testAgent } from '@voicelayer/sdk/testing';
273
- import agent from './agent.js';
274
-
275
- test('captures all required fields', async () => {
276
- const result = await testAgent(agent)
277
- .say('I was in a wreck yesterday with my Honda Civic, no injuries.')
278
- .say('Claim number is AB-12345678.')
279
- .end();
280
-
281
- expect(result.process.complete).toBe(true);
282
- expect(result.process.data.vehiclesInvolved).toEqual(['Honda Civic']);
283
- });
300
+ ```bash
301
+ node --import tsx agent.ts dev
284
302
  ```
285
303
 
286
- No LiveKit, no audio, no API keys. Pure unit-test speed.
304
+ This boots the worker against your LiveKit project and registers the agent with the VoiceLayer control plane, so you can call your number and iterate on the same file you will deploy. Set the environment variables listed under [Production](#production) first.
305
+
306
+ > A headless harness for asserting on process capture without audio is not part of the public API yet. Today, iterate by calling the agent, or drive the flow from the Playground in the dashboard.
287
307
 
288
308
  ---
289
309
 
@@ -329,6 +349,7 @@ At `1.0` we'll commit to semver and a stable public surface. Until then, expect
329
349
 
330
350
  ## Reference
331
351
 
332
- - API: [`docs/API.md`](../../docs/API.md)
333
- - Architecture (what's underneath): [`docs/architecture.md`](../../docs/architecture.md)
334
- - Migration from 0.x → 0.next: [`CHANGELOG.md`](./CHANGELOG.md)
352
+ - [SDK documentation](https://vlayers.ai/docs/sdk)
353
+ - [Dashboard, flow builder, and brain connectors](https://vlayers.ai/docs)
354
+ - [Pricing](https://vlayers.ai/pricing) — $0.05/min flat, providers billed at cost
355
+ - [github.com/vlayers](https://github.com/vlayers)
package/dist/index.js CHANGED
@@ -1669,6 +1669,212 @@ var init_connection = __esm({
1669
1669
  });
1670
1670
  }
1671
1671
  });
1672
+ var E164, RoomPrefix, RecordingPrefix, Cidr, TelephonySettings, ProfileSource, EffectiveCallProfile;
1673
+ var init_telephony = __esm({
1674
+ "../contracts/src/telephony.ts"() {
1675
+ E164 = z.string().regex(/^\+[1-9]\d{6,14}$/, "must be E.164 (+15551234567)");
1676
+ RoomPrefix = z.string().regex(/^[a-z0-9][a-z0-9-]{0,30}[a-z0-9-]?$/, "lowercase letters, digits, dashes; max 32");
1677
+ RecordingPrefix = z.string().min(1).max(128).refine((v) => !v.startsWith("/") && !v.includes(".."), "relative key prefix only");
1678
+ Cidr = z.string().regex(/^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/, "must be IPv4 CIDR (a.b.c.d/nn)");
1679
+ TelephonySettings = z.object({
1680
+ outboundTrunkId: z.string().min(1).max(128).nullable().default(null),
1681
+ defaultFromE164: E164.nullable().default(null),
1682
+ roomPrefix: RoomPrefix.nullable().default(null),
1683
+ sipAllowedCidrs: z.array(Cidr).max(32).nullable().default(null),
1684
+ recordingPrefix: RecordingPrefix.nullable().default(null),
1685
+ strictByok: z.boolean().default(false)
1686
+ });
1687
+ z.object({
1688
+ outboundTrunkId: z.string().min(1).max(128).nullable().optional(),
1689
+ defaultFromE164: E164.nullable().optional(),
1690
+ roomPrefix: RoomPrefix.nullable().optional(),
1691
+ sipAllowedCidrs: z.array(Cidr).max(32).nullable().optional(),
1692
+ recordingPrefix: RecordingPrefix.nullable().optional(),
1693
+ strictByok: z.boolean().optional()
1694
+ }).refine((v) => Object.keys(v).length > 0, { message: "nothing to update" });
1695
+ ProfileSource = z.enum(["org", "platform", "none"]);
1696
+ EffectiveCallProfile = z.object({
1697
+ projectId: z.string(),
1698
+ outboundTrunkId: z.string().nullable(),
1699
+ defaultFromE164: z.string().nullable(),
1700
+ roomPrefix: z.string(),
1701
+ sipAllowedCidrs: z.array(z.string()),
1702
+ recordingPrefix: z.string(),
1703
+ strictByok: z.boolean(),
1704
+ sources: z.object({
1705
+ outboundTrunkId: ProfileSource,
1706
+ defaultFromE164: ProfileSource,
1707
+ roomPrefix: ProfileSource,
1708
+ sipAllowedCidrs: ProfileSource,
1709
+ recordingPrefix: ProfileSource
1710
+ })
1711
+ });
1712
+ z.object({
1713
+ settings: TelephonySettings,
1714
+ profile: EffectiveCallProfile
1715
+ });
1716
+ }
1717
+ });
1718
+ var SHORTENER_HOSTS, PLACEHOLDER_HOSTS, PublicUrl, A2pBusinessType, A2pBusinessInfo, A2pOptInType, A2pUseCase, A2pMessagingProfile, A2pState;
1719
+ var init_a2p = __esm({
1720
+ "../contracts/src/a2p.ts"() {
1721
+ SHORTENER_HOSTS = [
1722
+ "bit.ly",
1723
+ "tinyurl.com",
1724
+ "t.co",
1725
+ "goo.gl",
1726
+ "ow.ly",
1727
+ "is.gd",
1728
+ "buff.ly",
1729
+ "rebrand.ly",
1730
+ "cutt.ly",
1731
+ "shorturl.at",
1732
+ "tiny.cc"
1733
+ ];
1734
+ PLACEHOLDER_HOSTS = ["acme.com", "example.com", "example.org", "test.com", "localhost"];
1735
+ PublicUrl = z.string().url().refine((v) => v.startsWith("https://"), "must be https").refine((v) => {
1736
+ try {
1737
+ const host = new URL(v).hostname.toLowerCase().replace(/^www\./, "");
1738
+ return !SHORTENER_HOSTS.includes(host);
1739
+ } catch {
1740
+ return false;
1741
+ }
1742
+ }, "public URL shorteners are rejected by carriers \u2014 use your own domain").refine((v) => {
1743
+ try {
1744
+ const host = new URL(v).hostname.toLowerCase().replace(/^www\./, "");
1745
+ return !PLACEHOLDER_HOSTS.some((p) => host === p || host.endsWith(`.${p}`));
1746
+ } catch {
1747
+ return false;
1748
+ }
1749
+ }, "placeholder domain \u2014 reviewers will follow this link and reject the campaign");
1750
+ A2pBusinessType = z.enum([
1751
+ "Sole Proprietorship",
1752
+ "Partnership",
1753
+ "Corporation",
1754
+ "Co-operative",
1755
+ "Limited Liability Corporation",
1756
+ "Non-profit Corporation"
1757
+ ]);
1758
+ A2pBusinessInfo = z.object({
1759
+ legalName: z.string().trim().min(2).max(200),
1760
+ /** EIN (US) or equivalent registration number. */
1761
+ registrationNumber: z.string().trim().min(4).max(50),
1762
+ businessType: A2pBusinessType,
1763
+ /** Publicly reachable production site — not staging, not a 404. */
1764
+ website: PublicUrl,
1765
+ industry: z.string().trim().min(2).max(60),
1766
+ address: z.object({
1767
+ street: z.string().trim().min(2).max(200),
1768
+ city: z.string().trim().min(1).max(100),
1769
+ region: z.string().trim().min(1).max(100),
1770
+ postalCode: z.string().trim().min(2).max(20),
1771
+ isoCountry: z.string().trim().length(2)
1772
+ }),
1773
+ authorizedRep: z.object({
1774
+ firstName: z.string().trim().min(1).max(100),
1775
+ lastName: z.string().trim().min(1).max(100),
1776
+ email: z.string().trim().email(),
1777
+ phone: z.string().trim().regex(/^\+[1-9]\d{6,14}$/, "must be E.164"),
1778
+ jobTitle: z.string().trim().min(2).max(100)
1779
+ })
1780
+ });
1781
+ A2pOptInType = z.enum(["WEB_FORM", "PAPER_FORM", "VERBAL", "VIA_TEXT", "MOBILE_QR_CODE"]);
1782
+ A2pUseCase = z.enum([
1783
+ "MIXED",
1784
+ "CUSTOMER_CARE",
1785
+ "MARKETING",
1786
+ "ACCOUNT_NOTIFICATION",
1787
+ "2FA",
1788
+ "DELIVERY_NOTIFICATION",
1789
+ "HIGHER_EDUCATION",
1790
+ "POLLING_VOTING",
1791
+ "PUBLIC_SERVICE_ANNOUNCEMENT",
1792
+ "LOW_VOLUME"
1793
+ ]);
1794
+ A2pMessagingProfile = z.object({
1795
+ useCase: A2pUseCase,
1796
+ /** Specific, not generic. "We send texts" gets rejected; describe the actual
1797
+ * messages and when they are sent. */
1798
+ description: z.string().trim().min(40).max(4096),
1799
+ /**
1800
+ * The single most-rejected field. Must describe HOW people opt in, state the
1801
+ * message frequency, include the "message and data rates may apply"
1802
+ * disclosure, and link to publicly reachable evidence. Twilio's API bounds it
1803
+ * to 40–2049 characters.
1804
+ */
1805
+ messageFlow: z.string().trim().min(40).max(2049),
1806
+ optInType: A2pOptInType,
1807
+ /** Publicly accessible screenshots/pages showing the opt-in. Reviewers open
1808
+ * these; anything behind a login fails. */
1809
+ optInEvidenceUrls: z.array(PublicUrl).min(1).max(5),
1810
+ /** Real messages the customer will send. Must reflect the declared use case
1811
+ * and carry opt-out language. */
1812
+ messageSamples: z.array(z.string().trim().min(10).max(1024)).min(2).max(5),
1813
+ privacyPolicyUrl: PublicUrl,
1814
+ termsAndConditionsUrl: PublicUrl,
1815
+ hasEmbeddedLinks: z.boolean().default(false),
1816
+ hasEmbeddedPhone: z.boolean().default(false)
1817
+ });
1818
+ z.object({
1819
+ business: A2pBusinessInfo,
1820
+ messaging: A2pMessagingProfile,
1821
+ /** Register against Twilio's mock endpoints — exercises the full pipeline
1822
+ * with no fees and no real carrier submission. Used in CI and staging. */
1823
+ mock: z.boolean().default(false)
1824
+ }).superRefine((v, ctx) => {
1825
+ const flow = v.messaging.messageFlow.toLowerCase();
1826
+ if (!/(msg|message)\s*(&|and)\s*data rates/.test(flow)) {
1827
+ ctx.addIssue({
1828
+ code: z.ZodIssueCode.custom,
1829
+ path: ["messaging", "messageFlow"],
1830
+ message: 'must include a "Message and data rates may apply" disclosure \u2014 carriers reject without it'
1831
+ });
1832
+ }
1833
+ if (!/\d/.test(flow) || !/(msg|message|text)/.test(flow)) {
1834
+ ctx.addIssue({
1835
+ code: z.ZodIssueCode.custom,
1836
+ path: ["messaging", "messageFlow"],
1837
+ message: 'must state message frequency, e.g. "Up to 4 msgs/month"'
1838
+ });
1839
+ }
1840
+ const hasOptOut = v.messaging.messageSamples.some((s) => /stop/i.test(s));
1841
+ if (!hasOptOut) {
1842
+ ctx.addIssue({
1843
+ code: z.ZodIssueCode.custom,
1844
+ path: ["messaging", "messageSamples"],
1845
+ message: 'at least one sample must include opt-out language (e.g. "Reply STOP to opt out")'
1846
+ });
1847
+ }
1848
+ });
1849
+ A2pState = z.enum([
1850
+ "none",
1851
+ "profile_pending",
1852
+ "profile_approved",
1853
+ "profile_failed",
1854
+ "brand_pending",
1855
+ "brand_approved",
1856
+ "brand_failed",
1857
+ "campaign_pending",
1858
+ "messaging_ready",
1859
+ "campaign_failed"
1860
+ ]);
1861
+ z.object({
1862
+ state: A2pState,
1863
+ customerProfileSid: z.string().nullable(),
1864
+ trustProductSid: z.string().nullable(),
1865
+ brandSid: z.string().nullable(),
1866
+ messagingServiceSid: z.string().nullable(),
1867
+ campaignSid: z.string().nullable(),
1868
+ mock: z.boolean(),
1869
+ /** Carrier/Twilio rejection details, surfaced verbatim so the customer can
1870
+ * fix the specific field rather than guess. */
1871
+ failures: z.array(z.object({ code: z.number().nullable(), field: z.string().nullable(), message: z.string() })).default([]),
1872
+ /** Plain-language next step for the dashboard. */
1873
+ nextAction: z.string().nullable(),
1874
+ updatedAt: z.string().nullable()
1875
+ });
1876
+ }
1877
+ });
1672
1878
  var ConnectorMode, ConnectorStatus, ConnectorNormalize;
1673
1879
  var init_connector = __esm({
1674
1880
  "../contracts/src/connector.ts"() {
@@ -2307,6 +2513,8 @@ var init_src = __esm({
2307
2513
  init_call_recording();
2308
2514
  init_outbound();
2309
2515
  init_connection();
2516
+ init_telephony();
2517
+ init_a2p();
2310
2518
  init_connector();
2311
2519
  init_connector_stream();
2312
2520
  init_flow_compile();
@@ -3251,11 +3459,12 @@ ${collected}`);
3251
3459
  if (ok && cfg.outputAs && typeof cfg.outputAs === "object") {
3252
3460
  for (const [slot, path] of Object.entries(cfg.outputAs)) {
3253
3461
  if (typeof path !== "string" || !path.trim()) continue;
3254
- const value = resolvePath(result, path.trim());
3255
- if (value === void 0 || value === null) {
3462
+ const raw = resolvePath(result, path.trim());
3463
+ if (raw === void 0 || raw === null) {
3256
3464
  deps.events.note("tool.output_missing", { node: node.id, slot, path });
3257
3465
  continue;
3258
3466
  }
3467
+ const value = typeof raw === "string" ? raw : typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint" ? String(raw) : JSON.stringify(raw);
3259
3468
  const captured = deps.processRt.captureField(slot, value);
3260
3469
  deps.events.note(captured ? "tool.output_captured" : "tool.output_rejected", {
3261
3470
  node: node.id,
@@ -7513,6 +7722,18 @@ function buildTextContext(adapter, call, processRt) {
7513
7722
  get process() {
7514
7723
  return { data: processRt.getData(), complete: processRt.isComplete() };
7515
7724
  },
7725
+ // Voice-only actions, stubbed so a flow containing them can still be dry-run
7726
+ // over text. Without these the handoff node threw "deps.ctx.handoff is not
7727
+ // a function" mid-trace (seen in the builder's dry run); the graph now
7728
+ // reaches its handoff outcome cleanly and the trace says why nothing dialed.
7729
+ handoff: async (to) => {
7730
+ processRt.recordEvent({
7731
+ kind: "handoff.simulated",
7732
+ data: { to, reason: "text dry run \u2014 no telephony leg to transfer" }
7733
+ });
7734
+ },
7735
+ endCall: async () => {
7736
+ },
7516
7737
  // Voice-only escape hatch is absent on text; left undefined.
7517
7738
  livekit: { session: {}, job: {} }
7518
7739
  };
@@ -7592,12 +7813,12 @@ function definitionFor(opts) {
7592
7813
  }
7593
7814
  async function runTextTranscript(opts) {
7594
7815
  const replies = [];
7595
- const trace7 = [];
7816
+ const trace8 = [];
7596
7817
  const processRt = opts.processRt ?? createProcessRuntime(definitionFor(opts));
7597
7818
  const session = runTextSession({
7598
7819
  program: opts.program,
7599
7820
  onAgentText: (t) => void replies.push(t),
7600
- onTrace: (e) => void trace7.push(e),
7821
+ onTrace: (e) => void trace8.push(e),
7601
7822
  ...processRt ? { processRt } : {},
7602
7823
  ...opts.generate ? { generate: opts.generate } : {},
7603
7824
  ...opts.call ? { call: opts.call } : {},
@@ -7606,7 +7827,7 @@ async function runTextTranscript(opts) {
7606
7827
  for (const m of opts.messages) session.sendUserMessage(m);
7607
7828
  session.end();
7608
7829
  const outcome = await session.result;
7609
- return { replies, outcome, trace: trace7 };
7830
+ return { replies, outcome, trace: trace8 };
7610
7831
  }
7611
7832
  function ensureLkLogger() {
7612
7833
  if (loggerOptions() === void 0) {
@@ -7639,9 +7860,15 @@ async function runAgentTextTranscript(opts) {
7639
7860
  return { replies };
7640
7861
  }
7641
7862
  var DEFAULT_OTLP_ENDPOINT = "https://otel.vlayers.ai/v1/otlp";
7863
+ var DEFAULT_METRIC_EXPORT_INTERVAL_MS = 15e3;
7642
7864
  var started = false;
7643
7865
  var sdk = null;
7644
7866
  var loggerProvider = null;
7867
+ var metricReader = null;
7868
+ function metricExportIntervalMs() {
7869
+ const raw = Number(process.env.OTEL_METRIC_EXPORT_INTERVAL);
7870
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_METRIC_EXPORT_INTERVAL_MS;
7871
+ }
7645
7872
  function start(opts) {
7646
7873
  if (started)
7647
7874
  return;
@@ -7666,21 +7893,23 @@ function start(opts) {
7666
7893
  processors: [new BatchLogRecordProcessor(new OTLPLogExporter())]
7667
7894
  });
7668
7895
  logs.setGlobalLoggerProvider(loggerProvider);
7896
+ metricReader = new PeriodicExportingMetricReader({
7897
+ exportIntervalMillis: metricExportIntervalMs(),
7898
+ // DELTA temporality: each export carries only the increment since the
7899
+ // previous one. The read side (apps/api openobserve-repository) totals a
7900
+ // metric with SUM(value) over the query window, which is correct only for
7901
+ // deltas. The OTLP exporter defaults to CUMULATIVE — it re-reports the
7902
+ // running total every export, so SUM double-counts every call that lives
7903
+ // longer than one export interval (inflating LLM/TTS/STT usage + cost).
7904
+ // Setting DELTA makes the existing SUM reads correct without touching them.
7905
+ exporter: new OTLPMetricExporter({
7906
+ temporalityPreference: AggregationTemporalityPreference.DELTA
7907
+ })
7908
+ });
7669
7909
  sdk = new NodeSDK({
7670
7910
  resource,
7671
7911
  traceExporter: new OTLPTraceExporter(),
7672
- metricReader: new PeriodicExportingMetricReader({
7673
- // DELTA temporality: each 60s export carries only the increment since the
7674
- // previous one. The read side (apps/api openobserve-repository) totals a
7675
- // metric with SUM(value) over the query window, which is correct only for
7676
- // deltas. The OTLP exporter defaults to CUMULATIVE — it re-reports the
7677
- // running total every export, so SUM double-counts every call that lives
7678
- // longer than one export interval (inflating LLM/TTS/STT usage + cost).
7679
- // Setting DELTA makes the existing SUM reads correct without touching them.
7680
- exporter: new OTLPMetricExporter({
7681
- temporalityPreference: AggregationTemporalityPreference.DELTA
7682
- })
7683
- }),
7912
+ metricReader,
7684
7913
  instrumentations: [
7685
7914
  getNodeAutoInstrumentations({
7686
7915
  // fs instrumentation is extremely noisy and rarely useful for app traces.
@@ -7699,6 +7928,19 @@ function start(opts) {
7699
7928
  process.once("SIGTERM", () => void shutdown().then(() => process.exit(0)));
7700
7929
  process.once("SIGINT", () => void shutdown().then(() => process.exit(0)));
7701
7930
  }
7931
+ async function flushTelemetry() {
7932
+ const flushes = [];
7933
+ if (metricReader)
7934
+ flushes.push(metricReader.forceFlush());
7935
+ if (loggerProvider)
7936
+ flushes.push(loggerProvider.forceFlush());
7937
+ const proxied = trace.getTracerProvider();
7938
+ const tracerProvider = proxied.getDelegate?.() ?? proxied;
7939
+ if (typeof tracerProvider.forceFlush === "function") {
7940
+ flushes.push(tracerProvider.forceFlush());
7941
+ }
7942
+ await Promise.allSettled(flushes);
7943
+ }
7702
7944
  function getLogger(name) {
7703
7945
  return logs.getLogger(name);
7704
7946
  }
@@ -7770,6 +8012,12 @@ function getCurrentCallContext() {
7770
8012
  }
7771
8013
  var METER_NAME = "voicelayer";
7772
8014
  var METER_VERSION = "0.1.0";
8015
+ var USAGE_METRIC_NAMES = {
8016
+ llmInputTokens: "vl.llm.input_tokens",
8017
+ llmOutputTokens: "vl.llm.output_tokens",
8018
+ ttsChars: "vl.tts.chars",
8019
+ sttSeconds: "vl.stt.seconds"
8020
+ };
7773
8021
  function meter() {
7774
8022
  return metrics.getMeter(METER_NAME, METER_VERSION);
7775
8023
  }
@@ -7790,25 +8038,25 @@ var _llmOutputTokens = null;
7790
8038
  var _ttsChars = null;
7791
8039
  var _sttSeconds = null;
7792
8040
  function llmInputTokens() {
7793
- return _llmInputTokens ??= meter().createCounter("vl.llm.input_tokens", {
8041
+ return _llmInputTokens ??= meter().createCounter(USAGE_METRIC_NAMES.llmInputTokens, {
7794
8042
  description: "Input tokens consumed by an LLM call",
7795
8043
  unit: "{tokens}"
7796
8044
  });
7797
8045
  }
7798
8046
  function llmOutputTokens() {
7799
- return _llmOutputTokens ??= meter().createCounter("vl.llm.output_tokens", {
8047
+ return _llmOutputTokens ??= meter().createCounter(USAGE_METRIC_NAMES.llmOutputTokens, {
7800
8048
  description: "Output tokens produced by an LLM call",
7801
8049
  unit: "{tokens}"
7802
8050
  });
7803
8051
  }
7804
8052
  function ttsChars() {
7805
- return _ttsChars ??= meter().createCounter("vl.tts.chars", {
8053
+ return _ttsChars ??= meter().createCounter(USAGE_METRIC_NAMES.ttsChars, {
7806
8054
  description: "Characters synthesized by TTS",
7807
8055
  unit: "{chars}"
7808
8056
  });
7809
8057
  }
7810
8058
  function sttSeconds() {
7811
- return _sttSeconds ??= meter().createCounter("vl.stt.seconds", {
8059
+ return _sttSeconds ??= meter().createCounter(USAGE_METRIC_NAMES.sttSeconds, {
7812
8060
  description: "Audio seconds transcribed by STT",
7813
8061
  unit: "s"
7814
8062
  });
@@ -11011,7 +11259,7 @@ async function quiesce(deps) {
11011
11259
  async function executeHandoff(args, config, deps) {
11012
11260
  const briefing = args.briefing ?? await buildBriefing(config, deps) ?? `Handoff to ${args.to}`;
11013
11261
  const mode = args.mode ?? config?.mode ?? "warm";
11014
- const sipOps = deps.sipOps ?? buildDefaultSipOps();
11262
+ const sipOps = deps.sipOps ?? buildDefaultSipOps(deps.outboundTrunkId, ownDidFor(deps.call));
11015
11263
  const notify = deps.notify ?? (() => {
11016
11264
  });
11017
11265
  if (mode === "warm" && sipOps && sipOps.canConference) {
@@ -11089,14 +11337,19 @@ async function buildBriefing(config, deps, args) {
11089
11337
  return void 0;
11090
11338
  }
11091
11339
  }
11092
- function buildDefaultSipOps() {
11340
+ function ownDidFor(call) {
11341
+ const direction = typeof call.metadata["direction"] === "string" ? call.metadata["direction"] : "inbound";
11342
+ const candidate = direction === "outbound" ? call.callerId : call.to;
11343
+ return candidate && /^\+[1-9]\d{6,14}$/.test(candidate) ? candidate : void 0;
11344
+ }
11345
+ function buildDefaultSipOps(trunkOverride, fromNumber) {
11093
11346
  const url = process.env["LIVEKIT_URL"];
11094
11347
  const key = process.env["LIVEKIT_API_KEY"];
11095
11348
  const secret = process.env["LIVEKIT_API_SECRET"];
11096
11349
  if (!url || !key || !secret) return null;
11097
11350
  const host = url.startsWith("wss://") ? `https://${url.slice("wss://".length)}` : url.startsWith("ws://") ? `http://${url.slice("ws://".length)}` : url;
11098
11351
  const client = new SipClient(host, key, secret);
11099
- const trunkId = process.env["LIVEKIT_SIP_OUTBOUND_TRUNK_ID"];
11352
+ const trunkId = trunkOverride ?? process.env["LIVEKIT_SIP_OUTBOUND_TRUNK_ID"];
11100
11353
  return {
11101
11354
  canConference: Boolean(trunkId),
11102
11355
  async refer(roomName, callerIdentity, target) {
@@ -11108,7 +11361,8 @@ function buildDefaultSipOps() {
11108
11361
  await client.createSipParticipant(trunkId, target.replace(/^tel:/, ""), roomName, {
11109
11362
  participantIdentity: identity,
11110
11363
  playDialtone: false,
11111
- waitUntilAnswered: true
11364
+ waitUntilAnswered: true,
11365
+ ...fromNumber ? { fromNumber } : {}
11112
11366
  });
11113
11367
  return { identity };
11114
11368
  }
@@ -11896,6 +12150,64 @@ var UsageAccumulator = class {
11896
12150
  };
11897
12151
  }
11898
12152
  };
12153
+ function registerTelemetryFlush(job, flush = flushTelemetry) {
12154
+ const ctx = job;
12155
+ if (typeof ctx?.addShutdownCallback !== "function") return;
12156
+ ctx.addShutdownCallback(async () => {
12157
+ try {
12158
+ await flush();
12159
+ } catch (err) {
12160
+ console.warn("[agent] telemetry flush failed", err);
12161
+ }
12162
+ });
12163
+ }
12164
+ function attachEndpointingProbe(session, events, ctx, deps) {
12165
+ const base = {
12166
+ projectId: ctx.projectId,
12167
+ callId: ctx.callId,
12168
+ ...ctx.campaignId ? { campaignId: ctx.campaignId } : {}
12169
+ };
12170
+ const now = deps.now ?? Date.now;
12171
+ let armed = false;
12172
+ let speechEndAt = null;
12173
+ let finalTranscriptAt = null;
12174
+ const resetTurn = () => {
12175
+ speechEndAt = null;
12176
+ finalTranscriptAt = null;
12177
+ };
12178
+ const tryEmit = () => {
12179
+ if (!armed || speechEndAt == null || finalTranscriptAt == null) return;
12180
+ const delayMs = finalTranscriptAt - speechEndAt;
12181
+ armed = false;
12182
+ resetTurn();
12183
+ if (!(delayMs > 0)) return;
12184
+ recordEouLatency({
12185
+ ...base,
12186
+ endOfUtteranceDelayMs: delayMs,
12187
+ transcriptionDelayMs: delayMs
12188
+ });
12189
+ recordTurnLatencySpan({ ...base, stage: "endpointing", latencyMs: delayMs });
12190
+ recordTurnLatencySpan({ ...base, stage: "stt", latencyMs: delayMs });
12191
+ };
12192
+ deps.vad.onSpeechEnd((sample) => {
12193
+ if (!armed) return;
12194
+ speechEndAt = sample.speechEndAt;
12195
+ tryEmit();
12196
+ });
12197
+ session.on(events.userStateChanged, (event) => {
12198
+ const e = event;
12199
+ if (e?.newState === "speaking") {
12200
+ armed = true;
12201
+ resetTurn();
12202
+ }
12203
+ });
12204
+ session.on(events.userInputTranscribed, (event) => {
12205
+ const e = event;
12206
+ if (!armed || !e?.isFinal) return;
12207
+ finalTranscriptAt = typeof e.createdAt === "number" ? e.createdAt : now();
12208
+ tryEmit();
12209
+ });
12210
+ }
11899
12211
  function attachMetricsBridge(session, eventName, ctx) {
11900
12212
  session.on(eventName, (event) => {
11901
12213
  try {
@@ -12011,6 +12323,51 @@ function dispatch(metrics2, ctx) {
12011
12323
  }
12012
12324
  }
12013
12325
  }
12326
+
12327
+ // src/runtime/vad-tap.ts
12328
+ function tapVad(vad, opts) {
12329
+ const listeners = /* @__PURE__ */ new Set();
12330
+ const now = opts.now ?? Date.now;
12331
+ const target = vad;
12332
+ if (!target || typeof target.stream !== "function") {
12333
+ return { onSpeechEnd: () => () => {
12334
+ } };
12335
+ }
12336
+ const originalStream = target.stream.bind(target);
12337
+ target.stream = () => {
12338
+ const stream = originalStream();
12339
+ const originalNext = stream.next.bind(stream);
12340
+ stream.next = async () => {
12341
+ const result = await originalNext();
12342
+ try {
12343
+ const event = result.value;
12344
+ if (!result.done && event && event.type === opts.endOfSpeechType) {
12345
+ const silenceMs = numberOr(event.silenceDuration, 0);
12346
+ const inferenceMs = numberOr(event.inferenceDuration, 0);
12347
+ const sample = {
12348
+ speechEndAt: now() - silenceMs - inferenceMs,
12349
+ silenceMs,
12350
+ inferenceMs
12351
+ };
12352
+ for (const listener of [...listeners]) listener(sample);
12353
+ }
12354
+ } catch (err) {
12355
+ console.warn("[agent] vad tap failed", err);
12356
+ }
12357
+ return result;
12358
+ };
12359
+ return stream;
12360
+ };
12361
+ return {
12362
+ onSpeechEnd(listener) {
12363
+ listeners.add(listener);
12364
+ return () => listeners.delete(listener);
12365
+ }
12366
+ };
12367
+ }
12368
+ function numberOr(value, fallback) {
12369
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
12370
+ }
12014
12371
  function resolveAgentModulePath(moduleUrl) {
12015
12372
  if (moduleUrl.startsWith("file://")) {
12016
12373
  return fileURLToPath(moduleUrl);
@@ -12166,6 +12523,82 @@ function actionSpecToAction(spec) {
12166
12523
  return void 0;
12167
12524
  }
12168
12525
 
12526
+ // src/worker-identity.ts
12527
+ async function exchangeWorkerToken(input) {
12528
+ const apiUrl = process.env["VOICELAYER_API_URL"];
12529
+ const internalToken = process.env["INTERNAL_SERVICE_TOKEN"];
12530
+ if (!apiUrl || !internalToken) return null;
12531
+ try {
12532
+ const res = await (input.fetchImpl ?? fetch)(
12533
+ `${apiUrl.replace(/\/+$/, "")}/v1/internal/worker-token`,
12534
+ {
12535
+ method: "POST",
12536
+ headers: {
12537
+ "content-type": "application/json",
12538
+ "x-internal-token": internalToken
12539
+ },
12540
+ body: JSON.stringify({
12541
+ projectId: input.projectId,
12542
+ ...input.callId ? { callId: input.callId } : {}
12543
+ })
12544
+ }
12545
+ );
12546
+ if (!res.ok) return null;
12547
+ const body = await res.json();
12548
+ return typeof body.token === "string" && body.token.length > 0 ? body.token : null;
12549
+ } catch {
12550
+ return null;
12551
+ }
12552
+ }
12553
+ async function fetchCallProfile(projectId, fetchImpl) {
12554
+ const apiUrl = process.env["VOICELAYER_API_URL"];
12555
+ const internalToken = process.env["INTERNAL_SERVICE_TOKEN"];
12556
+ if (!apiUrl || !internalToken) return null;
12557
+ try {
12558
+ const res = await (fetchImpl ?? fetch)(
12559
+ `${apiUrl.replace(/\/+$/, "")}/v1/internal/call-profile/${encodeURIComponent(projectId)}`,
12560
+ { headers: { "x-internal-token": internalToken } }
12561
+ );
12562
+ if (!res.ok) return null;
12563
+ const body = await res.json();
12564
+ if (!body.profile) return null;
12565
+ return {
12566
+ outboundTrunkId: typeof body.profile.outboundTrunkId === "string" ? body.profile.outboundTrunkId : null,
12567
+ strictByok: body.profile.strictByok === true
12568
+ };
12569
+ } catch {
12570
+ return null;
12571
+ }
12572
+ }
12573
+ async function resolveWorkerClient(md, fetchImpl) {
12574
+ const projectId = typeof md["projectId"] === "string" ? md["projectId"] : void 0;
12575
+ const callId = typeof md["callId"] === "string" ? md["callId"] : void 0;
12576
+ if (projectId) {
12577
+ const token = await exchangeWorkerToken({
12578
+ projectId,
12579
+ ...callId ? { callId } : {},
12580
+ ...{}
12581
+ });
12582
+ if (token) {
12583
+ try {
12584
+ return createClient({ apiKey: token });
12585
+ } catch {
12586
+ }
12587
+ } else if (process.env["INTERNAL_SERVICE_TOKEN"] && process.env["VOICELAYER_API_URL"]) {
12588
+ console.warn("[sdk.worker-identity] worker-token exchange failed; falling back to env key", {
12589
+ projectId
12590
+ });
12591
+ }
12592
+ }
12593
+ const apiKey = process.env["VOICELAYER_API_KEY"];
12594
+ if (!apiKey) return null;
12595
+ try {
12596
+ return createClient({ apiKey });
12597
+ } catch {
12598
+ return null;
12599
+ }
12600
+ }
12601
+
12169
12602
  // src/runtime/prompt-compose.ts
12170
12603
  function composeSystemPrompt(prompt, routingInstructions) {
12171
12604
  const routing = routingInstructions?.trim();
@@ -12690,26 +13123,34 @@ var Agent = class {
12690
13123
  const projectId = typeof md["projectId"] === "string" ? md["projectId"] : void 0;
12691
13124
  const agentId = typeof md["agentId"] === "string" ? md["agentId"] : void 0;
12692
13125
  const room = job.room.name ?? void 0;
12693
- return withCallContext(
12694
- {
12695
- kind: "agent.call",
12696
- source: this.config.name,
12697
- callId,
12698
- ...projectId ? { projectId } : {},
12699
- ...agentId ? { agentId } : {},
12700
- ...room ? { room } : {}
12701
- },
12702
- () => this.runCallSpan(job)
12703
- );
13126
+ registerTelemetryFlush(job);
13127
+ try {
13128
+ return await withCallContext(
13129
+ {
13130
+ kind: "agent.call",
13131
+ source: this.config.name,
13132
+ callId,
13133
+ ...projectId ? { projectId } : {},
13134
+ ...agentId ? { agentId } : {},
13135
+ ...room ? { room } : {}
13136
+ },
13137
+ () => this.runCallSpan(job)
13138
+ );
13139
+ } finally {
13140
+ await flushTelemetry().catch(() => {
13141
+ });
13142
+ }
12704
13143
  }
12705
13144
  async runCallSpan(job) {
12706
- const { AutoSubscribe, voice: voice3 } = await import('@livekit/agents');
13145
+ const { AutoSubscribe, voice: voice3, VADEventType } = await import('@livekit/agents');
12707
13146
  const Events = voice3.AgentSessionEventTypes;
12708
13147
  await job.connect(void 0, AutoSubscribe.SUBSCRIBE_ALL);
12709
13148
  const participant = await job.waitForParticipant();
12710
13149
  const callInfo = buildCallInfo(job, participant);
12711
- const sdkClient = tryCreateClient();
12712
13150
  const dispatchMdEarly = parseJobMetadata(job.job?.metadata);
13151
+ const sdkClient = await resolveWorkerClient(dispatchMdEarly);
13152
+ const earlyProjectId = stringValue(dispatchMdEarly["projectId"]);
13153
+ const workerCallProfile = earlyProjectId ? await fetchCallProfile(earlyProjectId) : null;
12713
13154
  let effectiveConfig = this.config;
12714
13155
  let graphMode = false;
12715
13156
  let graphProgram;
@@ -12868,6 +13309,7 @@ var Agent = class {
12868
13309
  callInfo,
12869
13310
  job.proc.userData.vad
12870
13311
  );
13312
+ const vadTap = tapVad(pipeline.vad, { endOfSpeechType: VADEventType.END_OF_SPEECH });
12871
13313
  const usingRealtime = pipeline.realtime != null;
12872
13314
  let runtimeLlm = usingRealtime ? pipeline.realtime : pipeline.llm;
12873
13315
  if (this.config.puppetMode) {
@@ -13161,7 +13603,8 @@ ${callIntent}`
13161
13603
  },
13162
13604
  getProcessData: () => processRt.getData(),
13163
13605
  transcript,
13164
- notify: (kind, payload) => emitOpEvent(kind, payload)
13606
+ notify: (kind, payload) => emitOpEvent(kind, payload),
13607
+ ...workerCallProfile?.outboundTrunkId ? { outboundTrunkId: workerCallProfile.outboundTrunkId } : {}
13165
13608
  });
13166
13609
  const triggersRt = createTriggersRuntime(effectiveConfig.triggers, handoffRt);
13167
13610
  const audioExperience = new AudioExperiencePrimitive();
@@ -13505,12 +13948,26 @@ ${callIntent}`
13505
13948
  console.error("[agent] session error", e.error);
13506
13949
  if (e.fatalTransport) endCallController.noteEngineClosed();
13507
13950
  });
13508
- attachMetricsBridge(session, Events.MetricsCollected, {
13951
+ const metricsCtx = {
13509
13952
  projectId: callProjectId,
13510
13953
  callId: callSync.callId,
13511
- ...callCampaignId ? { campaignId: callCampaignId } : {},
13954
+ ...callCampaignId ? { campaignId: callCampaignId } : {}
13955
+ };
13956
+ attachMetricsBridge(session, Events.MetricsCollected, {
13957
+ ...metricsCtx,
13512
13958
  usage: usageAccumulator
13513
13959
  });
13960
+ if (graphMode) {
13961
+ attachEndpointingProbe(
13962
+ session,
13963
+ {
13964
+ userStateChanged: Events.UserStateChanged,
13965
+ userInputTranscribed: Events.UserInputTranscribed
13966
+ },
13967
+ metricsCtx,
13968
+ { vad: vadTap }
13969
+ );
13970
+ }
13514
13971
  adapter.on("state", (e) => {
13515
13972
  console.log("[agent] state", {
13516
13973
  from: e.rawOldState,
@@ -13913,15 +14370,6 @@ function builtinToolCapabilities() {
13913
14370
  }
13914
14371
  return [...caps];
13915
14372
  }
13916
- function tryCreateClient() {
13917
- const apiKey = process.env["VOICELAYER_API_KEY"];
13918
- if (!apiKey) return null;
13919
- try {
13920
- return createClient({ apiKey });
13921
- } catch {
13922
- return null;
13923
- }
13924
- }
13925
14373
  function buildFormStreamWatcher(processRt) {
13926
14374
  const apiBaseUrl = process.env["VOICELAYER_API_URL"];
13927
14375
  if (!apiBaseUrl) {
@@ -1627,6 +1627,212 @@ var init_connection = __esm({
1627
1627
  });
1628
1628
  }
1629
1629
  });
1630
+ var E164, RoomPrefix, RecordingPrefix, Cidr, TelephonySettings, ProfileSource, EffectiveCallProfile;
1631
+ var init_telephony = __esm({
1632
+ "../contracts/src/telephony.ts"() {
1633
+ E164 = z.string().regex(/^\+[1-9]\d{6,14}$/, "must be E.164 (+15551234567)");
1634
+ RoomPrefix = z.string().regex(/^[a-z0-9][a-z0-9-]{0,30}[a-z0-9-]?$/, "lowercase letters, digits, dashes; max 32");
1635
+ RecordingPrefix = z.string().min(1).max(128).refine((v) => !v.startsWith("/") && !v.includes(".."), "relative key prefix only");
1636
+ Cidr = z.string().regex(/^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/, "must be IPv4 CIDR (a.b.c.d/nn)");
1637
+ TelephonySettings = z.object({
1638
+ outboundTrunkId: z.string().min(1).max(128).nullable().default(null),
1639
+ defaultFromE164: E164.nullable().default(null),
1640
+ roomPrefix: RoomPrefix.nullable().default(null),
1641
+ sipAllowedCidrs: z.array(Cidr).max(32).nullable().default(null),
1642
+ recordingPrefix: RecordingPrefix.nullable().default(null),
1643
+ strictByok: z.boolean().default(false)
1644
+ });
1645
+ z.object({
1646
+ outboundTrunkId: z.string().min(1).max(128).nullable().optional(),
1647
+ defaultFromE164: E164.nullable().optional(),
1648
+ roomPrefix: RoomPrefix.nullable().optional(),
1649
+ sipAllowedCidrs: z.array(Cidr).max(32).nullable().optional(),
1650
+ recordingPrefix: RecordingPrefix.nullable().optional(),
1651
+ strictByok: z.boolean().optional()
1652
+ }).refine((v) => Object.keys(v).length > 0, { message: "nothing to update" });
1653
+ ProfileSource = z.enum(["org", "platform", "none"]);
1654
+ EffectiveCallProfile = z.object({
1655
+ projectId: z.string(),
1656
+ outboundTrunkId: z.string().nullable(),
1657
+ defaultFromE164: z.string().nullable(),
1658
+ roomPrefix: z.string(),
1659
+ sipAllowedCidrs: z.array(z.string()),
1660
+ recordingPrefix: z.string(),
1661
+ strictByok: z.boolean(),
1662
+ sources: z.object({
1663
+ outboundTrunkId: ProfileSource,
1664
+ defaultFromE164: ProfileSource,
1665
+ roomPrefix: ProfileSource,
1666
+ sipAllowedCidrs: ProfileSource,
1667
+ recordingPrefix: ProfileSource
1668
+ })
1669
+ });
1670
+ z.object({
1671
+ settings: TelephonySettings,
1672
+ profile: EffectiveCallProfile
1673
+ });
1674
+ }
1675
+ });
1676
+ var SHORTENER_HOSTS, PLACEHOLDER_HOSTS, PublicUrl, A2pBusinessType, A2pBusinessInfo, A2pOptInType, A2pUseCase, A2pMessagingProfile, A2pState;
1677
+ var init_a2p = __esm({
1678
+ "../contracts/src/a2p.ts"() {
1679
+ SHORTENER_HOSTS = [
1680
+ "bit.ly",
1681
+ "tinyurl.com",
1682
+ "t.co",
1683
+ "goo.gl",
1684
+ "ow.ly",
1685
+ "is.gd",
1686
+ "buff.ly",
1687
+ "rebrand.ly",
1688
+ "cutt.ly",
1689
+ "shorturl.at",
1690
+ "tiny.cc"
1691
+ ];
1692
+ PLACEHOLDER_HOSTS = ["acme.com", "example.com", "example.org", "test.com", "localhost"];
1693
+ PublicUrl = z.string().url().refine((v) => v.startsWith("https://"), "must be https").refine((v) => {
1694
+ try {
1695
+ const host = new URL(v).hostname.toLowerCase().replace(/^www\./, "");
1696
+ return !SHORTENER_HOSTS.includes(host);
1697
+ } catch {
1698
+ return false;
1699
+ }
1700
+ }, "public URL shorteners are rejected by carriers \u2014 use your own domain").refine((v) => {
1701
+ try {
1702
+ const host = new URL(v).hostname.toLowerCase().replace(/^www\./, "");
1703
+ return !PLACEHOLDER_HOSTS.some((p) => host === p || host.endsWith(`.${p}`));
1704
+ } catch {
1705
+ return false;
1706
+ }
1707
+ }, "placeholder domain \u2014 reviewers will follow this link and reject the campaign");
1708
+ A2pBusinessType = z.enum([
1709
+ "Sole Proprietorship",
1710
+ "Partnership",
1711
+ "Corporation",
1712
+ "Co-operative",
1713
+ "Limited Liability Corporation",
1714
+ "Non-profit Corporation"
1715
+ ]);
1716
+ A2pBusinessInfo = z.object({
1717
+ legalName: z.string().trim().min(2).max(200),
1718
+ /** EIN (US) or equivalent registration number. */
1719
+ registrationNumber: z.string().trim().min(4).max(50),
1720
+ businessType: A2pBusinessType,
1721
+ /** Publicly reachable production site — not staging, not a 404. */
1722
+ website: PublicUrl,
1723
+ industry: z.string().trim().min(2).max(60),
1724
+ address: z.object({
1725
+ street: z.string().trim().min(2).max(200),
1726
+ city: z.string().trim().min(1).max(100),
1727
+ region: z.string().trim().min(1).max(100),
1728
+ postalCode: z.string().trim().min(2).max(20),
1729
+ isoCountry: z.string().trim().length(2)
1730
+ }),
1731
+ authorizedRep: z.object({
1732
+ firstName: z.string().trim().min(1).max(100),
1733
+ lastName: z.string().trim().min(1).max(100),
1734
+ email: z.string().trim().email(),
1735
+ phone: z.string().trim().regex(/^\+[1-9]\d{6,14}$/, "must be E.164"),
1736
+ jobTitle: z.string().trim().min(2).max(100)
1737
+ })
1738
+ });
1739
+ A2pOptInType = z.enum(["WEB_FORM", "PAPER_FORM", "VERBAL", "VIA_TEXT", "MOBILE_QR_CODE"]);
1740
+ A2pUseCase = z.enum([
1741
+ "MIXED",
1742
+ "CUSTOMER_CARE",
1743
+ "MARKETING",
1744
+ "ACCOUNT_NOTIFICATION",
1745
+ "2FA",
1746
+ "DELIVERY_NOTIFICATION",
1747
+ "HIGHER_EDUCATION",
1748
+ "POLLING_VOTING",
1749
+ "PUBLIC_SERVICE_ANNOUNCEMENT",
1750
+ "LOW_VOLUME"
1751
+ ]);
1752
+ A2pMessagingProfile = z.object({
1753
+ useCase: A2pUseCase,
1754
+ /** Specific, not generic. "We send texts" gets rejected; describe the actual
1755
+ * messages and when they are sent. */
1756
+ description: z.string().trim().min(40).max(4096),
1757
+ /**
1758
+ * The single most-rejected field. Must describe HOW people opt in, state the
1759
+ * message frequency, include the "message and data rates may apply"
1760
+ * disclosure, and link to publicly reachable evidence. Twilio's API bounds it
1761
+ * to 40–2049 characters.
1762
+ */
1763
+ messageFlow: z.string().trim().min(40).max(2049),
1764
+ optInType: A2pOptInType,
1765
+ /** Publicly accessible screenshots/pages showing the opt-in. Reviewers open
1766
+ * these; anything behind a login fails. */
1767
+ optInEvidenceUrls: z.array(PublicUrl).min(1).max(5),
1768
+ /** Real messages the customer will send. Must reflect the declared use case
1769
+ * and carry opt-out language. */
1770
+ messageSamples: z.array(z.string().trim().min(10).max(1024)).min(2).max(5),
1771
+ privacyPolicyUrl: PublicUrl,
1772
+ termsAndConditionsUrl: PublicUrl,
1773
+ hasEmbeddedLinks: z.boolean().default(false),
1774
+ hasEmbeddedPhone: z.boolean().default(false)
1775
+ });
1776
+ z.object({
1777
+ business: A2pBusinessInfo,
1778
+ messaging: A2pMessagingProfile,
1779
+ /** Register against Twilio's mock endpoints — exercises the full pipeline
1780
+ * with no fees and no real carrier submission. Used in CI and staging. */
1781
+ mock: z.boolean().default(false)
1782
+ }).superRefine((v, ctx) => {
1783
+ const flow = v.messaging.messageFlow.toLowerCase();
1784
+ if (!/(msg|message)\s*(&|and)\s*data rates/.test(flow)) {
1785
+ ctx.addIssue({
1786
+ code: z.ZodIssueCode.custom,
1787
+ path: ["messaging", "messageFlow"],
1788
+ message: 'must include a "Message and data rates may apply" disclosure \u2014 carriers reject without it'
1789
+ });
1790
+ }
1791
+ if (!/\d/.test(flow) || !/(msg|message|text)/.test(flow)) {
1792
+ ctx.addIssue({
1793
+ code: z.ZodIssueCode.custom,
1794
+ path: ["messaging", "messageFlow"],
1795
+ message: 'must state message frequency, e.g. "Up to 4 msgs/month"'
1796
+ });
1797
+ }
1798
+ const hasOptOut = v.messaging.messageSamples.some((s) => /stop/i.test(s));
1799
+ if (!hasOptOut) {
1800
+ ctx.addIssue({
1801
+ code: z.ZodIssueCode.custom,
1802
+ path: ["messaging", "messageSamples"],
1803
+ message: 'at least one sample must include opt-out language (e.g. "Reply STOP to opt out")'
1804
+ });
1805
+ }
1806
+ });
1807
+ A2pState = z.enum([
1808
+ "none",
1809
+ "profile_pending",
1810
+ "profile_approved",
1811
+ "profile_failed",
1812
+ "brand_pending",
1813
+ "brand_approved",
1814
+ "brand_failed",
1815
+ "campaign_pending",
1816
+ "messaging_ready",
1817
+ "campaign_failed"
1818
+ ]);
1819
+ z.object({
1820
+ state: A2pState,
1821
+ customerProfileSid: z.string().nullable(),
1822
+ trustProductSid: z.string().nullable(),
1823
+ brandSid: z.string().nullable(),
1824
+ messagingServiceSid: z.string().nullable(),
1825
+ campaignSid: z.string().nullable(),
1826
+ mock: z.boolean(),
1827
+ /** Carrier/Twilio rejection details, surfaced verbatim so the customer can
1828
+ * fix the specific field rather than guess. */
1829
+ failures: z.array(z.object({ code: z.number().nullable(), field: z.string().nullable(), message: z.string() })).default([]),
1830
+ /** Plain-language next step for the dashboard. */
1831
+ nextAction: z.string().nullable(),
1832
+ updatedAt: z.string().nullable()
1833
+ });
1834
+ }
1835
+ });
1630
1836
  var ConnectorMode, ConnectorStatus, ConnectorNormalize;
1631
1837
  var init_connector = __esm({
1632
1838
  "../contracts/src/connector.ts"() {
@@ -1931,6 +2137,8 @@ var init_src = __esm({
1931
2137
  init_call_recording();
1932
2138
  init_outbound();
1933
2139
  init_connection();
2140
+ init_telephony();
2141
+ init_a2p();
1934
2142
  init_connector();
1935
2143
  init_connector_stream();
1936
2144
  init_flow_compile();
@@ -2782,11 +2990,12 @@ ${collected}`);
2782
2990
  if (ok && cfg.outputAs && typeof cfg.outputAs === "object") {
2783
2991
  for (const [slot, path] of Object.entries(cfg.outputAs)) {
2784
2992
  if (typeof path !== "string" || !path.trim()) continue;
2785
- const value = resolvePath(result, path.trim());
2786
- if (value === void 0 || value === null) {
2993
+ const raw = resolvePath(result, path.trim());
2994
+ if (raw === void 0 || raw === null) {
2787
2995
  deps.events.note("tool.output_missing", { node: node.id, slot, path });
2788
2996
  continue;
2789
2997
  }
2998
+ const value = typeof raw === "string" ? raw : typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint" ? String(raw) : JSON.stringify(raw);
2790
2999
  const captured = deps.processRt.captureField(slot, value);
2791
3000
  deps.events.note(captured ? "tool.output_captured" : "tool.output_rejected", {
2792
3001
  node: node.id,
@@ -4756,6 +4965,18 @@ function buildTextContext(adapter, call, processRt) {
4756
4965
  get process() {
4757
4966
  return { data: processRt.getData(), complete: processRt.isComplete() };
4758
4967
  },
4968
+ // Voice-only actions, stubbed so a flow containing them can still be dry-run
4969
+ // over text. Without these the handoff node threw "deps.ctx.handoff is not
4970
+ // a function" mid-trace (seen in the builder's dry run); the graph now
4971
+ // reaches its handoff outcome cleanly and the trace says why nothing dialed.
4972
+ handoff: async (to) => {
4973
+ processRt.recordEvent({
4974
+ kind: "handoff.simulated",
4975
+ data: { to, reason: "text dry run \u2014 no telephony leg to transfer" }
4976
+ });
4977
+ },
4978
+ endCall: async () => {
4979
+ },
4759
4980
  // Voice-only escape hatch is absent on text; left undefined.
4760
4981
  livekit: { session: {}, job: {} }
4761
4982
  };
package/package.json CHANGED
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "name": "@voicelayer/sdk",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Official SDK for VoiceLayer — define, run, and orchestrate voice AI agents on the VoiceLayer control plane.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "VoiceLayer",
7
- "homepage": "https://github.com/paradox1612/voicelayer/tree/main/packages/sdk#readme",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/paradox1612/voicelayer.git",
11
- "directory": "packages/sdk"
12
- },
7
+ "homepage": "https://vlayers.ai/docs/sdk",
13
8
  "bugs": {
14
- "url": "https://github.com/paradox1612/voicelayer/issues"
9
+ "email": "customer@vlayers.ai"
15
10
  },
16
11
  "keywords": [
17
12
  "voicelayer",
@@ -81,14 +76,14 @@
81
76
  "vitest": "^4.1.5",
82
77
  "@voicelayer/contracts": "0.1.0",
83
78
  "@voicelayer/observability": "0.1.0",
84
- "@voicelayer/primitive-audio-experience": "0.1.0",
85
79
  "@voicelayer/primitive-audio-routing": "0.1.0",
86
- "@voicelayer/primitive-security": "0.1.0",
87
80
  "@voicelayer/primitive-process-schema": "0.1.0",
88
- "@voicelayer/agents-plugin-deepgram": "1.0.0",
89
- "@voicelayer/agents-plugin-livekit": "1.0.0",
90
- "@voicelayer/agents-plugin-openai": "1.0.0",
91
- "@voicelayer/agents-plugin-silero": "1.0.0"
81
+ "@voicelayer/agents-plugin-livekit": "1.0.1",
82
+ "@voicelayer/primitive-security": "0.1.0",
83
+ "@voicelayer/agents-plugin-openai": "1.0.1",
84
+ "@voicelayer/agents-plugin-silero": "1.0.1",
85
+ "@voicelayer/primitive-audio-experience": "0.1.0",
86
+ "@voicelayer/agents-plugin-deepgram": "1.0.1"
92
87
  },
93
88
  "scripts": {
94
89
  "typecheck": "tsc -b --pretty false",