Pinecall

Events Reference

Every event the SDK emits, with payload shapes and timing.

Real-time flow#

This is the order events fire during a typical exchange:

User speaks    →  speech.started
               →  user.speaking  (interim, fires multiple times)
               →  speech.ended
               →  user.message   (final confirmed text)
               →  eager.turn / turn.end

Bot responds   →  bot.speaking   (message ID assigned)
               →  bot.word       (word-by-word as TTS plays)
               →  bot.finished   (done speaking)

Interruption   →  bot.interrupted
               →  turn.continued (active ReplyStreams auto-aborted)

Lifecycle events#

call.started#

agent.on("call.started", (call: Call) => { });

A new voice call connected (phone or WebRTC). The Call object is partially populated — id, from, to, direction, transport, metadata and language are available. duration, endedAt, reason are not yet.

Three more fields are populated when a phone line was involved, and are null / [] otherwise:

call.extension;       // string | null — the extension dialled after the number ("11")
call.routedFrom;      // string | null — "line:+12186633772", the line that handed this call over
call.lineTranscript;  // LineTranscriptEntry[] — what the line heard and said before the hand-over
interface LineTranscriptEntry {
  who: "caller" | "line";
  text: string;
  at: number;                      // epoch ms
  role: "user" | "assistant";      // the same fact, in a plain Call transcript's shape
  content: string;
}

Note: call.started fires only for voice transports (phone, webrtc). For chat and WhatsApp, use chat.started and whatsapp.started instead.

memory.ops#

agent.on("memory.ops", (m: MemoryOpsEvent, call?: Call) => { });

Memory learned or revised something about the session's contact — add / update (with supersedes) / delete ops, applied. Fires after a reply completes (or once per call, per memory.consolidate), never on the turn's path. The same JSON is a memory.ops entry in the call log. See Memory.

chat.started#

agent.on("chat.started", (call: Call) => { });

A new chat session started. Receives the same Call object, with call.transport === "chat". Use setPromptVars(), addContext(), and all other Call methods as usual.

Chat never fires call.preparing unless the agent opts into it. If you localise a session from call.language or set per-session {{vars}}, handle both chat.started and call.preparing — an agent that only handles preparing leaves every chat session on its registered defaults.

whatsapp.started#

agent.on("whatsapp.started", (call: Call, session: WhatsAppSession) => { });

A new WhatsApp session started (first message from a new contact). Receives both:

  • call — the universal Call object for setPromptVars(), addContext(), etc.
  • session — a WhatsAppSession with contactPhone, contactName, and history methods.

call.preparing#

agent.on("call.preparing", (call: Call) => void | Promise<unknown>);

Fires before every LLM generation — voice, chat, and WhatsApp. The server holds the turn open while your handler runs, so anything you push here lands on this generation:

agent.on("call.preparing", async (call) => {
  await call.setPromptVars({
    TODAY: todayIn(call.metadata.tz),
    OPEN_TICKETS: await crm.openTickets(call.metadata.userId),
  });
});

Return a promise (an async handler does) and the SDK waits for it before releasing the turn. The wait is bounded by the agent's preparing budget — 150 ms if undeclared, 1500 ms with preparing: true, or your own timeoutMs. The turn resumes the moment the handler settles, so the budget is a ceiling, not a delay. See the events guide.

call.preparingTimeout#

agent.on("call.preparingTimeout", (event: PreparingTimeoutEvent, call: Call) => { });

The server gave up waiting for call.preparing and generated with the previous values. Only fires for agents that opted in with preparing.

FieldType
callIdstring
turnnumber
waitedMsnumber
budgetMsnumber

call.ended#

agent.on("call.ended", (call: Call, reason: string) => { });

The call ended. The Call is now fully populated, including duration, endedAt, messages, and transcript.

reason values: hangup, timeout, idle_timeout, max_duration, no_answer, busy, failed.

User speech events#

speech.started / speech.ended#

agent.on("speech.started", (event, call: Call) => { });
agent.on("speech.ended", (event, call: Call) => { });

VAD-level events: fire when the audio energy crosses the speech threshold.

user.speaking#

agent.on("user.speaking", (event: { text: string }, call: Call) => { });

Interim STT transcript. Fires multiple times as the STT engine refines its guess.

user.message#

agent.on("user.message", (event: { text: string; messageId: string }, call: Call) => { });

Final confirmed user text. After this fires, eager.turn or turn.end follows shortly.

Turn events#

eager.turn#

agent.on("eager.turn", (turn: { text: string; probability: number }, call: Call) => { });

Early signal that the user probably finished a turn. Use for low-latency responses — start the LLM, but be ready to abort if turn.continued fires.

turn.end#

agent.on("turn.end", (turn: { text: string; probability: number }, call: Call) => { });

Final turn signal. Higher confidence than eager.turn. This is where most apps trigger the LLM.

turn.continued#

agent.on("turn.continued", (event, call: Call) => { });

The user kept talking after a turn signal. Any active ReplyStream auto-aborts. Your handler doesn't need to do anything — just don't be surprised when the stream stops.

Bot speech events#

Bot speech follows this lifecycle:

bot.speaking  →  bot.word × N  →  bot.finished      (completed normally)
                                   bot.interrupted    (user barged in)
                                   message.confirmed  (full text saved to history)

call.currentBotText accumulates bot.word events into a live preview string. It resets on each new bot.speaking and clears after bot.finished / bot.interrupted.

bot.speaking#

agent.on("bot.speaking", (event: { messageId: string; text: string }, call: Call) => { });

The bot started speaking a message. messageId lets you track this specific utterance.

text contains the full response text for non-streaming replies (call.say(), call.reply()). For streaming replies (call.replyStream()), text is empty because tokens arrive incrementally — use bot.word events or call.currentBotText to track what the bot is saying.

bot.word#

agent.on("bot.word", (event: { messageId: string; word: string }, call: Call) => { });

A word was just played by TTS — synchronized with the actual audio playback. Use for live captions, subtitles, or transcript UIs.

Each bot.word is automatically accumulated into call.currentBotText:

// Live preview — grows word-by-word as the bot speaks
agent.on("bot.word", (event, call) => {
  console.log(`🗣  "${call.currentBotText}"`);
  // "¡Hola!"
  // "¡Hola! Estoy"
  // "¡Hola! Estoy bien,"
  // "¡Hola! Estoy bien, gracias."
});

Note: bot.word timing is aligned with TTS audio. If the bot says a 5-second sentence, words arrive spread across those 5 seconds — not all at once.

bot.finished#

agent.on("bot.finished", (event: { messageId: string; durationMs: number }, call: Call) => { });

The bot finished speaking. TTS audio fully played. call.currentBotText still contains the accumulated words during this handler — it clears immediately after.

agent.on("bot.finished", (event, call) => {
  console.log(`Done (${event.durationMs}ms): "${call.currentBotText}"`);
});

bot.interrupted#

agent.on("bot.interrupted", (event: { messageId: string; playedMs: number; reason: string }, call: Call) => { });

The user cut off the bot mid-speech. call.currentBotText shows what the bot managed to say before being interrupted.

agent.on("bot.interrupted", (event, call) => {
  console.log(`Interrupted after ${event.playedMs}ms, said: "${call.currentBotText}"`);
});

Protocol events#

message.confirmed#

agent.on("message.confirmed", (event: { messageId: string }, call: Call) => { });

The server acknowledged a bot message you sent (via say, reply, or replyStream).

llm.toolCall#

agent.on("llm.toolCall", (data: {
  msgId: string;
  toolCalls: Array<{ id: string; name: string; arguments: string }>;
}, call: Call) => { });

The server-side LLM is requesting one or more tool calls. If you defined tools with tool(), the SDK auto-executes them and sends results via call.toolResult(). This event still fires — use it for logging, metrics, or UI updates.

See Tools and Functions.

session.idleWarning#

agent.on("session.idleWarning", (event: {
  remainingSeconds: number;
  idleTimeoutSeconds: number;
}, call: Call) => { });

Fires before idle timeout. The user hasn't spoken in a while. Use it to prompt them.

agent.on("session.idleWarning", (event, call) => {
  call.say("Are you still there?");
});

session.timeout#

agent.on("session.timeout", (event: {
  reason: "max_duration" | "idle_timeout";
}, call: Call) => { });

A session limit hit. The call is about to end.

DTMF events#

call.dtmf_received#

agent.on("call.dtmf_received", (event: {
  callId: string;
  digit: string;    // this press
  digits: string;   // every press so far on this call
}, call: Call) => { });

The CALLER pressed a key. Phone only. Nothing is fed to the STT or the model. On a phone line, digits collected inside the extension window are not emitted here — they become call.extension.

call.dtmf_sent#

agent.on("call.dtmf_sent", (event: { callId: string; digits: string }, call: Call) => { });

Tones we played down the line, via call.sendDTMF().

Phone line events#

Emitted on a PhoneLine created with pc.line().

ready (line.created)#

line.on("ready", () => { });
await line.ready;              // the same fact as a promise

The server registered the line; the number is ours. Goes back to pending across a reconnect.

error (line.error)#

line.on("error", (err: PinecallError) => { });
// err.code — "LINE_CONFLICT" | "LINE_CONFIG_ERROR" | "PHONE_NOT_IN_ORG" | "UNAUTHORIZED"

The registration was refused.

call / call.ended#

line.on("call", (call: LineCall) => { });                 // fires after the extension window closes
line.on("call.ended", (call: LineCall, reason: string) => { });   // reason is "routed" after a hand-over

call.routed#

call.on("call.routed", (event: { callId: string; agent: string }) => { });

The owner swap landed — the agent is driving this call now, on the same audio stream. await call.routeTo(...) resolving { ok: true } is the same fact. A call.ended with reason "routed" follows, for the line.

call.route_failed#

call.on("call.route_failed", (event: {
  callId: string;
  agent: string;
  reason: "offline" | "unknown" | "no_phone_config" | "capacity" | "swap_failed";
}) => { });

The swap did not happen. The session is untouched and the line is still the owner.

WhatsApp events#

whatsapp.message#

agent.on("whatsapp.message", (event: {
  sessionId: string;
  from: string;
  name: string;
  type: "text" | "audio" | "image" | "video" | "document";
  text: string;
  messageId: string;
  paused: boolean;  // true when agent is paused (human-in-the-loop)
}) => { });

Incoming WhatsApp message. For voice notes (type: "audio"), text is the transcript.

When paused is true, the AI did not respond — a human should handle this message via agent.sendMessage().

whatsapp.response#

agent.on("whatsapp.response", (event: {
  sessionId: string;
  to: string;
  text: string;
  source?: "human";  // present when sent by human via agent.sendMessage()
}) => { });

The agent sent a WhatsApp response. When source is "human", the message was sent by a human operator (not the AI).

whatsapp.status#

agent.on("whatsapp.status", (event: {
  status: "sent" | "delivered" | "read";
  recipient: string;
  messageId: string;
}) => { });

Delivery status update from Meta.

Human-in-the-loop events#

session.paused#

agent.on("session.paused", (event: {
  sessionId?: string;   // set for session-level pause
  contact?: string;     // set for contact-level pause
  // both undefined = global pause
}) => { });

Confirmation that the agent was paused. Fires after agent.pause().

session.resumed#

agent.on("session.resumed", (event: {
  sessionId?: string;
  contact?: string;
}) => { });

Confirmation that the agent was resumed. Fires after agent.resume().

Audio metrics#

When you enable analysis.send_audio_metrics:

agent.on("audio.metrics", (event: {
  source: "user" | "bot";
  energyDb: number;     // -60 to 0
  rms: number;          // 0–1
  peak: number;         // 0–1
  isSpeech: boolean;
  vadProb: number;      // 0–1
}, call: Call) => { });

Use for live waveform UIs, energy meters, or VAD visualization.

The call log envelope#

Observed through the call log (GET /v1/calls/{id}/events, SSE or JSON), every fact arrives as a stamped log entry — the canonical shape for anything outside the agent process:

{"seq":12,"ts":1786537584.4,"call":"CA123","agent":"mara","type":"user.message","ephemeral":false,"data":{"id":"msg_abc","text":"Hello","final":true}}

seq is the cursor: dedupe by it, resume from it. See The Call Log for the full vocabulary and the control markers (log.gap, log.caught_up).

In-process SSE (pc.stream())#

pc.stream(res) relays the in-process bus as SSE, with an event: field and a JSON data: body carrying the agent id:

event: user.message
data: {"callId":"CA123","text":"Hello","messageId":"msg_abc","agent":"mara"}

A :ping comment is sent every 30s as keepalive. There is no seq here — no cursor, no replay, no history, and only the calls this process handles. It is what the run console is built on, not the observation model: for anything a user sees, read the log.

What's next#