Prerequisites#
- A Pinecall API key
- A phone number on your Pinecall account (purchase one or port one — see REST API → fetchPhones)
- Node.js ≥ 18
The minimum viable phone agent#
import { Pinecall } from "@pinecall/sdk";
const pc = new Pinecall({ apiKey: process.env.PINECALL_API_KEY! });
const receptionist = pc.agent("receptionist", {
prompt: "You are the receptionist for Acme Corp. Be brief and warm.",
llm: "openai/gpt-5-chat-latest",
voice: "elevenlabs/sarah",
stt: "deepgram/flux",
language: "en",
phoneNumber: "+13186330963",
});
receptionist.on("call.started", (call) => {
if (call.direction === "inbound") {
call.say("Thanks for calling Acme. How can I help?");
}
});
receptionist.on("call.ended", (call, reason) => {
console.log(`[${call.id}] ${reason} (${call.duration}s)`);
});That's a working phone agent. The server handles audio transport, STT, the LLM, TTS, and turn detection.
Greeting#
There are two ways to greet inbound callers:
Option 1: greeting in agent() (declarative)#
If you use pc.agent(), the greeting field handles everything — no event handler needed:
const agent = pc.agent("receptionist", {
voice: "elevenlabs/sarah",
llm: "openai/gpt-5-chat-latest",
stt: "deepgram/flux",
prompt: "You are a receptionist for Acme Corp.",
phoneNumber: "+13186330963",
// Static
greeting: "Thanks for calling Acme. How can I help?",
});The greeting is added to LLM history by default, so the model knows what was said. You can disable that:
greeting: { text: "Welcome to Acme.", addToHistory: false }Or make it dynamic per-call:
greeting: async (call) => {
const customer = await db.findByPhone(call.from);
return customer ? `Hi ${customer.name}!` : "Hi! How can I help?";
}Option 2: call.say() in call.started (programmatic)#
If you use pc.agent(), handle the greeting yourself:
agent.on("call.started", (call) => {
call.say("Hello! How can I help you today?");
});Use this when you need logic beyond what greeting supports — multiple says, conditional behavior, loading data before speaking, etc.
Outbound calls use a different mechanism: pass
greetinginagent.dial(). The server speaks it as soon as the callee picks up. See Outbound Calls.
Adding tools#
Define tools with tool() and Zod schemas. The SDK auto-executes them when the LLM calls them:
import { Pinecall, tool } from "@pinecall/sdk";
import { z } from "zod";
const lookupOrder = tool({
name: "lookupOrder",
description: "Look up an order by ID",
schema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => {
const order = await db.orders.findOne(orderId);
return order ?? { error: "not_found" };
},
});
const transferToHuman = tool({
name: "transferToHuman",
description: "Escalate to a human specialist.",
schema: z.object({}),
execute: async (_, call) => {
call.say("One moment, connecting you to a specialist.");
call.forward("+15558675309");
return { transferred: true };
},
});
const endCall = tool({
name: "endCall",
description: "End the call when the customer says goodbye.",
schema: z.object({}),
execute: async (_, call) => {
call.say("Have a great day. Goodbye!");
call.once("bot.finished", () => call.hangup());
return { ended: true };
},
});
const agent = pc.agent("receptionist", {
prompt: "You are a receptionist. Look up orders when asked.",
llm: "openai/gpt-5-chat-latest",
voice: "elevenlabs/sarah",
stt: "deepgram/flux",
language: "en",
phoneNumber: "+13186330963",
tools: [lookupOrder, transferToHuman, endCall],
});
agent.on("call.started", (call) => call.say("Thanks for calling. How can I help?"));See Tools and Functions for the full pattern.
Automatic call endings#
- When the user hangs up — emits
call.endedwith reasonhangup - After
max_duration_seconds(default: 10 minutes) — reasonmax_duration - After
idle_timeout_secondsof silence (default: 60s) — reasonidle_timeout
See Session Limits for tuning these.
Listening for live transcripts#
Use bot.word and user.message events to build a live transcript UI or log the conversation as it happens:
agent.on("user.message", (event, call) => {
console.log(`[${call.id}] User: ${event.text}`);
});
let currentBotMessage = "";
agent.on("bot.speaking", () => { currentBotMessage = ""; });
agent.on("bot.word", (event, call) => {
currentBotMessage += event.word + " ";
process.stdout.write(`\r[${call.id}] Bot: ${currentBotMessage}`);
});
agent.on("bot.finished", () => console.log());After the call ends#
When call.ended fires, the Call object is fully populated:
agent.on("call.ended", async (call, reason) => {
await db.calls.create({
id: call.id,
from: call.from,
to: call.to,
duration: call.duration,
reason,
transcript: call.transcript,
messages: call.messages, // full LLM history including tool calls
startedAt: call.startedAt,
endedAt: call.endedAt,
});
});What's next#
- Outbound calls — make programmatic outbound calls
- Tools and Functions — let the agent take actions
- Dev mode — share one number between prod and any number of devs
CallAPI reference — every method

