Pinecall

Agent

Owns channels, routes call events, stores defaults, dials outbound calls.

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 fieldTypeDescription
voicestring | VoiceConfigTTS provider — shortcut or full config
languagestringBCP-47 language code. Non-English auto-selects ElevenLabs eleven_multilingual_v2
flashbooleanKeep ElevenLabs eleven_flash_v2_5 on a non-English agent (lowest latency/cost) instead of the multilingual auto-default. ElevenLabs-only; see TTS Providers
sttstring | STTConfigSTT provider — shortcut or full config
llmLLMConfigLLM provider, model, prompt, enabled flag
toolsTool[]Declarative tools created with tool() + Zod schemas (auto-executed)
phoneNumberstring | PhoneNumberConfigPhone number to register (E.164 or SIP URI)
phoneNumbersArray<string | PhoneNumberConfig>Multiple numbers with per-number config
whatsappWhatsAppChannelConfig[]WhatsApp channels to register
historyHistoryStoreConversation persistence (see History)
sessionLimitsSessionLimitsDuration / idle timeout config
interruptionInterruptionConfigBarge-in gates: min duration/volume/words, backchannel filter
analysisAnalysisConfigAudio metrics streaming
greetingstring | { text, addToHistory? } | { [lang]: string } | (call) => stringFirst thing the agent says — see Greeting
greetingInChatbooleanDeliver the greeting on chat sessions too (default false)
memoryMemoryConfigLong-term memory per contact — see Memory
allowedOriginsstring[]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?" });
ShapeUse
"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) => stringcomputed 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 greeting and saying hello yourself from call.started, or painting a welcome line in the browser, produces two greetings back to back. Pick one owner: the greeting field (the server) or your own call.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 agent

Rejects 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. Await ready when 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" },
});
FieldTypeRequiredDescription
tostringDestination number (E.164)
fromstringCaller ID — auto-resolved if agent has one phone channel. Required when multiple.
greetingstringText the server speaks when callee picks up
metadataobjectCustom data attached to the call
configobjectPer-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, agentId implicit) vs pc.createToken(channel, agentId, metadata) (metadata 3rd). It is not the forgeable client-side metadata prop 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.",
});
FieldTypeRequiredDescription
sessionIdstringTarget session ID (e.g. wa-abc123)
textstringMessage 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 EventStream module. 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#

EventSignatureWhen
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#

EventSignatureWhen
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#

EventSignatureWhen
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#

EventSignatureWhen
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#

EventSignatureWhen
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#

EventSignatureWhen
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#

EventSignatureWhen
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#