@pinecall/skills 0.1.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 +65 -0
- package/build.mjs +204 -0
- package/package.json +29 -0
- package/skills/pinecall-concepts/SKILL.md +41 -0
- package/skills/pinecall-concepts/references/concepts/agents-and-channels.md +155 -0
- package/skills/pinecall-concepts/references/concepts/deployment-topologies.md +120 -0
- package/skills/pinecall-concepts/references/concepts/hot-reload.md +119 -0
- package/skills/pinecall-concepts/references/concepts/philosophy.md +100 -0
- package/skills/pinecall-concepts/references/concepts/server-vs-client-llm.md +119 -0
- package/skills/pinecall-examples/SKILL.md +59 -0
- package/skills/pinecall-examples/references/examples/browser-widget.md +206 -0
- package/skills/pinecall-examples/references/examples/chat-bot.md +184 -0
- package/skills/pinecall-examples/references/examples/headless-agent.md +121 -0
- package/skills/pinecall-examples/references/examples/index.md +183 -0
- package/skills/pinecall-examples/references/examples/multi-channel-bot.md +173 -0
- package/skills/pinecall-examples/references/examples/outbound-dispatch.md +109 -0
- package/skills/pinecall-examples/references/examples/turn-detection.md +150 -0
- package/skills/pinecall-guides/SKILL.md +68 -0
- package/skills/pinecall-guides/references/guides/call-ringing.md +149 -0
- package/skills/pinecall-guides/references/guides/conversation-history.md +377 -0
- package/skills/pinecall-guides/references/guides/dev-mode.md +130 -0
- package/skills/pinecall-guides/references/guides/events.md +677 -0
- package/skills/pinecall-guides/references/guides/human-takeover.md +184 -0
- package/skills/pinecall-guides/references/guides/inbound-voice.md +201 -0
- package/skills/pinecall-guides/references/guides/knowledge-bases.md +166 -0
- package/skills/pinecall-guides/references/guides/live-listening.md +199 -0
- package/skills/pinecall-guides/references/guides/multi-tenant.md +158 -0
- package/skills/pinecall-guides/references/guides/outbound-calls.md +279 -0
- package/skills/pinecall-guides/references/guides/sse-streaming.md +207 -0
- package/skills/pinecall-guides/references/guides/testing-agents.md +272 -0
- package/skills/pinecall-guides/references/guides/tools-and-functions.md +254 -0
- package/skills/pinecall-guides/references/guides/webrtc-browser.md +200 -0
- package/skills/pinecall-guides/references/guides/whatsapp.md +370 -0
- package/skills/pinecall-guides/references/guides/ws-streaming.md +235 -0
- package/skills/pinecall-quickstart/SKILL.md +54 -0
- package/skills/pinecall-quickstart/references/index.md +123 -0
- package/skills/pinecall-quickstart/references/quickstart.md +185 -0
- package/skills/pinecall-reference/SKILL.md +43 -0
- package/skills/pinecall-reference/references/reference/cli.md +578 -0
- package/skills/pinecall-reference/references/reference/events.md +366 -0
- package/skills/pinecall-reference/references/reference/llm-providers.md +263 -0
- package/skills/pinecall-reference/references/reference/rest-api.md +122 -0
- package/skills/pinecall-reference/references/reference/session-limits.md +119 -0
- package/skills/pinecall-reference/references/reference/stt-providers.md +174 -0
- package/skills/pinecall-reference/references/reference/tts-providers.md +149 -0
- package/skills/pinecall-sdk-api/SKILL.md +56 -0
- package/skills/pinecall-sdk-api/references/api/agent.md +328 -0
- package/skills/pinecall-sdk-api/references/api/call.md +324 -0
- package/skills/pinecall-sdk-api/references/api/pinecall.md +186 -0
- package/skills/pinecall-sdk-api/references/api/reply-stream.md +148 -0
- package/skills/pinecall-security/SKILL.md +37 -0
- package/skills/pinecall-security/references/security.md +138 -0
- package/skills/pinecall-web-chat/SKILL.md +38 -0
- package/skills/pinecall-web-chat/references/web/chat/chat-session.md +178 -0
- package/skills/pinecall-web-chat/references/web/chat/overview.md +98 -0
- package/skills/pinecall-web-components/SKILL.md +37 -0
- package/skills/pinecall-web-components/references/web/components/overview.md +128 -0
- package/skills/pinecall-web-voice/SKILL.md +40 -0
- package/skills/pinecall-web-voice/references/web/core/datachannel-protocol.md +149 -0
- package/skills/pinecall-web-voice/references/web/core/overview.md +70 -0
- package/skills/pinecall-web-voice/references/web/core/state-and-phases.md +153 -0
- package/skills/pinecall-web-voice/references/web/core/voice-session.md +279 -0
- package/skills/pinecall-web-widget/SKILL.md +41 -0
- package/skills/pinecall-web-widget/references/web/widget/overview.md +67 -0
- package/skills/pinecall-web-widget/references/web/widget/props.md +291 -0
- package/skills/pinecall-web-widget/references/web/widget/theming.md +131 -0
- package/skills/pinecall-web-widget/references/web/widget/tools-api.md +381 -0
- package/skills/pinecall-web-widget/references/web/widget/use-voice-session-hook.md +130 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Live Listening"
|
|
3
|
+
description: "Listen to active calls in real-time from a browser or custom client."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Live Listening
|
|
7
|
+
|
|
8
|
+
Monitor active calls in real-time. Pinecall mixes both sides of the conversation (user + bot) into a single audio stream accessible via WebSocket.
|
|
9
|
+
|
|
10
|
+
## Enable media
|
|
11
|
+
|
|
12
|
+
Add `media` to your agent config to enable live listening, recording, or both:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
const agent = pc.agent("support", {
|
|
16
|
+
prompt: "You are a support agent.",
|
|
17
|
+
voice: "elevenlabs/sarah",
|
|
18
|
+
llm: "openai/gpt-5-chat-latest",
|
|
19
|
+
stt: "deepgram/flux",
|
|
20
|
+
media: {
|
|
21
|
+
live: true, // enables real-time WebSocket stream
|
|
22
|
+
recording: true, // keeps full call recording in memory
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
When a call starts, you can build a live listening URL from the call ID:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
agent.on("call.started", (call) => {
|
|
31
|
+
const url = `https://voice.pinecall.io/live/${call.id}/player?token=${API_KEY}`;
|
|
32
|
+
console.log(`Listen live: ${url}`);
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Built-in player
|
|
37
|
+
|
|
38
|
+
Pinecall provides a hosted player page. Open the URL in any browser:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
https://voice.pinecall.io/live/{callId}/player?token=pk_xxx
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The page connects via WebSocket and plays the mixed audio through an AudioWorklet with minimal latency. No dependencies or setup needed.
|
|
45
|
+
|
|
46
|
+
## Authentication
|
|
47
|
+
|
|
48
|
+
All live listening endpoints require a valid Pinecall API key passed as a `token` query parameter. The key must belong to the same organization as the active session.
|
|
49
|
+
|
|
50
|
+
| Endpoint | Auth |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `GET /live/{id}/player?token=pk_xxx` | API key in query param |
|
|
53
|
+
| `WS /live/{id}/ws?token=pk_xxx` | API key in query param |
|
|
54
|
+
|
|
55
|
+
Without a valid token the server returns `401`.
|
|
56
|
+
|
|
57
|
+
## Build a custom player
|
|
58
|
+
|
|
59
|
+
If you need a custom UI or integration, connect directly to the WebSocket endpoint.
|
|
60
|
+
|
|
61
|
+
### WebSocket protocol
|
|
62
|
+
|
|
63
|
+
**Connect:**
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
wss://voice.pinecall.io/live/{callId}/ws?token=pk_xxx
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**First message** — JSON metadata:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"type": "metadata",
|
|
74
|
+
"sampleRate": 8000,
|
|
75
|
+
"channels": 1,
|
|
76
|
+
"bitDepth": 16,
|
|
77
|
+
"sessionId": "CA..."
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Subsequent messages** — binary frames containing raw PCM audio:
|
|
82
|
+
- Format: 16-bit signed little-endian (Int16LE), mono
|
|
83
|
+
- Sample rate: `8000` for Twilio calls, `16000` for WebRTC calls
|
|
84
|
+
- Chunk size: ~800 bytes per frame (50ms at 8kHz)
|
|
85
|
+
|
|
86
|
+
**End of call** — the server sends an empty binary frame (`0 bytes`) and closes the connection.
|
|
87
|
+
|
|
88
|
+
**Keepalive** — during silence the server sends a 2-byte zero frame every 5 seconds.
|
|
89
|
+
|
|
90
|
+
### Browser example (AudioWorklet)
|
|
91
|
+
|
|
92
|
+
This is a minimal browser implementation. It connects to the WebSocket, converts PCM Int16 to Float32, and plays through an AudioWorklet:
|
|
93
|
+
|
|
94
|
+
```javascript
|
|
95
|
+
// 1. Create the AudioWorklet processor
|
|
96
|
+
const PROCESSOR = `
|
|
97
|
+
class Player extends AudioWorkletProcessor {
|
|
98
|
+
constructor() {
|
|
99
|
+
super();
|
|
100
|
+
this._q = [];
|
|
101
|
+
this.port.onmessage = (e) => this._q.push(e.data.samples);
|
|
102
|
+
}
|
|
103
|
+
process(inputs, outputs) {
|
|
104
|
+
const out = outputs[0][0];
|
|
105
|
+
let i = 0;
|
|
106
|
+
while (i < out.length && this._q.length) {
|
|
107
|
+
const chunk = this._q[0];
|
|
108
|
+
const take = Math.min(chunk.length, out.length - i);
|
|
109
|
+
out.set(chunk.subarray(0, take), i);
|
|
110
|
+
i += take;
|
|
111
|
+
if (take === chunk.length) this._q.shift();
|
|
112
|
+
else this._q[0] = chunk.subarray(take);
|
|
113
|
+
}
|
|
114
|
+
for (; i < out.length; i++) out[i] = 0;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
registerProcessor('player', Player);
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
// 2. Set up AudioContext + Worklet
|
|
122
|
+
async function listen(callId, token) {
|
|
123
|
+
const ctx = new AudioContext({ sampleRate: 8000 });
|
|
124
|
+
const blob = new Blob([PROCESSOR], { type: 'application/javascript' });
|
|
125
|
+
await ctx.audioWorklet.addModule(URL.createObjectURL(blob));
|
|
126
|
+
const node = new AudioWorkletNode(ctx, 'player');
|
|
127
|
+
node.connect(ctx.destination);
|
|
128
|
+
|
|
129
|
+
// 3. Connect WebSocket
|
|
130
|
+
const ws = new WebSocket(
|
|
131
|
+
`wss://voice.pinecall.io/live/${callId}/ws?token=${token}`
|
|
132
|
+
);
|
|
133
|
+
ws.binaryType = 'arraybuffer';
|
|
134
|
+
|
|
135
|
+
ws.onmessage = (e) => {
|
|
136
|
+
if (typeof e.data === 'string') return; // metadata frame
|
|
137
|
+
|
|
138
|
+
const pcm = new Int16Array(e.data);
|
|
139
|
+
if (pcm.length < 2) return; // keepalive
|
|
140
|
+
|
|
141
|
+
// Convert Int16 → Float32
|
|
142
|
+
const f32 = new Float32Array(pcm.length);
|
|
143
|
+
for (let i = 0; i < pcm.length; i++) f32[i] = pcm[i] / 32768;
|
|
144
|
+
|
|
145
|
+
node.port.postMessage({ samples: f32 });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
ws.onclose = () => ctx.close();
|
|
149
|
+
|
|
150
|
+
return { stop: () => { ws.close(); ctx.close(); } };
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Node.js example
|
|
155
|
+
|
|
156
|
+
Stream live audio to a file or pipe it to another process:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import WebSocket from "ws";
|
|
160
|
+
import { createWriteStream } from "fs";
|
|
161
|
+
|
|
162
|
+
const ws = new WebSocket(
|
|
163
|
+
`wss://voice.pinecall.io/live/${callId}/ws?token=${apiKey}`
|
|
164
|
+
);
|
|
165
|
+
const out = createWriteStream("call.pcm");
|
|
166
|
+
|
|
167
|
+
ws.on("message", (data, isBinary) => {
|
|
168
|
+
if (!isBinary) return; // skip metadata
|
|
169
|
+
if (data.length < 4) return; // skip keepalive
|
|
170
|
+
out.write(data);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
ws.on("close", () => {
|
|
174
|
+
out.end();
|
|
175
|
+
// Convert to WAV: ffmpeg -f s16le -ar 8000 -ac 1 -i call.pcm call.wav
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Media config reference
|
|
180
|
+
|
|
181
|
+
| Field | Type | Default | Description |
|
|
182
|
+
|---|---|---|---|
|
|
183
|
+
| `live` | `boolean` | `false` | Enable real-time WebSocket streaming |
|
|
184
|
+
| `recording` | `boolean` | `false` | Keep full mixed audio in memory |
|
|
185
|
+
| `maxDurationSeconds` | `number` | `1800` | Max recording length (30 min) |
|
|
186
|
+
|
|
187
|
+
## How it works
|
|
188
|
+
|
|
189
|
+
The server maintains two audio buffers — one for user (mic) audio and one for bot (TTS) audio. A background task runs every 50ms, mixing both buffers into a single PCM stream. When bot audio arrives later in the call (e.g., after a greeting delay), the mixer automatically inserts silence to keep the timelines aligned.
|
|
190
|
+
|
|
191
|
+
On barge-in (user interrupts bot), the bot's remaining audio is discarded and the mixer pads with silence to maintain alignment.
|
|
192
|
+
|
|
193
|
+
Live listeners subscribe to the mixed output and receive chunks as they're produced. Recording captures the full mixed buffer for export after the call ends.
|
|
194
|
+
|
|
195
|
+
## What's next
|
|
196
|
+
|
|
197
|
+
- [Inbound Voice](/guides/inbound-voice) — build a phone agent
|
|
198
|
+
- [Tools and Functions](/guides/tools-and-functions) — let the agent take actions
|
|
199
|
+
- [Events Reference](/reference/events) — all SDK events
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Multi-Tenant Dashboards"
|
|
3
|
+
description: "Host many tenants on one Pinecall instance with scoped event streams."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Multi-Tenant Dashboards
|
|
7
|
+
|
|
8
|
+
A common pattern: you're building a SaaS where each customer has their own agents, and each customer's dashboard should only show their own calls. Pinecall's SSE filtering handles this server-side — no data leakage between tenants.
|
|
9
|
+
|
|
10
|
+
## The pattern
|
|
11
|
+
|
|
12
|
+
Each tenant owns one or more agents. When a tenant loads their dashboard, the SSE endpoint streams only events from their agents.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
## Building it
|
|
17
|
+
|
|
18
|
+
### 1. Store the agent-tenant mapping
|
|
19
|
+
|
|
20
|
+
In your existing app database, track which agents belong to which tenant:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// e.g. in your tenants table
|
|
24
|
+
{
|
|
25
|
+
id: "tenant_acme",
|
|
26
|
+
name: "Acme Corp",
|
|
27
|
+
agents: ["acme-support", "acme-sales"],
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Spin up the agents
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { Pinecall } from "@pinecall/sdk";
|
|
35
|
+
|
|
36
|
+
const pc = new Pinecall({ apiKey: process.env.PINECALL_API_KEY! });
|
|
37
|
+
|
|
38
|
+
const tenants = await db.tenants.findAll();
|
|
39
|
+
|
|
40
|
+
for (const tenant of tenants) {
|
|
41
|
+
for (const agentId of tenant.agents) {
|
|
42
|
+
const config = await db.agentConfigs.findOne(agentId);
|
|
43
|
+
pc.agent(agentId, {
|
|
44
|
+
prompt: config.prompt,
|
|
45
|
+
llm: config.llm,
|
|
46
|
+
voice: config.voice,
|
|
47
|
+
language: config.language,
|
|
48
|
+
phoneNumber: config.phoneNumber,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 3. Stream events scoped to the user's tenant
|
|
55
|
+
|
|
56
|
+
`pc.stream()` accepts an `agents` filter. Pass only the agents this user is allowed to see:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
app.get("/api/events", authMiddleware, (req, res) => {
|
|
60
|
+
const userId = req.auth.userId;
|
|
61
|
+
const tenantId = req.auth.tenantId;
|
|
62
|
+
|
|
63
|
+
// Look up which agents this tenant owns
|
|
64
|
+
const tenant = req.cache.tenants.get(tenantId);
|
|
65
|
+
const allowedAgents = tenant?.agents ?? [];
|
|
66
|
+
|
|
67
|
+
if (allowedAgents.length === 0) {
|
|
68
|
+
res.status(403).end();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Subscribe only to those agents — events from other tenants never reach the stream
|
|
73
|
+
pc.stream(res, { agents: allowedAgents });
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The filter is **server-side**. Events from agents the user doesn't own never touch the wire. There's no data leakage possible from the client.
|
|
78
|
+
|
|
79
|
+
### 4. Consume the stream in the browser
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
const source = new EventSource("/api/events");
|
|
83
|
+
|
|
84
|
+
source.addEventListener("call.started", (e) => {
|
|
85
|
+
const { agent, from, transport } = JSON.parse(e.data);
|
|
86
|
+
showCallNotification(`[${agent}] Incoming from ${from}`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
source.addEventListener("user.message", (e) => {
|
|
90
|
+
const { agent, callId, text } = JSON.parse(e.data);
|
|
91
|
+
appendToTranscript(callId, "user", text);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
source.addEventListener("bot.speaking", (e) => {
|
|
95
|
+
const { agent, callId, text } = JSON.parse(e.data);
|
|
96
|
+
appendToTranscript(callId, "bot", text);
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Per-tenant token endpoints
|
|
101
|
+
|
|
102
|
+
The same pattern applies to WebRTC and chat tokens. Each tenant can only mint tokens for their own agents:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
app.get("/api/token", authMiddleware, async (req, res) => {
|
|
106
|
+
const { agentId, channel } = req.query;
|
|
107
|
+
const tenant = req.cache.tenants.get(req.auth.tenantId);
|
|
108
|
+
|
|
109
|
+
if (!tenant.agents.includes(agentId)) {
|
|
110
|
+
return res.status(403).json({ error: "Forbidden" });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const agent = pc.getAgent(agentId);
|
|
114
|
+
const token = await agent.createToken(channel);
|
|
115
|
+
res.json(token);
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Per-tenant tool isolation
|
|
120
|
+
|
|
121
|
+
Tools also need to be tenant-aware. Since tools are registered per agent, build them with a factory that closes over the tenant — each agent gets its own tenant-scoped tool:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { tool } from "@pinecall/sdk";
|
|
125
|
+
import { z } from "zod";
|
|
126
|
+
|
|
127
|
+
function lookupOrderTool(tenantId) {
|
|
128
|
+
const tenantDb = db.scope(tenantId);
|
|
129
|
+
return tool({
|
|
130
|
+
name: "lookupOrder",
|
|
131
|
+
description: "Look up an order by ID",
|
|
132
|
+
schema: z.object({ orderId: z.string() }),
|
|
133
|
+
execute: async ({ orderId }) => {
|
|
134
|
+
return await tenantDb.orders.findOne(orderId);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// When spinning up each agent, pass its tenant-scoped tools:
|
|
140
|
+
pc.agent(agentId, {
|
|
141
|
+
prompt: config.prompt,
|
|
142
|
+
tools: [lookupOrderTool(tenant.id)],
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Scaling considerations
|
|
147
|
+
|
|
148
|
+
A single `Pinecall` instance handles dozens to hundreds of agents on one WebSocket. For larger fleets:
|
|
149
|
+
|
|
150
|
+
- **Split by region** — run one `Pinecall` instance per geographic region, route tenants to the nearest
|
|
151
|
+
- **Split by tier** — separate processes for free/paid tiers to isolate resource limits
|
|
152
|
+
- **Split by capability** — one process for voice-only tenants, another for WhatsApp-heavy tenants
|
|
153
|
+
|
|
154
|
+
## What's next
|
|
155
|
+
|
|
156
|
+
- [Deployment topologies](/concepts/deployment-topologies) — embedded is required for SSE
|
|
157
|
+
- [Security](/security) — token model details
|
|
158
|
+
- [Events reference](/reference/events) — all events available over SSE
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Outbound Calls"
|
|
3
|
+
description: "Make programmatic outbound phone calls with a greeting and metadata."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Outbound Calls
|
|
7
|
+
|
|
8
|
+
Pinecall agents can place outbound calls. Use it for appointment reminders, follow-ups, surveys, or any flow where the agent is the one initiating contact.
|
|
9
|
+
|
|
10
|
+
## The minimum example
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
const call = await agent.dial({
|
|
14
|
+
to: "+14155551234",
|
|
15
|
+
from: "+13186330963",
|
|
16
|
+
greeting: "Hi! This is a follow-up call from Acme.",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
call.on("call.ended", (_, reason) => {
|
|
20
|
+
console.log(`Done: ${reason}`);
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`agent.dial()` returns a `Promise<Call>` — same `Call` object you get from `call.started`.
|
|
25
|
+
|
|
26
|
+
## How the greeting works
|
|
27
|
+
|
|
28
|
+
Unlike inbound calls (where you use `call.say()` in `call.started`), outbound calls take a `greeting` string. The server speaks it via TTS the instant the callee picks up — no roundtrip through your code, no race condition between picking up and greeting.
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
await agent.dial({
|
|
32
|
+
to: "+14155551234",
|
|
33
|
+
from: "+13186330963",
|
|
34
|
+
greeting: "Hi, this is Mara from Acme calling to confirm your appointment tomorrow at 3 PM.",
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
After the greeting, the conversation continues normally — `turn.end`, `llm.toolCall`, etc. all fire as on inbound calls.
|
|
39
|
+
|
|
40
|
+
## Required fields
|
|
41
|
+
|
|
42
|
+
| Field | Type | Required | Description |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `to` | `string` | ✅ | Destination number in E.164 format |
|
|
45
|
+
| `from` | `string` | — | Caller ID — auto-resolved if agent has one phone channel. Required when multiple. |
|
|
46
|
+
| `greeting` | `string` | — | Text the server speaks when the callee picks up |
|
|
47
|
+
| `metadata` | `object` | — | Custom data attached to the call (visible on the `Call` object) |
|
|
48
|
+
| `config` | `object` | — | Per-call config override (voice, STT, language) |
|
|
49
|
+
| `detectTurnEnd` | `boolean` | — | Relay the OTHER party's end-of-turn (`turn.end`) to *your* code. Default `false`. See below. |
|
|
50
|
+
|
|
51
|
+
> **Tip:** If your agent has exactly one phone channel, you can omit `from` — the SDK auto-resolves it. Only pass `from` explicitly when the agent has multiple phone numbers.
|
|
52
|
+
|
|
53
|
+
## `detectTurnEnd` — knowing when the other party stops talking
|
|
54
|
+
|
|
55
|
+
By default an outbound call works like an inbound one: the **server** runs turn
|
|
56
|
+
detection on the callee, decides when they've finished a sentence, and the agent's
|
|
57
|
+
own pipeline (LLM → TTS) replies automatically. Your code doesn't need to be told
|
|
58
|
+
"they stopped talking" — the server already acted on it.
|
|
59
|
+
|
|
60
|
+
`detectTurnEnd` controls whether that end-of-turn signal is **also relayed to your
|
|
61
|
+
SDK code** as a `turn.end` event:
|
|
62
|
+
|
|
63
|
+
| Value | What the server does | Use it when |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `false` *(default)* | Detects the callee's turns internally and lets the agent's own LLM reply. No `turn.end` is emitted to your code. | A normal call — the agent (or a human on the line) handles the conversation. You don't need to know turn boundaries in code. |
|
|
66
|
+
| `true` | Additionally runs turn detection on the **callee** and emits `turn.end` (plus `eager.turn` / `turn.pause`) to the initiating side. | Your code is the one driving the conversation and must know *exactly* when the other side finished — e.g. an automated/test/judge agent that speaks with `call.say()` instead of a server LLM. |
|
|
67
|
+
|
|
68
|
+
In short: `false` = the agent talks for itself, you stay hands-off. `true` = your
|
|
69
|
+
code is puppeting the call and needs the turn signal to decide when to speak.
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Driving the call by hand: react to the callee finishing a turn.
|
|
73
|
+
const call = await agent.dial({ to: "+14155551234", detectTurnEnd: true });
|
|
74
|
+
|
|
75
|
+
call.on("user.message", (e) => {/* what the callee said */});
|
|
76
|
+
call.on("turn.end", () => {
|
|
77
|
+
call.say("Got it — let me confirm that for you.");
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Under the hood this just adds `detect_turn_end: true` to the dial request; nothing
|
|
82
|
+
else about the call changes. For agent-to-agent (`agent.bridge`) the default is the
|
|
83
|
+
opposite — `true` — because the initiator is *always* code-driven there (see below).
|
|
84
|
+
|
|
85
|
+
## Agent-to-agent voice (`agent.bridge`)
|
|
86
|
+
|
|
87
|
+
To have one Pinecall agent hold a **voice** conversation with **another** Pinecall
|
|
88
|
+
agent — no phone, no WebRTC — use `agent.bridge(target)`. The server cross-wires
|
|
89
|
+
the two agents' audio (each side's TTS becomes the other's incoming audio), so
|
|
90
|
+
both run their real STT/turn-detection/TTS pipelines. The calling agent is driven
|
|
91
|
+
manually: speak with `call.say()`, read the target via `user.message` / `turn.end`.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// The judge has voice + STT but no server-side LLM — your code is its brain.
|
|
95
|
+
const judge = pc.agent("judge", { voice: "elevenlabs/sarah", stt: "deepgram/flux" });
|
|
96
|
+
await pc.ready;
|
|
97
|
+
|
|
98
|
+
const call = await judge.bridge("pines", { detectTurnEnd: true });
|
|
99
|
+
|
|
100
|
+
call.on("user.message", (e) => {/* what the judge HEARD the target say */});
|
|
101
|
+
call.on("turn.end", () => {/* target finished → take your turn */ call.say("…"); });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`detectTurnEnd` (default `true` for `bridge`, `false` for `dial`) makes the server
|
|
105
|
+
emit the other party's end-of-turn (`turn.end`, `source: "bot"`) to the initiator,
|
|
106
|
+
so an automated caller knows when to speak. This is what powers voice-mode
|
|
107
|
+
`pinecall test`.
|
|
108
|
+
|
|
109
|
+
## Attaching metadata
|
|
110
|
+
|
|
111
|
+
Use `metadata` to carry context from your scheduling system into the call. It's available as `call.metadata` throughout the call.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const call = await agent.dial({
|
|
115
|
+
to: "+14155551234",
|
|
116
|
+
from: "+13186330963",
|
|
117
|
+
greeting: "Hi! This is Mara with a quick reminder about your appointment.",
|
|
118
|
+
metadata: {
|
|
119
|
+
appointmentId: "appt_001",
|
|
120
|
+
patientName: "Maria",
|
|
121
|
+
doctorName: "Dr. García",
|
|
122
|
+
appointmentTime: "2026-06-01T15:00:00Z",
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
agent.on("call.started", async (call) => {
|
|
127
|
+
if (call.direction === "outbound" && call.metadata?.patientName) {
|
|
128
|
+
await call.setPromptVars({
|
|
129
|
+
patient: call.metadata.patientName,
|
|
130
|
+
doctor: call.metadata.doctorName,
|
|
131
|
+
time: call.metadata.appointmentTime,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Per-call config overrides
|
|
138
|
+
|
|
139
|
+
Override voice, STT, or language for a specific outbound call. The agent's defaults stay untouched.
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const call = await agent.dial({
|
|
143
|
+
to: "+34611234567",
|
|
144
|
+
from: "+13186330963",
|
|
145
|
+
greeting: "¡Hola! Te llamo para confirmar tu cita.",
|
|
146
|
+
config: {
|
|
147
|
+
voice: "elevenlabs/valentina",
|
|
148
|
+
language: "es",
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Running a campaign
|
|
154
|
+
|
|
155
|
+
To call a list of people, just loop:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
const recipients = await db.appointments.dueForReminder();
|
|
159
|
+
|
|
160
|
+
for (const r of recipients) {
|
|
161
|
+
try {
|
|
162
|
+
const call = await agent.dial({
|
|
163
|
+
to: r.phone,
|
|
164
|
+
from: "+13186330963",
|
|
165
|
+
greeting: `Hi ${r.name}, this is a quick reminder about your appointment tomorrow at ${r.time}.`,
|
|
166
|
+
metadata: { appointmentId: r.id },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
call.on("call.ended", async (_, reason) => {
|
|
170
|
+
await db.appointments.markReminderSent(r.id, reason);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// throttle to avoid hammering the network
|
|
174
|
+
await new Promise((res) => setTimeout(res, 1000));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`Failed to dial ${r.phone}:`, err);
|
|
177
|
+
await db.appointments.markReminderFailed(r.id, err.message);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
For production campaigns, add: concurrency limits, retry logic, time-of-day enforcement, do-not-call list filtering, and call result logging.
|
|
183
|
+
|
|
184
|
+
## Handling no-answer / busy / rejected
|
|
185
|
+
|
|
186
|
+
When the callee doesn't pick up or rejects, `dial()` rejects immediately with the Twilio reason — no 30-second timeout:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
try {
|
|
190
|
+
const call = await agent.dial({ to: "+14155551234" });
|
|
191
|
+
// Call connected — run your logic
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// err.message is one of: "no-answer", "busy", "failed", "canceled", "Dial timeout"
|
|
194
|
+
console.log(`Call failed: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
If the call connects and then ends, `call.ended` fires with the reason:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
agent.on("call.ended", (call, reason) => {
|
|
202
|
+
// reason: "hangup", "disconnected", "idle_timeout", "max_duration", etc.
|
|
203
|
+
console.log(`Call ended: ${reason} (${call.duration}s)`);
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Running a campaign with `@pinecall/dispatch`
|
|
208
|
+
|
|
209
|
+
For production outbound campaigns, use the `@pinecall/dispatch` library. It handles rate limiting, concurrency control, deduplication by phone, and call result tracking.
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
npm install @pinecall/dispatch
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { DispatchHub, CsvStrategy } from "@pinecall/dispatch";
|
|
217
|
+
|
|
218
|
+
const csv = new CsvStrategy({
|
|
219
|
+
file: "./leads.csv",
|
|
220
|
+
mapRow: (row) => {
|
|
221
|
+
if (!row.phone || row.status) return null; // Skip processed rows
|
|
222
|
+
return {
|
|
223
|
+
id: `${row.phone}-${row.service}-${row.date}`,
|
|
224
|
+
phone: row.phone,
|
|
225
|
+
greeting: `Hi ${row.name}, this is a reminder about your appointment on ${row.date}.`,
|
|
226
|
+
metadata: { name: row.name, service: row.service },
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const hub = new DispatchHub({
|
|
232
|
+
agent,
|
|
233
|
+
strategies: [csv],
|
|
234
|
+
from: "+13186330963",
|
|
235
|
+
maxCallsPerMinute: 5,
|
|
236
|
+
maxConcurrent: 2,
|
|
237
|
+
retryAttempts: 1,
|
|
238
|
+
pollIntervalMs: 5000,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
hub.start();
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### What `DispatchHub` does
|
|
245
|
+
|
|
246
|
+
| Feature | Description |
|
|
247
|
+
|---|---|
|
|
248
|
+
| **Hot-reload** | Re-reads the CSV on every poll — add rows while it's running |
|
|
249
|
+
| **Dedup by phone** | Won't call the same phone twice simultaneously |
|
|
250
|
+
| **Dedup by ID** | Won't re-dispatch a record that's already been handled |
|
|
251
|
+
| **Rate limiting** | Configurable calls per minute (sliding window) |
|
|
252
|
+
| **Concurrency** | Max simultaneous active calls |
|
|
253
|
+
| **Lifecycle callbacks** | `onDispatched`, `onCompleted`, `onFailed`, `onSkipped` |
|
|
254
|
+
|
|
255
|
+
### Strategy callbacks
|
|
256
|
+
|
|
257
|
+
Override callbacks on the strategy to react to call lifecycle events:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
csv.onCompleted = (record, callId, reason) => {
|
|
261
|
+
writeResultToCsv(record.phone, reason); // "hangup", "no-answer", etc.
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
csv.onFailed = (record, error) => {
|
|
265
|
+
writeResultToCsv(record.phone, "no_answer");
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
csv.onSkipped = (record, reason) => {
|
|
269
|
+
console.log(`Skipped ${record.phone}: ${reason}`); // "duplicate"
|
|
270
|
+
};
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
> **See the full working example:** [`examples/outbound-dispatch/`](https://github.com/pinecall/sdk/tree/main/examples/outbound-dispatch) — CSV-driven appointment reminders with a `confirm_appointment` tool that writes results back to the CSV.
|
|
274
|
+
|
|
275
|
+
## What's next
|
|
276
|
+
|
|
277
|
+
- [Inbound voice](/guides/inbound-voice) — for receiving calls
|
|
278
|
+
- [Tools and Functions](/guides/tools-and-functions) — let the outbound agent act on responses (book a slot, cancel, transfer)
|
|
279
|
+
- [Session limits](/reference/session-limits) — cap outbound call duration
|