@voicelayer/sdk 0.1.10 → 0.2.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
@@ -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)
@@ -0,0 +1,283 @@
1
+ import { Redis } from 'ioredis';
2
+ import { z } from 'zod';
3
+
4
+ /** Frames a daemon sends up to the relay. */
5
+ declare const ConnectorUpFrame: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
6
+ kind: z.ZodLiteral<"hello">;
7
+ token: z.ZodString;
8
+ replicaId: z.ZodString;
9
+ version: z.ZodString;
10
+ caps: z.ZodObject<{
11
+ streaming: z.ZodBoolean;
12
+ tools: z.ZodBoolean;
13
+ litellm: z.ZodBoolean;
14
+ }, "strip", z.ZodTypeAny, {
15
+ tools: boolean;
16
+ streaming: boolean;
17
+ litellm: boolean;
18
+ }, {
19
+ tools: boolean;
20
+ streaming: boolean;
21
+ litellm: boolean;
22
+ }>;
23
+ }, "strip", z.ZodTypeAny, {
24
+ kind: "hello";
25
+ version: string;
26
+ token: string;
27
+ replicaId: string;
28
+ caps: {
29
+ tools: boolean;
30
+ streaming: boolean;
31
+ litellm: boolean;
32
+ };
33
+ }, {
34
+ kind: "hello";
35
+ version: string;
36
+ token: string;
37
+ replicaId: string;
38
+ caps: {
39
+ tools: boolean;
40
+ streaming: boolean;
41
+ litellm: boolean;
42
+ };
43
+ }>, z.ZodObject<{
44
+ kind: z.ZodLiteral<"pong">;
45
+ ts: z.ZodNumber;
46
+ }, "strip", z.ZodTypeAny, {
47
+ kind: "pong";
48
+ ts: number;
49
+ }, {
50
+ kind: "pong";
51
+ ts: number;
52
+ }>, z.ZodObject<{
53
+ kind: z.ZodLiteral<"brain.delta">;
54
+ streamId: z.ZodString;
55
+ content: z.ZodString;
56
+ }, "strip", z.ZodTypeAny, {
57
+ kind: "brain.delta";
58
+ content: string;
59
+ streamId: string;
60
+ }, {
61
+ kind: "brain.delta";
62
+ content: string;
63
+ streamId: string;
64
+ }>, z.ZodObject<{
65
+ kind: z.ZodLiteral<"brain.done">;
66
+ streamId: z.ZodString;
67
+ finishReason: z.ZodString;
68
+ }, "strip", z.ZodTypeAny, {
69
+ kind: "brain.done";
70
+ streamId: string;
71
+ finishReason: string;
72
+ }, {
73
+ kind: "brain.done";
74
+ streamId: string;
75
+ finishReason: string;
76
+ }>, z.ZodObject<{
77
+ kind: z.ZodLiteral<"brain.error">;
78
+ streamId: z.ZodString;
79
+ code: z.ZodEnum<["upstream_timeout", "upstream_error", "bad_response", "normalize_failed", "unreachable"]>;
80
+ message: z.ZodString;
81
+ }, "strip", z.ZodTypeAny, {
82
+ kind: "brain.error";
83
+ code: "upstream_timeout" | "upstream_error" | "bad_response" | "normalize_failed" | "unreachable";
84
+ message: string;
85
+ streamId: string;
86
+ }, {
87
+ kind: "brain.error";
88
+ code: "upstream_timeout" | "upstream_error" | "bad_response" | "normalize_failed" | "unreachable";
89
+ message: string;
90
+ streamId: string;
91
+ }>]>;
92
+ type ConnectorUpFrame = z.infer<typeof ConnectorUpFrame>;
93
+
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
+ declare function callbackTransport(onQuery: OnQuery): BrainTransport;
141
+
142
+ /** Convert a LK ChatContext (or any `{items}` shape) into BrainMessages. */
143
+ declare function chatContextToMessages(chatCtx: unknown): BrainMessage[];
144
+ /** The most recent user utterance, or '' if none. */
145
+ declare function lastUserText(messages: readonly BrainMessage[]): string;
146
+
147
+ interface ConnectorLLMOptions {
148
+ readonly model?: string;
149
+ readonly temperature?: number;
150
+ readonly callId?: string;
151
+ readonly projectId?: string;
152
+ /** Spoken instead of dead air when the brain errors before any reply. */
153
+ readonly fallbackText?: string;
154
+ }
155
+ /** One ChatChunk in the shape AgentSession consumes (subset of LK's ChatChunk). */
156
+ interface ConnectorChatChunk {
157
+ readonly id: string;
158
+ readonly delta: {
159
+ readonly role: 'assistant';
160
+ readonly content: string;
161
+ };
162
+ }
163
+ /**
164
+ * Stream a brain reply as ChatChunks. On error before any content is emitted,
165
+ * falls back to `fallbackText` if provided; otherwise rethrows. Errors after
166
+ * partial output stop the stream (the partial is already spoken).
167
+ */
168
+ declare function chatChunkStream(transport: BrainTransport, messages: ReturnType<typeof chatContextToMessages>, options: ConnectorLLMOptions, signal: AbortSignal): AsyncGenerator<ConnectorChatChunk>;
169
+ /**
170
+ * Build a ConnectorLLM instance for the pipeline's `llm` slot. The returned
171
+ * object's chat() yields the brain reply and exposes close() for barge-in.
172
+ */
173
+ declare function createConnectorLLM(transport: BrainTransport, options?: ConnectorLLMOptions): Promise<unknown>;
174
+
175
+ interface ConnectorChatModelOptions {
176
+ readonly model?: string;
177
+ }
178
+ interface ChatModelInput {
179
+ readonly messages: readonly {
180
+ readonly role: 'system' | 'user' | 'assistant';
181
+ readonly content: string;
182
+ }[];
183
+ readonly json?: boolean;
184
+ readonly temperature?: number;
185
+ readonly model?: string;
186
+ }
187
+ declare class ConnectorChatModel {
188
+ private readonly transport;
189
+ private readonly options;
190
+ constructor(transport: BrainTransport, options?: ConnectorChatModelOptions);
191
+ /** `signal` cancels the brain request (the tunnel sends `brain.cancel`); a deadline is the caller's to set. */
192
+ complete(input: ChatModelInput, signal?: AbortSignal): Promise<string>;
193
+ }
194
+
195
+ declare class BrainConfigError extends Error {
196
+ constructor(message: string);
197
+ }
198
+ /** Resolve a hostname to a list of IP strings. Injectable for tests. */
199
+ type HostLookup = (host: string) => Promise<readonly string[]>;
200
+ interface AssertUrlOptions {
201
+ /** Hostnames that bypass IP checks (explicit per-connector allowlist). */
202
+ readonly allowHosts?: readonly string[];
203
+ /** Override DNS resolution (tests). */
204
+ readonly lookup?: HostLookup;
205
+ }
206
+ /** True if `ip` is a literal address we must refuse. */
207
+ declare function isDisallowedIp(ip: string): boolean;
208
+ /**
209
+ * Validate a brain URL for direct egress. Throws BrainConfigError on any
210
+ * violation; returns the parsed URL when safe.
211
+ */
212
+ declare function assertPublicHttpsUrl(raw: string, opts?: AssertUrlOptions): Promise<URL>;
213
+
214
+ interface HttpBrainTransportOptions {
215
+ readonly baseUrl: string;
216
+ readonly apiKey?: string;
217
+ readonly defaultModel?: string;
218
+ /** Hostnames that bypass the IP checks (tests, an allow-listed private brain). */
219
+ readonly allowHosts?: readonly string[];
220
+ /** Test seams. */
221
+ readonly fetchImpl?: typeof fetch;
222
+ readonly lookup?: HostLookup;
223
+ }
224
+ declare function httpBrainTransport(opts: HttpBrainTransportOptions): BrainTransport;
225
+
226
+ declare class BrainRequestError extends Error {
227
+ readonly code: string;
228
+ constructor(code: string, message: string);
229
+ }
230
+
231
+ interface BrainPubSub {
232
+ publish(channel: string, message: string): Promise<void>;
233
+ /** Subscribe to a channel; returns an unsubscribe handle. */
234
+ subscribe(channel: string, handler: (raw: string) => void): Promise<() => Promise<void>>;
235
+ }
236
+ interface TunnelBrainTransportOptions {
237
+ readonly connectorId: string;
238
+ readonly pubsub: BrainPubSub;
239
+ /** Fail the turn if the daemon sends nothing within this window. Default 20s. */
240
+ readonly firstChunkTimeoutMs?: number;
241
+ }
242
+ declare function tunnelBrainTransport(options: TunnelBrainTransportOptions): BrainTransport;
243
+
244
+ declare class RedisBrainPubSub implements BrainPubSub {
245
+ private readonly sub;
246
+ private readonly pub;
247
+ private readonly listeners;
248
+ constructor(sub: Redis, pub: Redis);
249
+ publish(channel: string, message: string): Promise<void>;
250
+ subscribe(channel: string, handler: (raw: string) => void): Promise<() => Promise<void>>;
251
+ close(): Promise<void>;
252
+ }
253
+ declare function buildRedisBrainPubSub(redisUrl: string | undefined): RedisBrainPubSub | null;
254
+
255
+ interface SseDelta {
256
+ readonly content?: string;
257
+ readonly finishReason?: string;
258
+ }
259
+ declare function parseChatCompletionSse(source: AsyncIterable<Uint8Array> | ReadableStream<Uint8Array>): AsyncGenerator<SseDelta>;
260
+
261
+ interface BrainEndpointConfig {
262
+ /** Base URL of the OpenAI-compatible endpoint, e.g. http://localhost:4000/v1. */
263
+ readonly brainUrl: string;
264
+ readonly apiKey?: string;
265
+ readonly defaultModel?: string;
266
+ /** Injectable for tests; defaults to global fetch. */
267
+ readonly fetchImpl?: typeof fetch;
268
+ /** Refuse redirects (direct egress from VoiceLayer's own servers — the SSRF rule). Default: follow (the daemon). */
269
+ readonly refuseRedirects?: boolean;
270
+ }
271
+ interface IncomingBrainRequest {
272
+ readonly streamId: string;
273
+ readonly model?: string;
274
+ readonly temperature?: number;
275
+ readonly messages: ReadonlyArray<{
276
+ role: 'system' | 'user' | 'assistant';
277
+ content: string;
278
+ }>;
279
+ }
280
+ /** Run one brain.request and yield the reply frames. Never throws. */
281
+ declare function runBrainRequest(req: IncomingBrainRequest, cfg: BrainEndpointConfig, signal: AbortSignal): AsyncGenerator<ConnectorUpFrame>;
282
+
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 };