Creation#
import { tool } from "@pinecall/sdk";
import { z } from "zod";
const lookupOrder = tool({
name: "lookupOrder",
description: "Look up an order by ID",
schema: z.object({ id: z.string() }),
execute: async ({ id }) => ({ status: "shipped", eta: "today" }),
});
const agent = pc.agent("my-agent", {
voice: "elevenlabs/sarah",
language: "es",
stt: "deepgram/flux",
llm: "openai/gpt-5.4-nano",
prompt: "System prompt with {{template_vars}}.",
greeting: "Hello! How can I help you today?",
phoneNumber: "+13186330963",
tools: [lookupOrder],
});| Config field | Type | Description |
|---|---|---|
voice | string | VoiceConfig | TTS provider — shortcut or full config |
language | string | BCP-47 language code. Non-English auto-selects ElevenLabs eleven_multilingual_v2 |
flash | boolean | Keep ElevenLabs eleven_flash_v2_5 on a non-English agent (lowest latency/cost) instead of the multilingual auto-default. ElevenLabs-only; see TTS Providers |
stt | string | STTConfig | STT provider — shortcut or full config |
llm | LLMConfig | LLM provider, model, prompt, enabled flag |
tools | Tool[] | Declarative tools created with tool() + Zod schemas (auto-executed) |
phoneNumber | string | PhoneNumberConfig | Phone number to register (E.164 or SIP URI) |
phoneNumbers | Array<string | PhoneNumberConfig> | Multiple numbers with per-number config |
whatsapp | WhatsAppChannelConfig[] | WhatsApp channels to register |
history | HistoryStore | Conversation persistence (see History) |
sessionLimits | SessionLimits | Duration / idle timeout config |
interruption | InterruptionConfig | Barge-in gates: min duration/volume/words, backchannel filter |
analysis | AnalysisConfig | Audio metrics streaming |
greeting | string | { text, addToHistory? } | { [lang]: string } | (call) => string | First thing the agent says — see Greeting |
greetingInChat | boolean | Deliver the greeting on chat sessions too (default false) |
memory | MemoryConfig | Long-term memory per contact — see Memory |
allowedOrigins | string[] | Public token access (see Security) |
See Reference → Providers for full provider configs.
Greeting#
The first thing the agent says. The server delivers it — you do not send it yourself — so it is one text with one owner, and it lands in the LLM history: the model knows it already greeted and does not introduce itself again.
pc.agent("front-desk", { greeting: "Thanks for calling Studio Bella, this is Lucía. How can I help?" });| Shape | Use |
|---|---|
"Hi! How can I help?" | one language, every channel |
{ text, addToHistory? } | same, with explicit history control |
{ en: "Hi!…", es: "¡Hola!…" } | one text per language — the server picks the entry matching the session's call.language |
(call) => string | computed per call (a name from your CRM). Runs in your process on call.started, voice only |
Channels. Voice (phone and WebRTC) is greeted by default. Chat is not, because most chat UIs paint their own opening line — set greetingInChat: true to have the server send it there too, as the session's first bot message:
pc.agent("front-desk", {
greeting: { en: "Hi, this is Lucía. How can I help?", es: "Hola, habla Lucía. ¿En qué te ayudo?" },
greetingInChat: true,
});Do not greet twice. Declaring
greetingand saying hello yourself fromcall.started, or painting a welcome line in the browser, produces two greetings back to back. Pick one owner: thegreetingfield (the server) or your owncall.say— not both.
Memory#
agent.memory reads what the server remembers about this agent's contacts — get(contact), search(query, { contact?, k? }), forget(contact) — over REST with your API key; the agent need not be online. Facts arrive live on agent.on("memory.ops", (m, call) => …). The whole story: Memory.
Registration#
pc.agent() returns synchronously — it only queues agent.create on the socket. The agent exists server-side once the server acks it, and only then can it be reached from outside your process (token mints, inbound routing).
ready#
Promise<void> that resolves when the server has acknowledged the registration. Await it before anything that needs the agent to exist server-side.
const agent = pc.agent("recepcion", { prompt });
await agent.ready; // the server now knows this agentRejects with AgentConflictError if the registration is terminally refused (the id is held by another live process — run pinecall kick <id> or pick another id). Goes back to pending if the socket drops, and resolves again once the reconnect re-registers the agent.
Rejects with ServerAtCapacityError if the server's client-slot ceiling refused the registration. Nothing is wrong with your agent — the server is full:
import { ServerAtCapacityError } from "@pinecall/sdk";
try {
await agent.ready;
} catch (err) {
if (err instanceof ServerAtCapacityError) {
console.error(`server full: ${err.used}/${err.limit} slots`);
// `pinecall agents` lists the holders; free one, then retry.
}
}Worth knowing, because it used to be invisible: when a registration is refused for capacity, the agent never appears server-side, so a token mint for it answers
404 Agent '<id>' is not online. That 404 is a consequence, not the cause — always read the registration error first.
registered#
boolean — whether the server has acked the registration right now.
You rarely need either one:
createToken()already waits for the ack internally, so a register-then-mint sequence works without any delay on your side. Awaitreadywhen you need to know, or to surface a registration failure to your caller.
Phone numbers#
addPhoneNumber(number, config?)#
Register a phone number or SIP URI. Idempotent — calling again with the same number updates its config.
agent.addPhoneNumber("+13186330963");
agent.addPhoneNumber("sip:bot@trunk.twilio.com");
// Per-number config overrides
agent.addPhoneNumber("+34911234567", {
voice: "elevenlabs/valentina",
language: "es",
});removePhone(number)#
Unregister a phone number.
agent.removePhone("+34911234567");WhatsApp#
addWhatsapp(config)#
Register a WhatsApp channel. Idempotent.
agent.addWhatsapp({
phoneNumberId: "123456789012345",
accessToken: "EAABx...",
verifyToken: "my-secret",
appSecret: "abc123...",
});See WhatsApp guide for full config.
removeWhatsapp(phoneNumberId)#
Unregister a WhatsApp channel.
agent.removeWhatsapp("123456789012345");Config & hot-reload#
update(opts)#
Hot-reload the agent's defaults. Affects all future calls — existing calls keep their current config.
agent.update({ voice: "elevenlabs/claire", language: "fr" });
agent.update({ stt: "gladia" });
agent.update({ llm: "openai/gpt-5.4-nano", prompt: "..." });configureSession(callId, opts)#
Update config for a live call (equivalent to call.update()).
agent.configureSession("CA7ec...", { language: "es" });getConfig()#
Returns the current AgentConfig.
const cfg = agent.getConfig();Outbound calls#
dial(options)#
Make an outbound call. Returns Promise<Call>.
const call = await agent.dial({
to: "+14155551234",
from: "+13186330963",
greeting: "Hi! This is a follow-up call.",
metadata: { appointmentId: "appt_001" },
config: { voice: "cartesia/yumiko", language: "ar" },
});| Field | Type | Required | Description |
|---|---|---|---|
to | string | ✅ | Destination number (E.164) |
from | string | — | Caller ID — auto-resolved if agent has one phone channel. Required when multiple. |
greeting | string | — | Text the server speaks when callee picks up |
metadata | object | — | Custom data attached to the call |
config | object | — | Per-call config override (voice, STT, language) |
See Outbound Calls guide for the full pattern.
Tokens#
createToken(channel, metadata?)#
Mint a short-lived, single-use token for browser WebRTC or chat. Scoped to this agent (the agent-form shortcut for pc.createToken(channel, agentId, metadata?)).
const token = await agent.createToken("webrtc");
// { token, server, expiresIn }Safe to call immediately after pc.agent(): the mint waits for the agent's registration ack first, so it can't race ahead of the registration and come back Agent '<id>' is not online. If the agent is never registered (socket down, id held by a live process) the call fails rather than minting a token that would 404.
Sealed session metadata — pass a second argument to bake trusted context into the token:
const token = await agent.createToken("chat", { userId: "u_123", role: "admin" });The metadata is sealed into the signed token on your server, so the browser can't forge or alter it — it surfaces as call.metadata in your call.started handler. Use it for per-user / multi-tenant identity you can trust.
⚠️ Arg position differs by form:
agent.createToken(channel, metadata)(metadata 2nd,agentIdimplicit) vspc.createToken(channel, agentId, metadata)(metadata 3rd). It is not the forgeable client-sidemetadataprop on the widget /VoiceSession. See Multi-Tenant → sealed token metadata and Security.
Dev mode#
routeCallers(numbers)#
Route phone and WhatsApp messages from these numbers to this agent (instead of any other agent registered on the same channel). Used for dev mode isolation.
agent.routeCallers(["+34600123456", "+34612345678"]);See Dev mode guide.
Human-in-the-loop#
Pause the AI so a human can take over the conversation. Works on WhatsApp and (soon) voice/chat channels.
pause(target?)#
Pause the agent. While paused, incoming messages are forwarded to the SDK but the LLM doesn't respond.
// Pause a specific session
agent.pause("wa-abc123");
// Pause all sessions with a contact
agent.pause({ contact: "+34612345678" });
// Pause the entire agent
agent.pause();resume(target?)#
Resume the AI after a pause. Global resume clears all session and contact pauses.
agent.resume("wa-abc123");
agent.resume({ contact: "+34612345678" });
agent.resume();sendMessage(opts)#
Send a message as the human operator. The message is delivered through the channel (e.g. WhatsApp) and added to LLM history so the AI has context when resumed.
agent.sendMessage({
sessionId: "wa-abc123",
text: "Hi, I'm taking over this conversation.",
});| Field | Type | Required | Description |
|---|---|---|---|
sessionId | string | ✅ | Target session ID (e.g. wa-abc123) |
text | string | ✅ | Message body |
See Human Takeover guide for the full pattern.
Calls#
call(callId)#
Look up a live Call by ID. Returns Call | undefined.
const call = agent.call("CA7ec...");Observability#
The way to observe this agent's calls — live, late, or after the fact — is the call log: mint a stream token and read it from any process or browser, with replay and cursor resume.
// from Node
const obs = pc.observe({ agent: agent.id });
obs.on("entry", (entry) => console.log(entry.seq, entry.type));
// for a browser: mint the token here, read the log there
const { token, server } = await agent.createToken("stream", undefined, { scope: "observe" });The in-process streaming methods this class used to expose — an SSE one and a bidirectional WebSocket one — were removed, along with the
EventStreammodule. They only ever saw calls this process handled, had no cursor and no replay, and died with the process. The migration table is in Observe calls.
Events#
Subscribe via agent.on(event, handler). All call-scoped events include call as the last argument.
Lifecycle#
| Event | Signature | When |
|---|---|---|
call.started | (call) | New call connected |
call.ended | (call, reason) | Call disconnected |
call.preparing | (call) | Before every LLM generation — the server holds the turn while your handler refreshes per-turn {{vars}}. Return a promise and it waits for it. See the guide. |
call.preparingTimeout | (event, call) | The preparing budget expired and the turn rendered with the previous values |
User speech#
| Event | Signature | When |
|---|---|---|
speech.started | (event, call) | User began speaking (VAD) |
speech.ended | (event, call) | User stopped speaking (VAD) |
user.speaking | (event, call) | Interim STT transcript (updates live) |
user.message | (event, call) | Final confirmed user text |
Turns#
| Event | Signature | When |
|---|---|---|
eager.turn | (turn, call) | Early turn signal (low-latency response) |
turn.end | (turn, call) | Final turn signal |
turn.continued | (event, call) | User kept talking (auto-aborts active streams) |
Bot speech#
| Event | Signature | When |
|---|---|---|
bot.speaking | (event, call) | Bot started speaking a message |
bot.word | (event, call) | Individual word as TTS plays it |
bot.finished | (event, call) | Bot finished speaking a message |
bot.interrupted | (event, call) | Bot was cut off by user |
Protocol#
| Event | Signature | When |
|---|---|---|
message.confirmed | (event, call) | Server acknowledged bot message |
llm.toolCall | (data, call) | Server-side LLM requests a tool call |
session.idleWarning | (event, call) | Warning — user hasn't spoken, call will timeout soon |
session.timeout | (event, call) | Session timeout fired (max duration / idle) |
WhatsApp#
| Event | Signature | When |
|---|---|---|
whatsapp.sessionStarted | (event) | New WhatsApp conversation started |
whatsapp.message | (event) | Incoming WhatsApp message received |
whatsapp.response | (event) | Agent sent a WhatsApp response |
whatsapp.status | (event) | Message delivery status |
See Events reference for full event data shapes.
Human-in-the-loop#
| Event | Signature | When |
|---|---|---|
session.paused | (event) | AI paused for a session, contact, or globally |
session.resumed | (event) | AI resumed |
See Human Takeover guide.
Escape hatch#
send(data)#
Send a raw protocol message. Use only when no higher-level method covers your case.
agent.send({ type: "custom.command", payload: { /* ... */ } });What's next#
Call— per-session methods- Events reference — full event data shapes
- Hot-reload — patterns for
configure()andsetPrompt()

