@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.
Files changed (68) hide show
  1. package/README.md +65 -0
  2. package/build.mjs +204 -0
  3. package/package.json +29 -0
  4. package/skills/pinecall-concepts/SKILL.md +41 -0
  5. package/skills/pinecall-concepts/references/concepts/agents-and-channels.md +155 -0
  6. package/skills/pinecall-concepts/references/concepts/deployment-topologies.md +120 -0
  7. package/skills/pinecall-concepts/references/concepts/hot-reload.md +119 -0
  8. package/skills/pinecall-concepts/references/concepts/philosophy.md +100 -0
  9. package/skills/pinecall-concepts/references/concepts/server-vs-client-llm.md +119 -0
  10. package/skills/pinecall-examples/SKILL.md +59 -0
  11. package/skills/pinecall-examples/references/examples/browser-widget.md +206 -0
  12. package/skills/pinecall-examples/references/examples/chat-bot.md +184 -0
  13. package/skills/pinecall-examples/references/examples/headless-agent.md +121 -0
  14. package/skills/pinecall-examples/references/examples/index.md +183 -0
  15. package/skills/pinecall-examples/references/examples/multi-channel-bot.md +173 -0
  16. package/skills/pinecall-examples/references/examples/outbound-dispatch.md +109 -0
  17. package/skills/pinecall-examples/references/examples/turn-detection.md +150 -0
  18. package/skills/pinecall-guides/SKILL.md +68 -0
  19. package/skills/pinecall-guides/references/guides/call-ringing.md +149 -0
  20. package/skills/pinecall-guides/references/guides/conversation-history.md +377 -0
  21. package/skills/pinecall-guides/references/guides/dev-mode.md +130 -0
  22. package/skills/pinecall-guides/references/guides/events.md +677 -0
  23. package/skills/pinecall-guides/references/guides/human-takeover.md +184 -0
  24. package/skills/pinecall-guides/references/guides/inbound-voice.md +201 -0
  25. package/skills/pinecall-guides/references/guides/knowledge-bases.md +166 -0
  26. package/skills/pinecall-guides/references/guides/live-listening.md +199 -0
  27. package/skills/pinecall-guides/references/guides/multi-tenant.md +158 -0
  28. package/skills/pinecall-guides/references/guides/outbound-calls.md +279 -0
  29. package/skills/pinecall-guides/references/guides/sse-streaming.md +207 -0
  30. package/skills/pinecall-guides/references/guides/testing-agents.md +272 -0
  31. package/skills/pinecall-guides/references/guides/tools-and-functions.md +254 -0
  32. package/skills/pinecall-guides/references/guides/webrtc-browser.md +200 -0
  33. package/skills/pinecall-guides/references/guides/whatsapp.md +370 -0
  34. package/skills/pinecall-guides/references/guides/ws-streaming.md +235 -0
  35. package/skills/pinecall-quickstart/SKILL.md +54 -0
  36. package/skills/pinecall-quickstart/references/index.md +123 -0
  37. package/skills/pinecall-quickstart/references/quickstart.md +185 -0
  38. package/skills/pinecall-reference/SKILL.md +43 -0
  39. package/skills/pinecall-reference/references/reference/cli.md +578 -0
  40. package/skills/pinecall-reference/references/reference/events.md +366 -0
  41. package/skills/pinecall-reference/references/reference/llm-providers.md +263 -0
  42. package/skills/pinecall-reference/references/reference/rest-api.md +122 -0
  43. package/skills/pinecall-reference/references/reference/session-limits.md +119 -0
  44. package/skills/pinecall-reference/references/reference/stt-providers.md +174 -0
  45. package/skills/pinecall-reference/references/reference/tts-providers.md +149 -0
  46. package/skills/pinecall-sdk-api/SKILL.md +56 -0
  47. package/skills/pinecall-sdk-api/references/api/agent.md +328 -0
  48. package/skills/pinecall-sdk-api/references/api/call.md +324 -0
  49. package/skills/pinecall-sdk-api/references/api/pinecall.md +186 -0
  50. package/skills/pinecall-sdk-api/references/api/reply-stream.md +148 -0
  51. package/skills/pinecall-security/SKILL.md +37 -0
  52. package/skills/pinecall-security/references/security.md +138 -0
  53. package/skills/pinecall-web-chat/SKILL.md +38 -0
  54. package/skills/pinecall-web-chat/references/web/chat/chat-session.md +178 -0
  55. package/skills/pinecall-web-chat/references/web/chat/overview.md +98 -0
  56. package/skills/pinecall-web-components/SKILL.md +37 -0
  57. package/skills/pinecall-web-components/references/web/components/overview.md +128 -0
  58. package/skills/pinecall-web-voice/SKILL.md +40 -0
  59. package/skills/pinecall-web-voice/references/web/core/datachannel-protocol.md +149 -0
  60. package/skills/pinecall-web-voice/references/web/core/overview.md +70 -0
  61. package/skills/pinecall-web-voice/references/web/core/state-and-phases.md +153 -0
  62. package/skills/pinecall-web-voice/references/web/core/voice-session.md +279 -0
  63. package/skills/pinecall-web-widget/SKILL.md +41 -0
  64. package/skills/pinecall-web-widget/references/web/widget/overview.md +67 -0
  65. package/skills/pinecall-web-widget/references/web/widget/props.md +291 -0
  66. package/skills/pinecall-web-widget/references/web/widget/theming.md +131 -0
  67. package/skills/pinecall-web-widget/references/web/widget/tools-api.md +381 -0
  68. 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
+ ![Multi-tenant SSE scoping](/assets/diagrams/multi-tenant-sse.png)
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