@voicelayer/sdk 0.4.1 → 0.5.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/README.md CHANGED
@@ -32,7 +32,9 @@ export default await defineAgent({
32
32
  node --import tsx agent.ts dev
33
33
  ```
34
34
 
35
- Call your LiveKit number. The agent answers. That's it. STT, LLM, TTS, VAD, turn detection — defaults are picked for you. Swap them later with one config line.
35
+ The worker runs on **your** LiveKit project. Call a number you have connected to that project (a LiveKit SIP trunk plus a dispatch rule for the agent name `hello`), or join a room from LiveKit's Agents Playground, and the agent answers. STT, LLM, TTS, VAD, turn detection — defaults are picked for you. Swap them later with one config line.
36
+
37
+ > **What a self-hosted worker can and can't do today.** A number bought or imported in VoiceLayer can't reach a worker you run on your own LiveKit project: VoiceLayer numbers answer with **hosted flow agents** (built in the dashboard's flow builder) and **brain-connector agents** (your own LLM behind a VoiceLayer-hosted voice pipeline — see [Bring your own LLM](#bring-your-own-llm)). Your worker still registers with VoiceLayer, so it shows up in the dashboard with its calls and transcripts.
36
38
 
37
39
  ---
38
40
 
@@ -301,7 +303,7 @@ Each method receives `(args, ctx)`, so you can reach call state from inside a co
301
303
  node --import tsx agent.ts dev
302
304
  ```
303
305
 
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.
306
+ This boots the worker against your LiveKit project and registers the agent with the VoiceLayer control plane, so you can call a number connected to your LiveKit project and iterate on the same file you will deploy. Set the environment variables listed under [Production](#production) first.
305
307
 
306
308
  > 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.
307
309
 
@@ -320,21 +322,55 @@ export LIVEKIT_URL=...
320
322
  export LIVEKIT_API_KEY=...
321
323
  export LIVEKIT_API_SECRET=...
322
324
 
323
- # Provider keys for the models your agent uses (or set them per project in the
324
- # VoiceLayer dashboard's connections, resolved at call time):
325
+ # Required. Provider keys for the models your agent uses. Your worker reads
326
+ # them from its own environment — provider keys saved in the VoiceLayer
327
+ # dashboard's Connections are NOT handed to a worker you run yourself.
325
328
  export OPENAI_API_KEY=...
326
329
  export DEEPGRAM_API_KEY=...
327
330
 
328
331
  node agent.ts start
329
332
  ```
330
333
 
334
+ Agent names starting `voicelayer-` (and the names of VoiceLayer's own workers) are reserved; registering one is refused with `422 agent_name_reserved`.
335
+
336
+ ### Outbound HTTP from your agent
337
+
338
+ `httpTool`, a flow's tool steps and schema tools, and a process's completion `backendAck` all go through the SDK's
339
+ outbound guard. They use http(s) only, and any private, loopback, link-local or cloud-metadata address is refused, both
340
+ after DNS and on every redirect (at most 5 hops). The connection goes only to the address that was checked, a request
341
+ times out (a `backendAck` waits at most 10 s), and responses are capped at 2 MiB.
342
+
343
+ If your self-hosted worker has to call a service on your own network, opt in per tool or per ACK:
344
+
345
+ ```ts
346
+ httpTool({ name: 'inventory', description: '…', input: { sku: 'string' }, url: 'http://inventory.internal/{sku}', allowPrivateNetwork: true });
347
+ defineProcess({ collect: { … }, backendAck: { url: 'http://localhost:4000/ack', allowPrivateNetwork: true } });
348
+ ```
349
+
350
+ `allowPrivateNetwork` only lifts the private-address refusal. The request is still http(s), still pinned to the checked
351
+ address and still never follows a redirect blindly.
352
+
353
+ On `httpTool` you set it in your own code, so it takes effect directly. On a `backendAck`, or on a tool in a stored
354
+ flow or process schema, it takes effect only when the worker's operator also sets **`VL_ALLOW_PRIVATE_NETWORK=1`** in
355
+ the worker's environment, because an author's flag alone shouldn't open your network. VoiceLayer-hosted workers (those
356
+ holding `INTERNAL_SERVICE_TOKEN`) and the VoiceLayer API's text engine never honour it, whatever the environment says.
357
+ Their tool steps run through the platform's server-side guard, which has no such switch.
358
+
359
+ ```bash
360
+ export VL_ALLOW_PRIVATE_NETWORK=1 # self-hosted only: honour authors' allowPrivateNetwork on schema tools / backendAck
361
+ ```
362
+
331
363
  ### Texts
332
364
 
333
- The same worker answers texts — SMS replies, Discord, your website's chat widget, the dashboard playground. `.start()`
334
- registers a second LiveKit name, `<name>::text`, beside the voice one; each message is dispatched to it and runs as
335
- your agent: its tools, security, connectors and model, with the conversation so far. Over text `ctx.call.callerId` is
336
- the sender, `ctx.call.metadata.mode` is `'text'`, and call-only methods (`handoff`, `endCall`, `ask`, DTMF) throw. In
337
- production the text registration's health server listens on `VL_TEXT_TURN_PORT` (default 8082).
365
+ `.start()` also registers a second LiveKit name, `<name>::text`, beside the voice one. A text message dispatched to it
366
+ runs as your agent: its tools, security, connectors and model, with the conversation so far. Over text
367
+ `ctx.call.callerId` is the sender, `ctx.call.metadata.mode` is `'text'`, and call-only methods (`handoff`, `endCall`,
368
+ `ask`, DTMF) throw. In production the text registration's health server listens on `VL_TEXT_TURN_PORT` (default 8082).
369
+
370
+ > **Today** VoiceLayer dispatches text turns (SMS replies, Discord, the website chat widget, the dashboard playground)
371
+ > through its own LiveKit project, so they reach VoiceLayer-hosted workers only. A worker on your own LiveKit project
372
+ > doesn't receive them yet — the turn answers `agent_offline`. For texts on a VoiceLayer number, use a hosted flow
373
+ > agent or a brain-connector agent.
338
374
 
339
375
  ---
340
376
 
@@ -1,3 +1,5 @@
1
+ import { O as OnQuery, B as BrainTransport, d as BrainMessage } from '../types-KqrAfY85.js';
2
+ export { a as BrainCallMetadata, b as BrainCapabilities, c as BrainChunk, e as BrainRequest, f as OnQueryContext, g as OnQueryResult } from '../types-KqrAfY85.js';
1
3
  import { Redis } from 'ioredis';
2
4
  import { z } from 'zod';
3
5
 
@@ -91,52 +93,6 @@ declare const ConnectorUpFrame: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
91
93
  }>]>;
92
94
  type ConnectorUpFrame = z.infer<typeof ConnectorUpFrame>;
93
95
 
94
- interface BrainMessage {
95
- readonly role: 'system' | 'user' | 'assistant';
96
- readonly content: string;
97
- }
98
- interface BrainCallMetadata {
99
- readonly channel: 'voice' | 'text';
100
- readonly callId?: string;
101
- readonly projectId?: string;
102
- }
103
- interface BrainRequest {
104
- readonly messages: readonly BrainMessage[];
105
- /** Upstream model id / alias. Omitted → the brain's own default. */
106
- readonly model?: string;
107
- readonly temperature?: number;
108
- readonly metadata: BrainCallMetadata;
109
- }
110
- /** One streamed piece of the brain's reply. */
111
- interface BrainChunk {
112
- readonly content?: string;
113
- }
114
- interface BrainCapabilities {
115
- readonly reachable: boolean;
116
- readonly streaming: boolean;
117
- }
118
- interface BrainTransport {
119
- readonly kind: 'callback' | 'http' | 'tunnel';
120
- /** Stream the reply token-by-token (the voice path: first sentence → TTS ASAP). */
121
- stream(req: BrainRequest, signal: AbortSignal): AsyncIterable<BrainChunk>;
122
- /** Non-streaming convenience (the text path). */
123
- complete(req: BrainRequest, signal: AbortSignal): Promise<string>;
124
- }
125
- /** Context handed to a customer's in-process `onQuery` brain. */
126
- interface OnQueryContext {
127
- /** Full conversation so far (system + prior turns + latest user message). */
128
- readonly messages: readonly BrainMessage[];
129
- /** Aborts when the caller barges in / the turn is cancelled. */
130
- readonly signal: AbortSignal;
131
- }
132
- type OnQueryResult = string | AsyncIterable<string>;
133
- /**
134
- * Bring-your-own brain, in-process. Receives the latest user utterance (and the
135
- * full history via `ctx.messages`) and returns the reply — either a string or an
136
- * async-iterable of string pieces for token streaming.
137
- */
138
- type OnQuery = (text: string, ctx: OnQueryContext) => OnQueryResult | Promise<OnQueryResult>;
139
-
140
96
  declare function callbackTransport(onQuery: OnQuery): BrainTransport;
141
97
 
142
98
  /** Convert a LK ChatContext (or any `{items}` shape) into BrainMessages. */
@@ -280,4 +236,4 @@ interface IncomingBrainRequest {
280
236
  /** Run one brain.request and yield the reply frames. Never throws. */
281
237
  declare function runBrainRequest(req: IncomingBrainRequest, cfg: BrainEndpointConfig, signal: AbortSignal): AsyncGenerator<ConnectorUpFrame>;
282
238
 
283
- export { type AssertUrlOptions, type BrainCallMetadata, type BrainCapabilities, type BrainChunk, BrainConfigError, type BrainEndpointConfig, type BrainMessage, type BrainPubSub, type BrainRequest, BrainRequestError, type BrainTransport, type ConnectorChatChunk, ConnectorChatModel, type ConnectorChatModelOptions, type ConnectorLLMOptions, type HostLookup, type HttpBrainTransportOptions, type IncomingBrainRequest, type OnQuery, type OnQueryContext, type OnQueryResult, RedisBrainPubSub, type SseDelta, type TunnelBrainTransportOptions, assertPublicHttpsUrl, buildRedisBrainPubSub, callbackTransport, chatChunkStream, chatContextToMessages, createConnectorLLM, httpBrainTransport, isDisallowedIp, lastUserText, parseChatCompletionSse, runBrainRequest, tunnelBrainTransport };
239
+ export { type AssertUrlOptions, BrainConfigError, type BrainEndpointConfig, BrainMessage, type BrainPubSub, BrainRequestError, BrainTransport, type ConnectorChatChunk, ConnectorChatModel, type ConnectorChatModelOptions, type ConnectorLLMOptions, type HostLookup, type HttpBrainTransportOptions, type IncomingBrainRequest, OnQuery, RedisBrainPubSub, type SseDelta, type TunnelBrainTransportOptions, assertPublicHttpsUrl, buildRedisBrainPubSub, callbackTransport, chatChunkStream, chatContextToMessages, createConnectorLLM, httpBrainTransport, isDisallowedIp, lastUserText, parseChatCompletionSse, runBrainRequest, tunnelBrainTransport };
@@ -599,7 +599,10 @@ var ProcessFieldDTO = z.object({
599
599
  min: z.number().optional(),
600
600
  max: z.number().optional(),
601
601
  enum: z.array(z.string()).max(64).optional(),
602
- ask: z.string().max(512).optional()
602
+ ask: z.string().max(512).optional(),
603
+ /** false ⇒ written by the agent itself (a tool's output, an assignment), never said by the caller — the runtime
604
+ * doesn't extract it from their words. Absent ⇒ the caller may say it. */
605
+ fromCaller: z.boolean().optional()
603
606
  });
604
607
  var ProcessTriggerDTO = z.object({
605
608
  name: z.string().min(1).max(64),
@@ -618,7 +621,12 @@ var ProcessToolDTO = z.object({
618
621
  method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
619
622
  headers: z.record(z.string().max(1024)).optional(),
620
623
  auth: z.enum(["none", "connection"]),
621
- connectionRef: z.string().max(128).optional()
624
+ connectionRef: z.string().max(128).optional(),
625
+ /**
626
+ * Self-hosted workers only: allow a private / loopback address (a localhost service of your own). A platform
627
+ * worker (it holds the internal service token) never honours it.
628
+ */
629
+ allowPrivateNetwork: z.boolean().optional()
622
630
  });
623
631
  var ProcessSchemaDTO = z.object({
624
632
  id: z.string().min(1).max(64),
@@ -632,7 +640,9 @@ var ProcessSchemaDTO = z.object({
632
640
  requiredFields: z.array(z.string()).max(64),
633
641
  backendAck: z.object({
634
642
  url: z.string().url(),
635
- timeoutMs: z.number().int().positive()
643
+ timeoutMs: z.number().int().positive(),
644
+ // self-hosted workers only (see ProcessToolDTO.allowPrivateNetwork); a platform worker never honours it
645
+ allowPrivateNetwork: z.boolean().optional()
636
646
  }).optional()
637
647
  }),
638
648
  // Deterministic rails compiled from trigger/handoff/end nodes.
@@ -650,6 +660,9 @@ var ProcessSchemaDTO = z.object({
650
660
  }).optional(),
651
661
  // Tool capabilities compiled from tool nodes (maps to AgentConfig.tools).
652
662
  tools: z.array(ProcessToolDTO).max(32).optional(),
663
+ // The agent's human name, sealed at deploy (G-31): what a caller hears for {{ agent_name }} and what the dashboard
664
+ // shows. Distinct from the agent's dispatch name (a slug). Absent on schemas deployed before it.
665
+ displayName: z.string().min(1).max(128).optional(),
653
666
  // Voice turn-taking compiled from the flow's speech capability node (maps to
654
667
  // AgentConfig.speech → applySpeechTurnHandling). Absent = platform defaults.
655
668
  speech: z.object({
@@ -1782,6 +1795,41 @@ z.object({
1782
1795
  toolCalls: z.array(ToolCallAudit),
1783
1796
  mcpInteractions: z.array(McpInteractionAudit)
1784
1797
  });
1798
+ var WEBHOOK_EVENT_TYPES = ["call.started", "call.ended", "recording.ready"];
1799
+ z.enum(WEBHOOK_EVENT_TYPES);
1800
+ var CallWebhookDirection = z.enum(["inbound", "outbound", "web"]);
1801
+ var CallWebhookOutcome = z.enum(["completed", "dropped", "failed", "no_answer", "other"]);
1802
+ var CallWebhookAttributes = z.record(z.union([z.string(), z.number(), z.boolean()]));
1803
+ var Party = z.string().min(1).nullable();
1804
+ z.object({
1805
+ callId: z.string().uuid(),
1806
+ startedAt: z.string().datetime(),
1807
+ agentId: z.string().nullable(),
1808
+ phoneNumberId: z.string().nullable(),
1809
+ callerE164: Party,
1810
+ toE164: Party,
1811
+ // Null when it can't be told yet: an inbound phone call and a web session look alike until the caller joins. When
1812
+ // set, it is the same value `call.ended` carries; `call.ended` always has one.
1813
+ direction: CallWebhookDirection.nullable()
1814
+ });
1815
+ z.object({
1816
+ callId: z.string().uuid(),
1817
+ durationMs: z.number().int().min(0),
1818
+ endedAt: z.string().datetime(),
1819
+ endReason: z.string(),
1820
+ agentId: z.string().nullable(),
1821
+ phoneNumberId: z.string().nullable(),
1822
+ callerE164: Party,
1823
+ toE164: Party,
1824
+ direction: CallWebhookDirection,
1825
+ outcome: CallWebhookOutcome,
1826
+ attributes: CallWebhookAttributes
1827
+ });
1828
+ z.object({
1829
+ callId: z.string().uuid(),
1830
+ recordingUrl: z.string().url(),
1831
+ status: z.enum(["starting", "active", "completed", "failed", "aborted"])
1832
+ });
1785
1833
  var PhoneNumberStatus = z.enum([
1786
1834
  "pending",
1787
1835
  "active",
@@ -1843,6 +1891,43 @@ z.object({
1843
1891
  perCallMaxCumulativeMs: z.number().int().min(1e3).default(3e5)
1844
1892
  })
1845
1893
  });
1894
+
1895
+ // ../contracts/src/dispatch-metadata.ts
1896
+ var IDENTITY_DISPATCH_METADATA_KEYS = [
1897
+ "projectId",
1898
+ "agentId",
1899
+ "sessionId",
1900
+ "callId",
1901
+ "phoneNumberId",
1902
+ "bindingId",
1903
+ "apiKeyId",
1904
+ "tenantId",
1905
+ // the SecurityPrimitive's identity for the call
1906
+ "moduleId",
1907
+ "toNumber",
1908
+ // fromNumber / toNumber / callerId: a warm transfer's caller ID (sdk handoff.ts ownDidFor)
1909
+ "fromNumber",
1910
+ "callerId"
1911
+ ];
1912
+ var RESERVED_DISPATCH_METADATA_KEYS = [
1913
+ ...IDENTITY_DISPATCH_METADATA_KEYS,
1914
+ "route",
1915
+ "direction",
1916
+ "carrier",
1917
+ "source",
1918
+ "mode",
1919
+ // mode + turnId would make a call's job run as a text turn
1920
+ "turnId",
1921
+ // set by the platform from its own inputs (the call's directive, the project's recording notice, AMD) — a caller's
1922
+ // copy in a metadata bag would speak a line, skip a disclosure or change answering-machine handling
1923
+ "initialDirective",
1924
+ "recordingAnnouncement",
1925
+ "amd"
1926
+ ];
1927
+ new Set(RESERVED_DISPATCH_METADATA_KEYS);
1928
+ new Set(IDENTITY_DISPATCH_METADATA_KEYS);
1929
+
1930
+ // ../contracts/src/flow-validate.ts
1846
1931
  var FlowIssueSeverity = z.enum(["error", "warning"]);
1847
1932
  var FlowIssue = z.object({
1848
1933
  code: z.string().max(64),
@@ -1935,6 +2020,25 @@ z.object({
1935
2020
  amd: OutboundAmdMode.optional()
1936
2021
  });
1937
2022
  var ConnectionAuthType = z.enum(["oauth2", "api_key", "bearer", "basic", "none"]);
2023
+ var TRANSPORT_OWNED_HEADERS = /* @__PURE__ */ new Set([
2024
+ "host",
2025
+ "content-length",
2026
+ "transfer-encoding",
2027
+ "connection",
2028
+ "keep-alive",
2029
+ "upgrade",
2030
+ "te",
2031
+ "trailer",
2032
+ "expect",
2033
+ // the transport advertises only what it can decode
2034
+ "accept-encoding"
2035
+ ]);
2036
+ function isTransportOwnedHeader(name) {
2037
+ const n = name.trim().toLowerCase();
2038
+ return TRANSPORT_OWNED_HEADERS.has(n) || n.startsWith("proxy-");
2039
+ }
2040
+ var PLATFORM_OWNED_HEADERS = { has: (n) => n === "content-type" || isTransportOwnedHeader(n) };
2041
+ z.string().trim().regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/, "a header name: letters, digits and - only, up to 64").refine((n) => !PLATFORM_OWNED_HEADERS.has(n.toLowerCase()), "the platform sets this header itself");
1938
2042
  var ConnectionStatus = z.enum(["active", "revoked", "error"]);
1939
2043
  z.object({
1940
2044
  v: z.number().int(),
@@ -2020,7 +2124,11 @@ z.object({
2020
2124
  reauthRequired: z.boolean().optional()
2021
2125
  });
2022
2126
  var E164 = z.string().regex(/^\+[1-9]\d{6,14}$/, "must be E.164 (+15551234567)");
2023
- var RoomPrefix = z.string().regex(/^[a-z0-9][a-z0-9-]{0,30}[a-z0-9-]?$/, "lowercase letters, digits, dashes; max 32");
2127
+ var PLATFORM_ROOM_NAMESPACES = ["text-", "vl-", "call-out-"];
2128
+ var RoomPrefix = z.string().regex(/^[a-z0-9][a-z0-9-]{0,30}[a-z0-9-]?$/, "lowercase letters, digits, dashes; max 32").refine(
2129
+ (v) => !PLATFORM_ROOM_NAMESPACES.some((ns) => v.startsWith(ns)),
2130
+ `must not start with a platform room namespace (${PLATFORM_ROOM_NAMESPACES.join(", ")})`
2131
+ );
2024
2132
  var RecordingPrefix = z.string().min(1).max(128).refine((v) => !v.startsWith("/") && !v.includes(".."), "relative key prefix only");
2025
2133
  var Cidr = z.string().regex(/^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/, "must be IPv4 CIDR (a.b.c.d/nn)");
2026
2134
  var TelephonySettings = z.object({
@@ -2321,7 +2429,7 @@ z.discriminatedUnion("kind", [
2321
2429
  BrainRequestFrame,
2322
2430
  BrainCancelFrame
2323
2431
  ]);
2324
- var FLOW_BOOT_FAILURES = ["schema_fetch_failed", "no_process_schema", "missing_agent_id", "no_api_key"];
2432
+ var FLOW_BOOT_FAILURES = ["schema_fetch_failed", "no_process_schema", "missing_agent_id", "no_api_key", "worker_identity_refused"];
2325
2433
  z.enum(
2326
2434
  FLOW_BOOT_FAILURES.map((cause) => `flow_boot:${cause}`)
2327
2435
  );