import { ServerResponse } from 'node:http'; /** * TypedEventBus — zero-dependency typed event emitter. * * Improvements over the previous TypedEmitter: * 1. Handler errors routed to onError callback (not console.error). * 2. once-handlers removed BEFORE invocation (prevents re-entry bugs). * 3. emit is protected (subclass-only). * 4. listenerCount() added. */ type EventMap = { [key: string]: (...args: any[]) => void; }; interface EventBusOptions { /** Called when a handler throws. If unset, error surfaces via queueMicrotask. */ onError?: (err: unknown, event: string, args: unknown[]) => void; } declare class TypedEventBus { #private; constructor(opts?: EventBusOptions); on(event: K, handler: E[K]): this; off(event: K, handler: E[K]): this; once(event: K, handler: E[K]): this; protected emit(event: K, ...args: Parameters): void; /** * Like `emit`, but KEEPS what each handler returned. * * Exists for `call.preparing`: the server holds the turn open while the app * refreshes its per-turn variables, so the SDK has to know when the app is * actually done — which for an async handler means awaiting the promise it * returned. Plain `emit` throws that value away, and the SDK could only * guess, which is exactly how the barrier used to be lost. */ protected emitCollect(event: K, ...args: Parameters): unknown[]; listenerCount(event: K): number; removeAllListeners(event?: keyof E): void; } /** * WhatsAppSession — a session handle passed to `whatsapp.sessionStarted`. * * Provides history injection methods (setHistory, addHistory, addContext, etc.) * that work identically to the Call equivalents, allowing WhatsApp conversations * to restore prior context on reconnection. * * @example * ```ts * agent.on("whatsapp.sessionStarted", async (session) => { * const prior = await history.findByContact(session.contactPhone, 1); * if (prior.length > 0) { * await session.setHistory(prior[0].messages); * } * }); * ``` */ interface WhatsAppSessionEvent { sessionId: string; agentId: string; contactPhone: string; contactName: string; } type SendFn = (payload: Record) => void; declare class WhatsAppSession { #private; /** Session ID (e.g. `"wa-70bebcaf5817"`). */ readonly id: string; /** Contact phone number. */ readonly contactPhone: string; /** Contact display name. */ readonly contactName: string; /** Agent ID this session belongs to. */ readonly agentId: string; /** @internal Created by the WhatsApp dispatch handler. */ constructor(event: WhatsAppSessionEvent, send: SendFn); /** Get the current LLM conversation history from the server. */ getHistory(): Promise>>; /** Inject messages into the server-side LLM history. */ addHistory(messages: Array<{ role: string; content: string; }>): Promise; /** Replace the entire server-side LLM history. */ setHistory(messages: Array<{ role: string; content: string; }>): Promise; /** Clear all messages from the server-side LLM history. */ clearHistory(): Promise; /** Replace the system prompt for this session. */ setPrompt(text: string): Promise; /** Set `{{variable}}` values in the prompt template. */ setPromptVars(vars: Record): Promise; /** Append context after the system prompt. */ addContext(text: string): Promise; /** @internal Resolve a pending history request/response promise. */ _applyHistoryResponse(eventType: string, data: Record): boolean; } interface ReplyStreamOptions { callId: string; messageId?: string; inReplyTo: string; send: (data: Record) => void; /** Called when the stream ends or is aborted — for cleanup. */ onComplete?: () => void; } declare class ReplyStream { #private; readonly messageId: string; readonly callId: string; constructor(opts: ReplyStreamOptions); /** True if the stream was aborted (e.g. turn.continued). */ get aborted(): boolean; /** True if end() was called. */ get ended(): boolean; /** AbortSignal that fires on abort — use with fetch, LLM clients, etc. */ get signal(): AbortSignal; /** * Write a token/chunk to the stream. * Automatically sends `start` on the first write. */ write(token: string): void; /** End the stream normally — flushes remaining buffer on server. */ end(): void; /** Abort the stream immediately (e.g. on turn.continued). */ abort(): void; } /** * Turn — value object representing a completed user turn. * * Built from eager.turn + user.message + turn.end events. */ interface Turn { id: number; messageId: string; text: string; confidence: number; language?: string; probability: number; latencyMs: number; } /** * Server → Client event types — every event from PROTOCOL.md §7. * * Convention: camelCase for all TypeScript fields. Wire protocol uses * snake_case — the SDK transforms at the boundary (see protocol/codec.ts). */ interface CallStartedEvent { event: "call.started"; callId: string; sessionId: string; from: string; to: string; direction: "inbound" | "outbound"; metadata?: Record; /** * The extension the caller dialled after the number ("33"), or null. * Phone lines only — see `pc.line()`; an agent that a line routed to * receives the same value so it knows which door the call came through. */ extension?: string | null; /** Who owns the session right now: the line that answered, or an agent. */ owner?: "line" | "agent"; /** Set on a call handed over by `line.routeTo()`: the line's id, `line:`. */ routedFrom?: string; /** What the line heard and said before it handed the call over. */ lineTranscript?: LineTranscriptEntry[]; } /** * One line in a phone line's own transcript — what the CALLER said and what * the LINE said back, before any model was involved. * * It carries `role`/`content` as well so anything that reads a plain `Call` * transcript keeps working on a line's. */ interface LineTranscriptEntry { who: "caller" | "line"; text: string; /** Epoch milliseconds. */ at: number; /** `who`, in the shape a plain Call transcript uses. */ role: "user" | "assistant"; /** `text`, in the shape a plain Call transcript uses. */ content: string; } interface CallRingingEvent { event: "call.ringing"; callId: string; from: string; to: string; direction: "inbound"; } interface CallRejectedEvent { event: "call.rejected"; callId: string; reason: string; } interface CallEndedEvent { event: "call.ended"; callId: string; sessionId: string; reason: string; durationSeconds: number; } interface SessionTimeoutEvent { event: "session.timeout"; callId: string; sessionId: string; reason: "max_duration" | "idle_timeout"; } interface SpeechStartedEvent { event: "speech.started"; callId: string; turnId: number; confidence: number; timestamp: number; } interface SpeechEndedEvent { event: "speech.ended"; callId: string; turnId: number; durationMs: number; timestamp: number; } interface UserSpeakingEvent { event: "user.speaking"; callId: string; messageId: string; text: string; confidence: number; confirmedText?: string; } interface UserMessageEvent { event: "user.message"; callId: string; messageId: string; text: string; confidence: number; language?: string; lagMs?: number; turnId: number; } interface EagerTurnEvent { event: "eager.turn"; callId: string; turnId: number; probability: number; latencyMs: number; text: string; messageId: string; } interface TurnPauseEvent { event: "turn.pause"; callId: string; turnId: number; probability: number; latencyMs: number; } interface TurnEndEvent { event: "turn.end"; callId: string; turnId: number; probability: number; latencyMs: number; } interface TurnResumedEvent { event: "turn.resumed"; callId: string; turnId: number; timestamp: number; } interface TurnContinuedEvent { event: "turn.continued"; callId: string; turnId: number; timestamp: number; } interface BotSpeakingEvent { event: "bot.speaking"; callId: string; messageId: string; text: string; } interface BotWordEvent { event: "bot.word"; callId: string; messageId: string; word: string; wordIndex: number; startTime?: number; endTime?: number; } interface BotFinishedEvent { event: "bot.finished"; callId: string; messageId: string; durationMs: number; } interface BotInterruptedEvent { event: "bot.interrupted"; callId: string; messageId: string; playedMs: number; wordsSpoken: number; lastWord?: string; reason: "continuation" | "user_spoke" | "cancelled"; } interface BargeInEvent { event: "barge_in"; callId: string; cancelledMessageId: string; } interface MessageConfirmedEvent { event: "message.confirmed"; callId: string; messageId: string; text: string; } interface ReplyRejectedEvent { event: "reply.rejected"; callId: string; messageId: string; inReplyTo: string; expectedReplyTo: string; reason: string; } interface AudioMetricsEvent { event: "audio.metrics"; callId: string; source: "user" | "bot"; energyDb: number; rms: number; peak: number; isSpeech: boolean; vadProb?: number; timestamp: number; } interface CallDialingEvent { event: "call.dialing"; callId: string; to: string; from: string; } interface CallErrorEvent { event: "call.error"; callId: string; error: string; code?: string; } interface CallForwardedEvent { event: "call.forwarded"; callId: string; to: string; } interface CallDtmfSentEvent { event: "call.dtmf_sent"; callId: string; digits: string; } /** * The CALLER pressed a key on a live phone call. * * The inbound twin of `call.dtmf_sent`, and named apart from it on purpose: * `call.dtmf` is the command that plays tones DOWN the line, so an event * called `call.dtmf` could not say whose finger it was. * * `digit` is this press. `digits` is every press so far on this call, so a * menu collecting an entry ("extension 204#") does not have to buffer them. */ interface CallDtmfReceivedEvent { event: "call.dtmf_received"; callId: string; digit: string; digits: string; } interface ConfigUpdatedEvent { event: "config_updated"; phone: string; } interface SessionConfigUpdatedEvent { event: "session_config_updated"; sessionId: string; success: boolean; } interface PhoneAddedEvent { event: "phone_added"; phone: string; } interface PhoneRemovedEvent { event: "phone_removed"; phone: string; } /** A single tool call from the server-side LLM. */ interface ToolCallItem { /** Tool call ID (for correlating results). */ id: string; /** Tool/function name. */ name: string; /** JSON-encoded arguments string. */ arguments: string; } interface ToolCallEvent { event: "llm.toolCall"; callId: string; /** Tool calls requested by the LLM. */ toolCalls: ToolCallItem[]; /** Message ID — pass back in `call.toolResult()`. */ msgId: string; } interface RegisteredEvent { event: "registered"; appId: string; organizationId: string; protocolVersion: string; } interface ErrorEvent { event: "error"; error: string; code?: string; } interface PongEvent { event: "pong"; timestamp: number; } interface AgentDisplacedEvent { event: "agent.displaced"; agentId: string; reason: string; } interface CallHeldEvent { event: "call.held"; callId: string; } interface CallUnheldEvent { event: "call.unheld"; callId: string; } interface CallMutedEvent { event: "call.muted"; callId: string; } interface CallUnmutedEvent { event: "call.unmuted"; callId: string; mutedTranscript: string | null; } /** The server registered a line and it now owns the number. */ interface LineCreatedEvent { event: "line.created"; number: string; } /** The registration was refused. `code` says which of the four cases it is. */ interface LineErrorEvent { event: "line.error"; number: string; code: "LINE_CONFLICT" | "LINE_CONFIG_ERROR" | "PHONE_NOT_IN_ORG" | "UNAUTHORIZED"; error: string; } /** Ack of `line.destroy` — the number is released. */ interface LineDestroyedEvent { event: "line.destroyed"; number: string; } /** * The owner swap succeeded: the agent is now driving this call, on the same * audio stream. A `call.ended` with reason `"routed"` follows, for the line. */ interface CallRoutedEvent { event: "call.routed"; callId: string; agent: string; } /** The owner swap did not happen. The line is still the owner; nothing was dropped. */ interface CallRouteFailedEvent { event: "call.route_failed"; callId: string; agent: string; reason: "offline" | "unknown" | "no_phone_config" | "capacity" | "swap_failed"; } type ServerEvent = CallStartedEvent | CallEndedEvent | SessionTimeoutEvent | SpeechStartedEvent | SpeechEndedEvent | UserSpeakingEvent | UserMessageEvent | EagerTurnEvent | TurnPauseEvent | TurnEndEvent | TurnResumedEvent | TurnContinuedEvent | BotSpeakingEvent | BotWordEvent | BotFinishedEvent | BotInterruptedEvent | BargeInEvent | MessageConfirmedEvent | ReplyRejectedEvent | AudioMetricsEvent | CallDialingEvent | CallErrorEvent | CallForwardedEvent | CallDtmfSentEvent | ConfigUpdatedEvent | SessionConfigUpdatedEvent | PhoneAddedEvent | PhoneRemovedEvent | RegisteredEvent | ErrorEvent | PongEvent | AgentDisplacedEvent | CallHeldEvent | CallUnheldEvent | CallMutedEvent | CallUnmutedEvent | CallRingingEvent | CallRejectedEvent | LineCreatedEvent | LineErrorEvent | LineDestroyedEvent | CallRoutedEvent | CallRouteFailedEvent; /** * Call event map and option types. * * Split out of `call.ts` so the class file is the behaviour and this is the * shape of what it emits. Everything here is re-exported from `call.ts`, which * is where index.ts (and every app) has always imported it from. */ interface CallEvents { [key: string]: (...args: any[]) => void; "speech.started": (event: SpeechStartedEvent) => void; "speech.ended": (event: SpeechEndedEvent) => void; "user.speaking": (event: UserSpeakingEvent) => void; "user.message": (event: UserMessageEvent) => void; "eager.turn": (turn: Turn) => void; "turn.pause": (event: TurnPauseEvent) => void; "turn.end": (turn: Turn) => void; "turn.resumed": (event: TurnResumedEvent) => void; "turn.continued": (event: TurnContinuedEvent) => void; "bot.speaking": (event: BotSpeakingEvent) => void; "bot.word": (event: BotWordEvent) => void; "bot.finished": (event: BotFinishedEvent) => void; "bot.interrupted": (event: BotInterruptedEvent) => void; "message.confirmed": (event: MessageConfirmedEvent) => void; "reply.rejected": (event: ReplyRejectedEvent) => void; "audio.metrics": (event: AudioMetricsEvent) => void; "call.held": () => void; "call.unheld": () => void; "call.muted": () => void; "call.unmuted": (mutedTranscript: string | null) => void; /** The caller pressed a key. Phone only — a browser has no keypad. */ "call.dtmf_received": (event: CallDtmfReceivedEvent) => void; /** A phone line handed this call to an agent. `routeTo()` resolves on the same fact. */ "call.routed": (event: CallRoutedEvent) => void; /** The hand-over did not happen; the line is still the owner. */ "call.route_failed": (event: CallRouteFailedEvent) => void; "llm.toolCall": (event: ToolCallEvent) => void; "skill.loaded": (event: SkillEvent) => void; "skill.unloaded": (event: SkillEvent) => void; /** * The server refused a `call.log()` entry (bad name, value too large, the * call has ended, over the durable cap…). Nothing was appended. The same * refusal also reaches the client-level `error` event, like every other * call-verb refusal. */ "log.rejected": (event: CallLogRejectedEvent) => void; /** * The server is about to generate a reply and is HOLDING the turn open for * you. Refresh per-turn prompt variables here. * * Return a promise (an `async` handler does this for you) and the SDK waits * for it before telling the server to go ahead — so an awaited * `call.setPromptVars()` inside this handler is guaranteed to land on THIS * generation, not the next one. */ "call.preparing": (call: Call) => void | Promise; /** * The server gave up waiting for `call.preparing` and generated with the * previous values. Only fires for agents that opted in with `preparing`. * This is the loud failure — a silent one is what this replaced. */ "call.preparingTimeout": (event: PreparingTimeoutEvent) => void; "session.timeout": (event: SessionTimeoutEvent) => void; "ended": (reason: string) => void; } /** Payload of `call.preparingTimeout`. */ interface PreparingTimeoutEvent { callId: string; /** Turn counter, as the server numbers it. */ turn: number; /** How long the server actually waited, in ms. */ waitedMs: number; /** The budget it was allowed to wait, in ms. */ budgetMs: number; } /** Emitted when a skill is activated/deactivated on a call. */ interface SkillEvent { /** Skill name. */ skill: string; /** Who triggered it: "model" (loadSkill meta-tool) or "manual" (call.loadSkill). */ by: "model" | "manual"; } /** Payload of `log.rejected` — a `call.log()` the server did not append. */ interface CallLogRejectedEvent { callId: string; /** The server's reason, e.g. `"invalid name"`, `"value too large (N > 16384 bytes)"`, `"call has ended"`. */ reason: string; /** The full message as the server worded it (`"call.log: "`). */ error: string; } interface CallLogOptions { /** * Upsert key for observers' projection: a later entry with the same * `(name, id)` replaces the value wholesale. Absent → every entry is its * own row. Max 128 chars. */ id?: string; /** * Live-only: fanned out to observers, never buffered, persisted or * replayed, and never counted against the durable cap. Default `false`. */ ephemeral?: boolean; } interface ReplyOptions { messageId?: string; inReplyTo?: string; } interface ForwardOptions { message?: string; announce?: boolean; } /** * Call SSE streaming — a live transcript of ONE call, pushed to an HTTP response. * * This lives outside `domain/call.ts` on purpose: a Call is a handle on a * session, not an HTTP concern. `Call.streamSSE()` stays as a one-line delegate * so the public API is unchanged. */ /** Minimal writable response for streamSSE. */ interface SSEResponse { writeHead?: (status: number, headers: Record) => void; write: (chunk: string) => boolean; end: () => void; on: (event: string, handler: () => void) => void; } interface StreamSSEOptions { /** Greeting text to send as the first bot message (for outbound calls). */ greeting?: string; } /** * Session configuration types — mirrors PROTOCOL.md §5. * * Provider-specific fields keep snake_case to mirror their underlying APIs. * This is the documented hybrid naming convention. */ interface DeepgramSTTConfig { provider: "deepgram"; language?: string; model?: string; interim_results?: boolean; smart_format?: boolean; punctuate?: boolean; profanity_filter?: boolean; use_native_vad?: boolean; endpointing_ms?: number; utterance_end_ms?: number; keywords?: string[]; keyterms?: string[]; min_confidence?: number | null; } interface FluxSTTConfig { provider: "deepgram-flux"; language?: string; language_hint?: string; eot_threshold?: number; eager_eot_threshold?: number; eot_timeout_ms?: number; keyterms?: string[]; min_confidence?: number | null; } interface GladiaSTTConfig { provider: "gladia"; language?: string; model?: string; endpointing?: number; max_duration_without_endpointing?: number; speech_threshold?: number; code_switching?: boolean; audio_enhancer?: boolean; } interface TranscribeSTTConfig { provider: "transcribe"; language?: string; } /** * Soniox real-time STT (`stt-rt-v5`, 60+ languages, one model). * * Soniox has SEMANTIC endpointing — it decides the user is done from pauses, * intonation and whether the utterance is complete — so by default it is also * the session's turn detector (like Flux). `turn` hands that job to the local * SmartTurn model instead; the `endpoint_*` fields tune Soniox's own decision * when you keep it. */ interface SonioxSTTConfig { provider: "soniox"; language?: string; model?: string; /** * Who ends the turn. `"native"` (default): Soniox's semantic endpointing. * `"smart_turn"`: the local SmartTurn model, with Soniox as transcriber only. * Pick `"smart_turn"` when `max_endpoint_delay_ms` keeps cutting long * mid-sentence pauses and raising it makes every reply wait. */ turn?: "native" | "smart_turn"; enable_endpoint_detection?: boolean; /** 0–3, higher = ends the turn sooner. Default 2. */ endpoint_latency_adjustment_level?: number; /** -1.0..1.0, positive = more endpoints. Default 0.3. */ endpoint_sensitivity?: number; /** Hard cap on the wait, 500–3000. Default 1500. */ max_endpoint_delay_ms?: number; /** Free-form recognition bias; `keyterms` is folded into it. */ context?: string; keyterms?: string[]; } type STTConfig = DeepgramSTTConfig | FluxSTTConfig | GladiaSTTConfig | TranscribeSTTConfig | SonioxSTTConfig; interface ElevenLabsTTSConfig { provider: "elevenlabs"; voice_id?: string; model?: string; speed?: number; stability?: number; similarity_boost?: number; style?: number; use_speaker_boost?: boolean; language?: string | null; } interface CartesiaTTSConfig { provider: "cartesia"; voice_id?: string; model?: string; speed?: number; volume?: number; emotion?: string | null; language?: string; } interface PollyTTSConfig { provider: "polly"; voice_id?: string; engine?: "neural" | "standard"; language?: string; rate?: string | null; volume?: string | null; pitch?: string | null; } type TTSConfig = ElevenLabsTTSConfig | CartesiaTTSConfig | PollyTTSConfig; interface InterruptionConfig { enabled?: boolean; energy_threshold_db?: number; min_duration_ms?: number; } interface SpeakerFilterConfig { enabled?: boolean; energy_threshold_db?: number; warmup_seconds?: number; } interface AnalysisConfig { send_audio_metrics?: boolean; audio_metrics_interval_ms?: number; send_turn_audio?: boolean; send_bot_audio?: boolean; } interface SessionConfig { stt?: STTConfig; tts?: TTSConfig; interruption?: InterruptionConfig; speaker_filter?: SpeakerFilterConfig; analysis?: AnalysisConfig; } /** * History — pluggable conversation persistence. * * When `history` is set on an agent config, conversations are saved * incrementally: each confirmed user message, bot response, and tool call * triggers an upsert. The final save on `call.ended` adds metadata. * * If the store implements `findByContact()`, prior conversations are * automatically restored for returning contacts — no extra code needed. * * Built-in: `JsonFileHistory` — appends to a JSON file on disk. * Custom: implement `HistoryStore` (only `save()` is required). * * @example * ```ts * import { Pinecall, JsonFileHistory } from "@pinecall/sdk"; * * const agent = pc.agent("my-agent", { * history: new JsonFileHistory("./data/calls.json"), * // auto-saves AND auto-restores — zero boilerplate * }); * ``` */ /** A conversation record — saved incrementally during a call and finalized on end. */ interface ConversationRecord { callId: string; agentId: string; channel: "phone" | "webrtc" | "chat" | "whatsapp" | "unknown"; direction: "inbound" | "outbound"; from: string; to: string; startedAt: number; endedAt: number; duration: number; reason: string; /** `"active"` while the call is in progress, `"ended"` after call.ended. */ status: "active" | "ended"; transcript: Array<{ role: string; content: string; }>; /** Full LLM messages including tool calls. Built incrementally from events. */ messages: Array>; metadata: Record; } /** * Pluggable storage interface for conversation history. * * Only `save()` is required. Implement `findByContact`, `list`, `get`, * `delete` for richer features (returning callers, admin dashboards, etc.). * * @example Custom MongoDB store * ```ts * class MongoHistory implements HistoryStore { * async save(record: ConversationRecord) { * await db.conversations.updateOne( * { callId: record.callId }, * { $set: record }, * { upsert: true }, * ); * } * * async findByContact(contactId: string, limit = 5) { * return db.conversations * .find({ from: contactId }) * .sort({ endedAt: -1 }) * .limit(limit) * .toArray(); * } * } * ``` */ interface HistoryStore { /** Save/upsert a conversation. Called on every confirmed message and on call.ended. */ save(record: ConversationRecord): Promise; /** * Find conversations by contact identifier (phone number, userId, etc.). * Searches the `from` field. Override for custom matching logic. */ findByContact?(contactId: string, limit?: number): Promise; /** List conversations for an agent, newest first. */ list?(agentId: string, limit?: number): Promise; /** Get a single conversation by call ID. */ get?(callId: string): Promise; /** Delete a single conversation. Returns true if found and deleted. */ delete?(callId: string): Promise; } /** * Built-in history store — appends conversations to a JSON file. * * Good for prototyping and small projects. For production at scale, * implement `HistoryStore` with MongoDB, Postgres, or your own API. * * @example * ```ts * import { JsonFileHistory } from "@pinecall/sdk"; * const history = new JsonFileHistory("./data/calls.json"); * ``` */ declare class JsonFileHistory implements HistoryStore { #private; readonly path: string; constructor(path: string); save(record: ConversationRecord): Promise; findByContact(contactId: string, limit?: number): Promise; list(agentId: string, limit?: number): Promise; get(callId: string): Promise; delete(callId: string): Promise; } /** * Call — per-session handle for interacting with a voice call. * * Created automatically when `call.started` is received. * Provides high-level methods: say(), reply(), replyStream(), hold(), mute(), cancel(), hangup(). * * Tracks `lastMessageId` from user.message events for automatic `in_reply_to`. * * The old _handleEvent() 140-line switch is gone. Dispatch handlers now call * typed _apply* methods directly. Each method is small, typed, and explicit. */ /** * What a `call.started` carries into a `Call`. * * Named because `Agent._createCall()` hands it on: a `PhoneLine` builds a * `LineCall` from exactly this, so the shape had to stop being an inline * literal on one constructor. */ interface CallInit { call_id: string; from: string; to: string; direction: "inbound" | "outbound"; transport?: "webrtc" | "phone" | "chat" | "whatsapp" | "unknown"; metadata?: Record; language?: string; /** The extension dialled after the number, when a line resolved one. */ extension?: string | null; /** Who is driving this session — a line, or an agent. */ owner?: "line" | "agent"; /** The line that handed this call over, `line:`. */ routed_from?: string; /** What the line heard and said before the hand-over. */ line_transcript?: Array<{ who: "caller" | "line"; text: string; at?: number; }>; } /** What an awaited `say()` reports: whether the caller talked over it. */ interface SayResult { interrupted: boolean; } declare class Call extends TypedEventBus { #private; readonly id: string; readonly from: string; readonly to: string; readonly direction: "inbound" | "outbound"; readonly transport: "webrtc" | "phone" | "chat" | "whatsapp" | "unknown"; readonly metadata: Record; /** * The SESSION's language, as the server resolved it: the browser's pick on * webrtc (`config.language` in the offer, e.g. a language toggle in the * page), the dialled number's channel config on phone, the agent's default * otherwise. BCP-47 base ("en", "es"). Empty when the server predates it. * Read this — not `metadata` — to localise a session's prompt: it is the * same fact the server used to pick STT/TTS language and the greeting. * * It FOLLOWS a mid-call switch: when a browser changes the session's * language (`VoiceSession.configure({ language })`), the server moves STT * and TTS and tells the SDK, which updates this before the next * `call.preparing`. So a prompt localised in that hook stays in step with * what the caller is hearing — read it per turn, do not cache it. */ get language(): string; /** @internal The server reported a new session language (mid-call switch). */ _setLanguage(lang: string): void; /** * The extension the caller dialled after the number ("33"), or null. * * Set by a phone line (`pc.line()`), and carried through `routeTo` so the * agent knows which door the call came through. Always null on a call that * no line answered. */ readonly extension: string | null; /** * The line that handed this call over — `line:` — or null when the * call came straight to the agent. */ readonly routedFrom: string | null; /** * What the line heard and said before it routed the call here. Empty on a * call no line answered. */ readonly lineTranscript: readonly LineTranscriptEntry[]; /** Auto-tracked from the latest user.message. Used as default `in_reply_to`. */ lastMessageId: string | null; /** Conversation transcript (user + assistant messages only). Derived from `messages`. */ get transcript(): Array<{ role: string; content: string; }>; /** Full LLM message history. Built incrementally from events; server copy merged on call.ended. */ messages: Array>; /** Conversation status. `"active"` during call, `"ended"` after call.ended. */ status: "active" | "ended"; /** Call duration in seconds. Populated on call.ended. */ duration: number; /** Epoch seconds when call started. Populated on call.ended. */ startedAt: number; /** Epoch seconds when call ended. Populated on call.ended. */ endedAt: number; /** End reason (e.g. "hangup", "timeout"). Populated on call.ended. */ reason: string; /** * Live preview of what the bot is currently saying. * Accumulated word-by-word from `bot.word` events. * Resets when a new bot message starts, clears when finished/interrupted. */ get currentBotText(): string; /** @internal The message id the word buffer belongs to — read by the SSE stream. */ get _currentBotMessageId(): string | null; /** Outbound greeting (set by dial). Used by streamSSE to send the first transcript entry. */ greeting: string | null; /** @internal Base prompt template (for variable interpolation). */ _promptTemplate: string; /** @internal Prompts directory (set by agent). */ _promptsDir: string; /** * Debounce interval for incremental history saves (ms). * The recorder owns it now; kept here because that is where it has always * been read from (and set from, in tests). */ static get HISTORY_DEBOUNCE_MS(): number; static set HISTORY_DEBOUNCE_MS(ms: number); constructor(data: CallInit, send: (data: Record) => void); /** * Send a greeting or standalone message (no in_reply_to required). * * Pass `{ addToHistory: true }` to inject this text into the server-side * LLM conversation history as an assistant message, so the model knows * what was said and won't repeat it. */ say(text: string, opts?: { addToHistory?: boolean; }): Promise; /** * @internal Resolve when the audio for `messageId` stopped coming out of * the speaker — finished, interrupted, or the call ended under it. * * NEVER rejects. `say()` has always been fire-and-forget and stays that * way: an un-awaited call cannot produce an unhandled rejection, because * there is nothing to reject. */ protected _awaitPlayback(messageId: string): Promise; /** * @internal One raw frame out on this call's socket. * * The private `#send` cannot cross a subclass boundary, and `LineCall` * has verbs of its own to send (`call.route`, `set_context`). */ protected _sendRaw(data: Record): void; /** Reply to the latest user message (auto-tracks in_reply_to). */ reply(text: string, options?: ReplyOptions): void; /** Create a streaming reply. Write tokens, then end. */ replyStream(turn?: Turn, messageId?: string): ReplyStream; /** Respond to a server-side LLM tool call. */ toolResult(msgId: string, results: Array<{ toolCallId: string; result: unknown; ephemeral?: boolean; noFollowup?: boolean; }>): void; /** Cancel a specific message or the current one. */ cancel(messageId?: string): void; /** Clear all queued audio. */ clear(): void; /** Hang up the call. */ hangup(): void; /** Forward the call to another number. */ forward(to: string, options?: ForwardOptions): void; /** Send DTMF tones. */ sendDTMF(digits: string): void; /** Update config for this call (mid-call). */ update(opts: Record): void; /** @deprecated Use `call.update()` instead. */ configure(opts: Record): void; /** @deprecated Use `call.update()` instead. */ updateConfig(config: Partial): void; /** Skills currently active on this call (server-authoritative). */ get activeSkills(): string[]; /** * Activate a declared skill on this call now — exposing its tools and * instructions to the LLM and adding its knowledge base to RAG. Programmatic * counterpart to the model-driven `loadSkill` meta-tool. Takes effect on the * next LLM turn. Emits `skill.loaded` once the server confirms. */ loadSkill(name: string): void; /** Deactivate a skill on this call (inverse of `loadSkill`). */ unloadSkill(name: string): void; /** @internal Update tracked active-skill state from a server skill event. */ _setSkillActive(name: string, active: boolean): void; /** * Append a custom entry to this call's log: `type: "custom"`, * `data: { name, value, id?, turn }`. Durable by default — visible to every * observer of the call (dashboards, `useCall`, `GET /v1/calls/{id}/events`, * SSE) and replayed on resume; `ephemeral: true` fans it out live only. * Reachable from a tool through its `call` parameter. * * Fire-and-forget: the server validates (`name` matches * `^[a-z0-9][a-z0-9._-]{0,63}$`, `value` ≤ 16 KiB as JSON, `id` ≤ 128 * chars, ≤ 1000 durable entries per call, call still open) and a refusal * arrives as the call's `log.rejected` event (and the client `error`). */ log(name: string, value: unknown, opts?: CallLogOptions): void; hold(): void; unhold(): void; mute(): void; unmute(): void; getHistory(): Promise>; addHistory(messages: Array<{ role: string; content: string; }>): Promise; setHistory(messages: Array<{ role: string; content: string; }>): Promise; clearHistory(): Promise; addContext(text: string): Promise; setPrompt(prompt: string): Promise; setPromptFile(filePath: string): Promise; /** * Push `{{var}}` values for the CURRENT turn. Highest precedence: they beat * the agent-level `promptVars` and stay until you overwrite them. * * Inside a `call.preparing` handler this is the per-turn contract — return * the promise (or `await` it) and the server holds the generation until it * lands. Resolves with the message count, or rejects if the server never * acknowledges it. */ setPromptVars(vars: Record): Promise; /** @internal Reset word buffer and start tracking a new bot message. */ _applyBotSpeaking(event: BotSpeakingEvent): void; /** @internal Append a word to the live preview buffer. */ _applyBotWord(event: BotWordEvent): void; /** @internal Clear the word buffer (bot finished or interrupted). */ _clearBotWords(): void; /** @internal Resolve a pending history request/response promise. */ _applyHistoryResponse(eventType: string, data: Record): boolean; /** * @internal Run the `call.preparing` handlers and hand back whatever they * returned, so the caller can await async ones before releasing the turn. */ _emitPreparing(): unknown[]; /** @internal True when the app is listening for the pre-turn hook. */ _hasPreparingListener(): boolean; /** @internal Apply user.message — tracks lastMessageId and turn state. */ _applyUserMessage(event: UserMessageEvent): void; /** @internal Apply eager.turn — pre-tracks turn state. */ _applyEagerTurn(turn: Turn): void; /** @internal Apply turn.end — emits Turn with merged state. */ _applyTurnEnd(wireEvent: Record): void; /** @internal Apply turn.continued — aborts all active streams. */ _applyTurnContinued(event: TurnContinuedEvent): void; /** @internal Emit a typed event. Used by dispatch handlers. */ _emitWire(event: K, ...args: Parameters): void; /** @internal Mark call as ended. Populates messages from server data. */ _applyEnd(reason: string, data?: Record): void; /** * @internal Initialize history tracking. Called by lifecycle handler on call.started. */ _initHistory(agentId: string, historyStore: HistoryStore): void; /** * @internal Append a message and trigger a debounced history save. * Called by speech/bot/tool handlers on confirmed events. */ _pushMessage(msg: Record): void; /** * Stream this call's events as Server-Sent Events to an HTTP response — * headers, word buffering, keepalive pings and cleanup. See sse/call-stream.ts. */ streamSSE(res: SSEResponse, opts?: StreamSSEOptions): void; } /** * RingingCall — lightweight handle for an inbound call pending accept/reject. * * Created when the server sends `call.ringing` (opt-in via `ringing: true` * on the phone channel). Unlike `Call`, this object only has `accept()` and * `reject()` — no `say()`, `reply()`, `hangup()`, etc. * * If neither method is called within the server timeout (5s), the call is * auto-accepted and `call.started` fires as usual. */ declare class RingingCall { #private; readonly callId: string; readonly from: string; readonly to: string; readonly direction: "inbound"; constructor(data: { callId: string; from: string; to: string; agentId: string; }, send: (data: Record) => void); /** Whether accept() or reject() has been called. */ get settled(): boolean; /** Accept the call — proceeds to call.started. */ accept(): void; /** * Reject the call — caller hears a rejection tone, call.started never fires. * * @param reason - `"busy"` (busy tone) or `"rejected"` (generic rejection). * Default: `"busy"`. */ reject(reason?: "busy" | "rejected"): void; } /** * tool() — declarative tool definitions with Zod schema + auto-execution. * * Usage: * ```ts * import { tool } from "@pinecall/sdk"; * import { z } from "zod"; * * const openDoor = tool({ * name: "openDoor", * description: "Opens the door if the code is valid", * schema: z.object({ code: z.string().describe("5-digit code") }), * execute: async ({ code }, call) => ({ success: VALID_CODES.has(code) }), * }); * ``` * * The returned Tool object is passed to `tools: [openDoor]` in agent config. * The SDK auto-executes matching tools on `llm.tool_call` events. */ interface ToolConfig { name: string; description: string; /** Zod schema (or any object with .parse() and ._def). */ schema: ZodLike; /** Execute function — receives parsed args + call. */ execute: (args: T, call: Call) => unknown | Promise; /** * Ephemeral tools — the result is used to generate the current reply but is * NOT persisted to conversation history (neither the LLM context for later * turns nor the saved transcript). Defaults to `false` (results are saved). * Use for sensitive lookups or large/noisy payloads you don't want to keep. */ ephemeral?: boolean; /** * Fire-and-forget / UI-only tools — after this tool's result the server does * NOT generate a follow-up assistant turn. Use for tools whose result only * drives the UI (suggested-question chips, a toast, a state mutation) and * should NOT produce another spoken/written reply. The result still reaches * the client via `llm.tool_result`. Defaults to `false`. * * Only takes effect when EVERY tool called in that round is `noFollowup`; a * mixed round (a normal tool + a noFollowup tool) still replies, because the * normal tool's result needs one. */ noFollowup?: boolean; } interface Tool { readonly name: string; readonly description: string; readonly schema: ZodLike; readonly execute: (args: T, call: Call) => unknown | Promise; /** Result is not persisted to history when true. */ readonly ephemeral: boolean; /** No follow-up assistant turn is generated after this tool when true. */ readonly noFollowup: boolean; /** @internal JSON Schema for wire protocol. */ readonly _jsonSchema: Record; /** @internal Convert to OpenAI function-calling wire format. */ _toWire(): Record; } /** Duck-typed Zod schema — anything with parse() and _def. */ interface ZodLike { parse: (input: unknown) => T; _def: Record; [key: string]: any; } declare function tool(config: ToolConfig): Tool; /** * skill() — bundle prompt + tools + knowledge base into a unit the LLM can * load and unload on demand (progressive disclosure). * * A Skill is a named capability. When it is *active* the server: * - injects its `instructions` as a dedicated section of the system prompt, * - exposes its `tools` to the LLM (merged into the live tool list), * - includes its `knowledgeBase` in RAG retrieval. * When inactive, none of that is visible to the model — keeping the prompt and * tool list small. Activation is driven by the model (auto-generated * `loadSkill` / `unloadSkill` meta-tools), by your code (`call.loadSkill(...)`), * or pinned with `activation: "always"`. * * Usage: * ```ts * import { skill, tool } from "@pinecall/sdk"; * import { z } from "zod"; * * const booking = skill({ * name: "booking", * description: "Reserve, reschedule or cancel calendar appointments.", * instructions: "Confirm date, time and name before booking.", * tools: [getAvailableSlots, bookAppointment], * knowledgeBase: "kb_booking_policies", * }); * * pc.agent("front-desk", { tools: [endCall], skills: [booking] }); * ``` */ /** How a skill becomes active. */ type SkillActivation = "model" | "manual" | "always"; interface SkillConfig { /** Unique id — used by `loadSkill("name")`. */ name: string; /** Shown to the LLM (in the `loadSkill` meta-tool) so it knows when to load it. */ description: string; /** Prompt fragment injected as a system-prompt section while the skill is active. */ instructions?: string; /** Tools that become visible to the LLM while the skill is active. */ tools?: Tool[]; /** Knowledge base (id) added to RAG retrieval while the skill is active. */ knowledgeBase?: string; /** Per-skill RAG top-k. Falls back to the agent's value when omitted. */ ragTopK?: number; /** * Activation mode: * - "model" (default) — the LLM loads it via the `loadSkill` meta-tool. * - "manual" — only your code loads it (`call.loadSkill`). * - "always" — active from the start of every call. */ activation?: SkillActivation; } interface Skill { readonly name: string; readonly description: string; readonly instructions?: string; readonly tools: Tool[]; readonly knowledgeBase?: string; readonly ragTopK?: number; readonly activation: SkillActivation; /** @internal Convert to wire format for the server. */ _toWire(): Record; } declare function skill(config: SkillConfig): Skill; /** * Voice configuration. * * Use the `provider/friendly-id` format (always lowercase): * * @example * voice: "elevenlabs/sarah" // ElevenLabs voice * voice: "cartesia/yumiko" // Cartesia voice * voice: "polly/lucia" // AWS Polly voice * * // Full config object for advanced settings: * voice: { provider: "elevenlabs", voice_id: "...", speed: 1.1 } */ type VoiceShortcut = string | Record; /** STT shortcut: "deepgram/flux" or full config object. */ type STTShortcut = string | Record; /** Interruption shortcut: false (disable) or config object. */ type InterruptionShortcut = boolean | Record; /** See `AgentConfig.memory`. */ interface MemoryConfig { /** What is worth remembering, in the business's words. Drives the extractor. */ remember?: string[]; /** What must never be stored. */ forget?: string[]; /** `"turn"` (default): after every exchange. `"call.ended"`: one pass per call. */ consolidate?: "turn" | "call.ended"; /** Extractor model — small and fixed. Default `openrouter/qwen/qwen3-8b`, on the org's OpenRouter key. */ model?: string; /** Metadata key(s) that identify the contact on WebRTC/chat. Default `["contactId","userId","phone"]`. */ contactKey?: string | string[]; /** `false` switches memory off without removing the block. */ enabled?: boolean; } interface AgentConfig { voice?: VoiceShortcut; language?: string; /** * Force the faster ElevenLabs flash model, opting out of the multilingual * auto-default. * * For non-English agents (any `language` other than `en`) the server * automatically selects `eleven_multilingual_v2` — it pronounces numbers, * dates, currency and accents correctly, at the cost of slightly higher * latency. Set `flash: true` to keep `eleven_flash_v2_5` instead (lowest * latency, cheaper), accepting that non-English text normalization is weaker. * * - Only affects ElevenLabs voices (no effect on Cartesia/Polly). * - No-op for English agents (they already default to flash). * - Ignored when you pin a model explicitly via the `voice` object — an * explicit `voice: { model }` always wins. * * @example * // Spanish agent that prioritizes latency over pronunciation quality: * pc.agent("sofia", { voice: "elevenlabs/agus", language: "es", flash: true }); */ flash?: boolean; stt?: STTShortcut; interruption?: InterruptionShortcut; /** Server-side LLM: "openai/gpt-4.1-mini" or full config object. */ llm?: string | Record; /** System prompt for the LLM. */ prompt?: string; /** * Default values for the prompt's `{{vars}}`, seeded server-side at agent * registration. They resolve on the FIRST turn (all transports, incl. chat) * without waiting for a per-call `setPromptVars` round-trip. Per-call * `setPromptVars` (in `call.preparing`) still overrides these for fresh values. */ promptVars?: Record; /** * Opt in to the pre-turn barrier: before every generation the server fires * `call.preparing` and HOLDS the turn until your handler answers (or the * budget runs out). * * Set it when the app computes values per turn — live CRM state, a catalog, * a clock in the tenant's timezone — and the generation must not run with * the previous ones. * * ```ts * pc.agent("front-desk", { preparing: true }); // 1500ms budget * pc.agent("front-desk", { preparing: { timeoutMs: 2500 } }); // your own * pc.agent("front-desk", { preparing: false }); // never wait * ``` * * - **omitted** — legacy behaviour: the server waits 150ms, and gives up on * waiting entirely after a few turns with no answer. Fine for the majority * of agents, which have no `call.preparing` handler at all. * - **`true` / `{ timeoutMs }`** — a real budget (capped at 5000ms), and a * `call.preparingTimeout` event whenever it is missed. The turn resumes the * instant your handler settles, so the budget is a ceiling, not a delay. * - **`false`** — the server never even signals. The cheapest option. * * The wait overlaps knowledge-base retrieval, so a KB-backed agent often * spends nothing extra at all. */ preparing?: boolean | { enabled?: boolean; timeoutMs?: number; }; /** * IANA timezone (e.g. `"Europe/Madrid"`, `"America/Lima"`). The server * resolves the built-in date/time vars — `{{date}}`, `{{time}}`, `{{day}}`, * `{{datetime}}`, `{{date_block}}` — in THIS zone, on every transport * (voice, chat, WhatsApp), with no per-turn round-trip. Omit → server-local * (UTC). The clean way to give an agent a "location clock". */ timezone?: string; /** * Use the `prompt` verbatim, with NO auto-injected guidance. Default `false`. * * When `false` (default), the server augments your prompt with house-style * guidance tailored to the channel — so the agent "just works" out of the box: * - **voice** (phone / WebRTC): answer like a phone receptionist — natural * spoken sentences, no markdown/emojis (everything is read aloud by TTS). * - **chat**: clean common Markdown + tasteful emojis. * - **whatsapp**: WhatsApp's own formatting (`*bold*`, `_italic_`, * `~strike~`, ` ```mono``` `) — NOT standard Markdown. * It also injects, when the agent has `skills`, a note on using the * `loadSkill` / `unloadSkill` tools. * * Set `true` to take full control and disable all of that injection. */ rawPrompt?: boolean; /** Declarative tool definitions created with `tool()`. Auto-executed on llm.tool_call. */ tools?: Tool[]; /** * Skills created with `skill()` — bundles of prompt + tools + knowledge base * that the LLM loads and unloads on demand (progressive disclosure). * * Skills declared here are sent to the server but kept latent: their tools * and instructions only reach the model once the skill is active (via the * `loadSkill` meta-tool, `call.loadSkill(...)`, or `activation: "always"`). * Their `execute` functions still run on this client regardless of visibility. * * @example * skills: [booking, billing, techSupport] */ skills?: Skill[]; config?: SessionConfig; /** * Knowledge base (RAG) the agent grounds its answers on. * * Pass the id of a knowledge base created in the Pinecall dashboard * (Knowledge section). Before every LLM turn, the voice server retrieves * the most relevant document chunks for the user's message and injects them * into the prompt. * * Placement is controlled by the `{{RAG_CONTEXT}}` template variable in your * `prompt`: include it to decide exactly where the retrieved docs go. If the * prompt does NOT contain `{{RAG_CONTEXT}}`, the context is appended * automatically — so a knowledge base works out of the box. * * Pass a single id, or an array of ids to ground on several knowledge bases * at once — retrieval merges the top chunks across them by score. * * @example * pc.agent("docs", { * knowledgeBase: "kb_1a2b3c", * prompt: "You are a docs assistant.\n\n{{RAG_CONTEXT}}\n\nAnswer only from the docs above.", * }); * @example * pc.agent("support", { knowledgeBase: ["kb_product", "kb_billing"] }); */ knowledgeBase?: string | string[]; /** * Greeting spoken on every inbound `call.started`. * Added to LLM history by default so the model knows what was said. * * - **String**: static greeting, `addToHistory` defaults to `true`. * - **Object**: `{ text, addToHistory? }` for explicit control. * - **Per-language map**: `{ en: "Hi…", es: "Hola…" }` — the server picks the * entry for the SESSION's language (the browser's `config.language` on * webrtc, the number's channel language on phone, the sealed lang on chat). * - **Function**: `(call) => string` for dynamic greetings, `addToHistory` defaults to `true`. * * @example "Hi! How can I help?" * @example { text: "Hi!", addToHistory: false } * @example async (call) => `Hello ${(await db.findByPhone(call.from)).name}!` */ greeting?: string | { text: string; addToHistory?: boolean; } | Record | ((call: Call) => string | Promise); /** * Deliver the greeting on CHAT sessions too, as the first bot message — * rendered by the widget, recorded in the transcript, and in the LLM * history so the model never introduces itself again. String/object * greetings only (a function greeting stays client-side and voice-only). * * Opt-in on purpose: most existing chat clients paint their own welcome * client-side, and flipping the default would greet those visitors twice. * Set it when the agent's greeting is the single source for every channel. */ greetingInChat?: boolean; /** * Long-term memory per contact — facts the agent keeps ACROSS conversations * and hands you as they are learned. * * After each reply (or once per call, see `consolidate`) a small model reads * the last exchange against the facts already held about the contact and * returns ops — add / update / delete — which the server applies to a * per-contact `memory.md` on its semantic index, puts back into the prompt * as `{{MEMORY}}`, and emits as ONE `memory.ops` event: to `agent.on(...)`, * to the call log (the observer, cursor-replayable) and to the browser's * DataChannel. Never on the turn's own path — it runs after the bot spoke. * * Identity is the precondition: the caller's number on phone/WhatsApp; on * WebRTC/chat a key your backend sealed into the token (`contactKey`, * default `contactId` → `userId` → `phone`). No identity → inert. * * ```ts * pc.agent("front-desk", { * prompt: "…\n## About this caller\n{{MEMORY}}", * memory: { * remember: ["name and preferred address", "services + preferred professional", * "allergies staff must know", "contact preferences and opt-outs"], * forget: ["payment details", "health beyond treatment sensitivities"], * consolidate: "turn", // or "call.ended" * model: "openrouter/qwen/qwen3-8b", // the default; nano on purpose * }, * }); * agent.on("memory.ops", (m) => db.upsertMany(m.ops)); * const hits = await agent.memory.search("asked not to be called", { k: 20 }); * ``` */ memory?: MemoryConfig; /** * Phone number to register (Twilio E.164 or SIP URI). * * @example "+14155551234" * @example { number: "+14155551234", ringing: true } */ phoneNumber?: string | PhoneNumberConfig; /** * Multiple phone numbers with per-number config (e.g. one per language/region). * * @example ["+14155551234", "+34612345678"] * @example [{ number: "+14155551234", language: "en" }, { number: "+34612345678", language: "es" }] */ phoneNumbers?: Array; /** * WhatsApp channels to register (Meta Cloud API credentials). * * @example [{ phoneNumberId: "123", accessToken: "EAA..." }] */ whatsapp?: WhatsAppChannelConfig[]; /** * Pluggable conversation persistence. When set, conversations are * auto-saved on every `call.ended`. * * Use the built-in `JsonFileHistory` for prototyping, or implement * `HistoryStore` for MongoDB, Postgres, or your own API. * * @example * ```ts * import { JsonFileHistory } from "@pinecall/sdk"; * const agent = pc.agent("my-agent", { * history: new JsonFileHistory("./data/calls.json"), * }); * ``` */ history?: HistoryStore; /** * Allowed origins for public browser token access (WebRTC, Chat). * * When set, the token endpoint accepts browser requests from these * origins without an API key. Supports wildcards: * - `"https://mysite.com"` — exact match * - `"https://*.mysite.com"` — subdomain wildcard * - `"http://localhost:*"` — any port (dev) * * When NOT set (default), token requests require API key authentication * via `pc.createToken()` or `agent.createToken()`. */ allowedOrigins?: string[]; } /** Per-phone-number configuration for `phoneNumber` option. */ interface PhoneNumberConfig { /** Phone number in E.164 format or SIP URI. */ number: string; /** * Enable call.ringing for this number. * When true, inbound calls emit `call.ringing` instead of auto-accepting. */ ringing?: boolean; /** Per-number voice override. */ voice?: VoiceShortcut; /** Per-number STT override (e.g. `"deepgram/nova-3"` for languages not supported by Flux). */ stt?: STTShortcut; /** Per-number language override. */ language?: string; } interface ChannelConfig { voice?: VoiceShortcut; language?: string; /** Force ElevenLabs flash, opting out of the multilingual auto-default. See {@link AgentConfig.flash}. */ flash?: boolean; stt?: STTShortcut; interruption?: InterruptionShortcut; /** Server-side LLM: "openai/gpt-4.1-mini" or full config object. */ llm?: string | Record; config?: Partial; /** * Enable call.ringing for this channel (phone only). * * When true, inbound calls emit `call.ringing` instead of auto-accepting. * The SDK must call `accept()` or `reject()` on the RingingCall. * If neither is called within 5 seconds, the call is auto-accepted. * * Default: false (auto-accept, zero latency impact). */ ringing?: boolean; } /** WhatsApp channel config — credentials for Meta Cloud API. */ interface WhatsAppChannelConfig extends ChannelConfig { /** Meta Phone Number ID (numeric string from API Setup). */ phoneNumberId: string; /** Meta Graph API access token (permanent, not temporary). */ accessToken: string; /** Webhook verification token (you choose this, must match Meta config). */ verifyToken?: string; /** Meta App Secret for HMAC signature verification (recommended). */ appSecret?: string; /** * Actual WhatsApp phone number in E.164 format (e.g. "+51987654321"). * Used by the widget to auto-generate wa.me links. * Optional — if not set, the WhatsApp option won't appear in the ContactHub popover. */ phone?: string; } /** * Memory REST client — what an agent remembers about its contacts. * * Talks to `/api/memory` on the voice server with the org's API key. The * agent does not need to be online: memory is a store, not a session, which * is what lets a back office ask "which callers asked not to be phoned" long * after the calls ended. */ interface MemoryFact { id: string; kind: string; text: string; confidence: number; valid_from: string; valid_to?: string | null; supersedes?: string; evidence?: string; source?: { call?: string; turn?: number; transport?: string; }; } interface MemoryHit { contact: string; kind: string; text: string; score: number | null; } interface MemoryContact { contact: string; revision: number; facts: MemoryFact[]; /** The regenerated memory.md — the same text the prompt sees as {{MEMORY}}. */ memoryMd: string; } /** * Token API — create tokens for browser connections. */ interface WebRTCToken { token: string; server?: string; } interface TokenResponse { token: string; server: string; expiresIn: number; } interface FetchWebRTCTokenOptions { agentId: string; apiUrl?: string; apiKey?: string; } /** * Token scope (CALL_LOG_SPEC.md §5). * * · `observe` read-only: the call log, nothing else. Agent-scoped (all * its calls) or call-scoped (one, via `callId`). * · `participate` media + log — what today's webrtc/chat tokens already are. * · `supervise` observe + the control verbs of §7. * * Optional and additive: omitting it mints exactly the token this SDK has * always minted (§8 — `createToken("webrtc"|"chat")` keeps working). */ type TokenScope = "observe" | "participate" | "supervise"; /** Extra, optional token attributes. Absent ⇒ today's behavior, byte-identical. */ interface TokenScopeOptions { /** §5 scope. Absent ⇒ the server's channel default. */ scope?: TokenScope; /** Narrow an `observe`/`supervise` token to a single call. */ callId?: string; } interface CreateTokenOptions extends TokenScopeOptions { channel: "webrtc" | "chat" | "stream"; /** * One agent slug — or a non-empty list: the AGENT SET this token may see * (CALL_LOG_SPEC.md §5, "VISIBILITY — the agent set"). Minted per logged * session by YOUR backend, sealed in the token: the browser cannot widen * it. Stream tokens only; media channels take one agent. */ agentId: string | readonly string[]; apiKey: string; apiUrl?: string; /** * Sealed session metadata baked into the signed token. Trusted server-side * (the browser cannot forge or alter it) — surfaces as `call.metadata` for * tools and event handlers. Use for per-session identity (tenantId, userId, * role). Only honored when minting with an API key (this method). Max ~2KB. */ metadata?: Record; } declare function createToken(opts: CreateTokenOptions): Promise; /** One applied memory op, as emitted on `memory.ops`. */ type MemoryOp = { op: "add"; id: string; kind: string; text: string; confidence: number; valid_from: string; evidence?: string; } | { op: "update"; id: string; supersedes: string; kind: string; text: string; confidence: number; valid_from: string; evidence?: string; } | { op: "delete"; id: string; kind?: string; text?: string; reason?: string; }; /** The `memory.ops` payload — the same JSON the call log and the DataChannel carry. */ interface MemoryOpsEvent { contact: string; call_id: string; turn: number; /** True on the end-of-call consolidation pass. */ final: boolean; ops: MemoryOp[]; memory: { revision: number; path: string; }; model: string; latency_ms: number; } /** `agent.memory` — read what the server remembers. */ interface AgentMemory { /** Semantic + lexical search. With `contact`, only that contact; without, across every contact of the agent. */ search(query: string, opts?: { contact?: string | null; k?: number; }): Promise; /** Every fact held about a contact, plus the regenerated memory.md. */ get(contact: string): Promise; /** The right to be forgotten: facts, view and index entries, gone. */ forget(contact: string): Promise; } interface AgentEvents { [key: string]: (...args: any[]) => void; ready: () => void; "call.started": (call: Call) => void; /** * Memory learned or revised something about the contact of a session — * see `AgentConfig.memory`. `ops` is what was APPLIED (final ids, validity), * not what was asked; `final` marks the end-of-call pass. */ "memory.ops": (ops: MemoryOpsEvent, call: Call | undefined) => void; "call.ended": (call: Call, reason: string) => void; "call.ringing": (call: RingingCall) => void; "speech.started": (event: SpeechStartedEvent, call: Call) => void; "speech.ended": (event: SpeechEndedEvent, call: Call) => void; "user.speaking": (event: UserSpeakingEvent, call: Call) => void; "user.message": (event: UserMessageEvent, call: Call) => void; "eager.turn": (turn: Turn, call: Call) => void; "turn.pause": (event: TurnPauseEvent, call: Call) => void; "turn.end": (turn: Turn, call: Call) => void; "turn.resumed": (event: TurnResumedEvent, call: Call) => void; "turn.continued": (event: TurnContinuedEvent, call: Call) => void; "bot.speaking": (event: BotSpeakingEvent, call: Call) => void; "bot.word": (event: BotWordEvent, call: Call) => void; "bot.finished": (event: BotFinishedEvent, call: Call) => void; "bot.interrupted": (event: BotInterruptedEvent, call: Call) => void; "message.confirmed": (event: MessageConfirmedEvent, call: Call) => void; "reply.rejected": (event: ReplyRejectedEvent, call: Call) => void; "audio.metrics": (event: AudioMetricsEvent, call: Call) => void; "session.idleWarning": (event: any, call: Call) => void; "session.timeout": (event: SessionTimeoutEvent, call: Call) => void; "call.dtmf_received": (event: CallDtmfReceivedEvent, call: Call) => void; "llm.toolCall": (event: ToolCallEvent, call: Call) => void; "skill.loaded": (event: SkillEvent, call: Call) => void; "skill.unloaded": (event: SkillEvent, call: Call) => void; "channel.added": (type: string, ref: string) => void; "channel.configured": (ref: string) => void; "channel.removed": (ref: string) => void; "whatsapp.message": (event: Record) => void; "whatsapp.response": (event: Record) => void; "whatsapp.status": (event: Record) => void; "whatsapp.sessionStarted": (session: WhatsAppSession) => void; "whatsapp.sessionEnded": (event: Record) => void; "session.paused": (event: { sessionId?: string; contact?: string; }) => void; "session.resumed": (event: { sessionId?: string; contact?: string; }) => void; "call.preparing": (call: Call) => void | Promise; "call.preparingTimeout": (event: PreparingTimeoutEvent, call: Call) => void; } declare class Agent extends TypedEventBus { #private; readonly id: string; /** Human-readable display name. Defaults to id. */ name: string; /** @internal — created by Pinecall.agent() */ constructor(id: string, config: AgentConfig, send: (data: Record) => void); /** * Send a raw protocol message. Buffers if the agent isn't server-ready yet. * * Prefer high-level methods like `call.toolResult()`, `call.say()`, * `call.reply()`, `agent.setDevCallers()` etc. Use `send()` only * as an escape hatch for protocol-level access. */ send(data: Record): void; /** @internal Alias for backwards compat — use send() instead. */ _send(data: Record): void; /** All active calls for this agent. */ get calls(): ReadonlyMap; /** Get a specific call by ID. */ call(callId: string): Call | undefined; /** Get the current agent config. */ getConfig(): AgentConfig; /** * True once the SERVER has acknowledged this agent's registration. * * `pc.agent()` returns synchronously — it only *queues* `agent.create` on * the socket. Until the server answers `agent.created`, the agent does not * exist server-side, so token mints and inbound routing 404 on it. */ get registered(): boolean; /** * Resolves when the SERVER has acknowledged this agent's registration * (`agent.created` / `agent.resumed`) — NOT when `pc.agent()` returned. * * Await this before doing anything that requires the agent to exist * server-side (minting a chat/WebRTC token, dialing out). Rejects with * {@link AgentConflictError} if the registration is terminally refused. * Goes back to pending if the socket drops, and resolves again once the * reconnect re-registers the agent. * * @example * const agent = pc.agent("recepcion", { prompt }); * await agent.ready; // server now knows it * const { token } = await agent.createToken("chat"); */ get ready(): Promise; /** * Register a phone number or SIP URI. Idempotent — calling again with the * same number updates its config. * * @example * agent.addPhoneNumber("+13186330963"); * agent.addPhoneNumber("+34612345678", { ringing: true, voice: "elevenlabs/lucia" }); * agent.addPhoneNumber("sip:bot@trunk.twilio.com"); */ addPhoneNumber(number: string, config?: ChannelConfig): void; /** * Register a WhatsApp channel. Idempotent — calling again with the * same phoneNumberId updates its config. * * @example * agent.addWhatsapp({ phoneNumberId: "123", accessToken: "EAA..." }); */ addWhatsapp(config: WhatsAppChannelConfig): void; /** * Register the browser voice channel, so this agent can take WebRTC calls. * * Phone and WhatsApp had public methods and these two did not — the only * way in was `_addChannel`, which is internal. Anything outside this * package (a plugin, another @pinecall/* module) could not open a browser * channel without reaching past the public API. * * @example * agent.addWebrtc(); */ addWebrtc(config?: ChannelConfig): void; /** * Register the text chat channel. Same reasoning as `addWebrtc()`. * * @example * agent.addChat(); */ addChat(config?: ChannelConfig): void; /** * Remove a phone number or SIP URI. * * @example agent.removePhone("+13186330963"); */ removePhone(number: string): void; /** * Remove a WhatsApp channel by phoneNumberId. * * @example agent.removeWhatsapp("123"); */ removeWhatsapp(phoneNumberId: string): void; /** @internal — used by client.ts and config processing. */ _addChannel(type: "phone" | "webrtc" | "chat" | "whatsapp", ref?: string | WhatsAppChannelConfig, config?: ChannelConfig): void; configureChannel(ref: string, config: ChannelConfig): void; removeChannel(ref: string): void; update(opts: AgentConfig): void; /** * Attach (or hot-reload) a single skill at runtime. The skill is sent to the * server and kept latent until activated (by the model, by `call.loadSkill`, * or immediately if `activation: "always"`). */ skill(config: SkillConfig): Skill; /** @deprecated Use `agent.update()` instead. */ configure(opts: AgentConfig): void; configureSession(sessionId: string, opts: ChannelConfig): void; routeCallers(callers: string[]): void; /** * Mint a short-lived browser token for this agent (webrtc / chat / stream). * * `metadata` (optional) is sealed into the signed token — trusted server-side * (the browser cannot forge it) and surfaced as `call.metadata` for tools. * Use for per-session identity (tenantId, userId, role). */ createToken(channel: "webrtc" | "chat" | "stream", metadata?: Record, opts?: TokenScopeOptions): Promise; /** @internal Set the parent Pinecall client reference. */ _setClient(client: { createToken: (channel: "webrtc" | "chat" | "stream", agentId: string, metadata?: Record, opts?: TokenScopeOptions) => Promise; memoryApi?: { apiKey: string; apiUrl: string; }; }): void; /** * What this agent remembers about its contacts. Reads go to the server's * store over REST with the org's key — the agent need not be online. * See `AgentConfig.memory` for how facts get there. */ get memory(): AgentMemory; dial(options: { to: string; /** Caller ID. If omitted, uses the agent's only phone channel. */ from?: string; greeting?: string; metadata?: Record; config?: Record; /** * When true, the server also detects the OTHER party's end-of-turn and * emits `turn.end` to this (initiating) side — so an automated caller * (e.g. a test/judge agent talking to another agent) knows when to * speak. Default false (a normal caller is a human and doesn't need it). */ detectTurnEnd?: boolean; }): Promise; /** * Place a VOICE call to ANOTHER Pinecall agent (no phone, no WebRTC). * * The server cross-wires the two agents' audio: this agent's TTS becomes the * target's incoming audio and vice-versa, so both run their real * STT/turn-detection/TTS pipelines. This agent is driven manually — speak * with `call.say()` and read the target via `user.message` / `turn.end`. * Typically the calling agent has no server-side LLM (it's puppeted by your * code), e.g. the `pinecall test` voice judge. * * @param target - The target agent's slug (must be online in the same org). */ bridge(target: string, options?: { greeting?: string; /** Detect the target's end-of-turn and emit `turn.end` to this side. Default true. */ detectTurnEnd?: boolean; /** Per-call config override for THIS (calling) agent — voice, STT, language. */ config?: Record; /** Enable live listening / recording on the bridged call. */ media?: { live?: boolean; recording?: boolean; }; metadata?: Record; }): Promise; /** * Pause the AI agent. While paused, incoming messages are forwarded to * the SDK but the LLM does not generate responses — a human takes over. * * @param target - Session ID, `{ contact: "+34..." }`, or omit for global pause. */ pause(target?: string | { contact: string; }): void; /** * Resume the AI agent after a pause. * * @param target - Session ID, `{ contact: "+34..." }`, or omit for global resume. */ resume(target?: string | { contact: string; }): void; /** * Send a message as the human operator (not AI-generated). * Works while the session is paused — the message is sent through the * channel (WhatsApp, etc.) and added to LLM history for context. * * Pass `contact` (the customer's phone) alongside `sessionId` so the server * can still deliver via WhatsApp when the referenced session has already * expired (2h idle / 24h window GC) — it falls back to a direct channel * send for that contact instead of a silent no-op. */ sendMessage(opts: { sessionId?: string; contact?: string; text: string; }): void; /** @internal End all calls (on disconnect). */ _endAllCalls(reason: string): void; /** @internal Emit a typed event — used by dispatch handlers. */ _emitWire(event: K, ...args: Parameters): void; /** * @internal Run agent-level `call.preparing` handlers and hand back what * they returned, so async ones can be awaited before the turn is released. */ _emitPreparing(call: Call): unknown[]; /** @internal True when the app is listening for the pre-turn hook. */ _hasPreparingListener(): boolean; /** * @internal Build the `Call` for an inbound session. * * A seam, not a factory pattern for its own sake: a `PhoneLine` registers * under `line:` so that every dispatch handler routes to it * unchanged, and this is the one place it has to differ — the object the * handler hands out is a `LineCall`, with `say`/`listen`/`ask`/`routeTo`. */ _createCall(data: CallInit, send: (data: Record) => void): Call; /** @internal Get a call by ID. */ _getCall(callId: string): Call | undefined; /** @internal Set a call in the registry. */ _setCall(callId: string, call: Call): void; /** @internal Remove a call from the registry. */ _deleteCall(callId: string): boolean; /** @internal Check if a call exists. */ _hasCall(callId: string): boolean; /** @internal Get channels map (for PHONE_IN_USE handling). */ _getChannels(): Map; /** * @internal Get executable Tool objects for auto-dispatch. * * Returns the full executable universe — global tools plus every declared * skill's tools — regardless of which skills are currently active on the * server. Visibility to the LLM is decided server-side; execution must * always succeed, so we never want a "Unknown tool" for a latent skill. */ _getTools(): Tool[]; /** @internal Get declared skills. */ _getSkills(): Skill[]; /** @internal Mark agent as server-ready and flush buffered messages. */ _flushPending(): void; /** * @internal The server acknowledged this agent (`agent.created`/`agent.resumed`). * Settles `ready` — this is the ONLY moment the agent exists server-side. */ _markRegistered(): void; /** * @internal The socket dropped — the server no longer holds this * registration, so `ready` goes back to pending until the reconnect * re-registers us. Without this, a mint during a reconnect would race * against `agent.create` all over again. */ _markUnregistered(): void; /** * @internal The registration was terminally refused (see AgentConflictError). * Rejects `ready` so an awaiting caller fails loudly instead of hanging. */ _failRegistration(err: Error): void; } /** * Base error type. * * Lives in kernel/ rather than client.ts so the domain layer (Call, Agent) can * throw it without importing the client — client.ts already imports the domain, * and the cycle would bite at module-evaluation time. * * `client.ts` re-exports it, so `import { PinecallError } from "@pinecall/sdk"` * keeps working exactly as before. */ declare class PinecallError extends Error { code?: string | undefined; constructor(message: string, code?: string | undefined); } /** * Terminal registration conflict — the agent id is held by another LIVE * process and retrying cannot change that. * * Emitted on the client's `error` event (so it is catchable programmatically, * not just a log line) when either: * - the server answered `AGENT_CONFLICT_FATAL` (its liveness probe confirmed * the holder alive), or * - the retry budget (2× the server's stale-registration window) ran out. * * Lives here, not in client.ts, so a dispatch handler can construct one * without importing the orchestrator that dispatches it. `client.ts` and * `index.ts` re-export it, so both existing import paths keep working. */ declare class AgentConflictError extends PinecallError { /** The agent id that could not be registered. */ readonly agentId: string; /** How the terminal state was reached. */ readonly reason: "server_fatal" | "retry_budget_exhausted"; constructor(message: string, /** The agent id that could not be registered. */ agentId: string, /** How the terminal state was reached. */ reason: "server_fatal" | "retry_budget_exhausted"); } /** * The server ran out of client slots — it refused to register this agent. * * A distinct type because it is a distinct fact with a distinct remedy. The * server used to report the refusal as a nondescript REGISTRATION_ERROR, and * the token-mint endpoints — which only see that the agent never appeared — * answered `Agent 'x' is not online`. Nothing about the agent is wrong: the * SERVER is full. Surface the server's own words verbatim. */ declare class ServerAtCapacityError extends PinecallError { /** The agent id that could not be registered. */ readonly agentId: string; /** Client slots in use, as reported by the server (if provided). */ readonly used?: number | undefined; /** The server's max_clients ceiling (if provided). */ readonly limit?: number | undefined; constructor(message: string, /** The agent id that could not be registered. */ agentId: string, /** Client slots in use, as reported by the server (if provided). */ used?: number | undefined, /** The server's max_clients ceiling (if provided). */ limit?: number | undefined); } /** * PhoneLine — a phone number you program, with no model behind it. * * `pc.line("+12186633772")` claims a number as a session OWNER that is not an * agent: it has its own STT and TTS, it takes the call first, and every * decision it makes is plain code — `if`, `switch`, `await`. The first model * call happens only if the code hands the live call to an agent * (`call.routeTo`), or never at all. * * The line registers under the id `line:` in the client's registry, so * every existing dispatch handler routes its events without knowing lines * exist. The one seam is `Agent._createCall`, which a line overrides to hand * out a {@link LineCall} instead of a plain `Call`. * * Contract: docs/notes/phone-line-plan.md §11 (frozen). */ /** * The line's own pipeline — the same shortcut shapes an agent accepts, minus * everything that implies a model. * * `llm`, `prompt`, `tools` and `greeting` are REFUSED, synchronously, in * `pc.line()`: a line has no model, and its first words are code (the first * `call.say()` takes the greeting lock exactly like an agent's greeting does). */ interface LineOptions { /** The line's STT. Multilingual by default is the point — nobody knows the caller's language yet. */ stt?: STTShortcut; /** The line's voice. */ voice?: VoiceShortcut; /** BCP-47 language for STT/TTS. */ language?: string; /** End-of-turn detection, passed through to the server untouched. */ turnDetection?: string | Record; /** * Opt-in, and OFF by default: a post-dial extension window. For that many * ms after connect the line stays SILENT and collects the digits a phone * sends on its own when the caller dialled `+1218…,33` (the comma is a * ~2 s pause, then `3 3` as keypad tones); `call.started` then carries * them as `call.extension`. It costs every caller that much dead air, so * it is for a line that knowingly wants extension dialling — a * switchboard — never for a front desk, where the caller expects to hear * a voice the instant the call connects. */ extension?: { window: number; }; } /** * Default post-dial extension window, in ms: NONE. A caller who dials a number * expects to hear something the instant it connects, not to guess that a digit * is wanted. The window exists only for a line that knowingly trades silence * for `+1…,10`-style extension dialling, and it must be asked for. */ declare const DEFAULT_EXTENSION_WINDOW_MS = 0; /** * A routing table: extension → an agent slug, or code. * * `"*"` is the no-extension / unmatched case. Checked BEFORE `line.on("call")`; * a call with no matching key and no `"*"` falls through to the `call` * listeners. */ type ExtensionTable = Record void | Promise)>; /** What `listen()` was waiting for, and what it got. */ type ListenResult = { by: "keypad"; digit: string; digits: string; } | { by: "speech"; text: string; confidence: number; } | { by: "timeout"; }; interface ListenOptions { /** Resolve once this many keys have been pressed. `1` resolves on the first press. */ digits?: number; /** Resolve when this key is pressed, whatever the buffer holds ("#"). */ terminator?: string; /** Also race the caller's SPEECH — the session's own end-of-turn, not a second STT. Opt-in. */ speech?: boolean; /** How long to wait before giving up, in ms. */ timeout: number; /** Switch the session's language before listening. */ language?: string; } interface SayOptions { /** Speak this one line in another voice. */ voice?: VoiceShortcut; /** Speak this one line in another language. */ language?: string; /** Inject the text into the server-side history (inherited from `Call.say`). */ addToHistory?: boolean; } interface RouteOptions { language?: string; voice?: VoiceShortcut; stt?: STTShortcut; /** Override the agent's own greeting for this hand-over. */ greeting?: string; promptVars?: Record; /** Keyed context the agent inherits — the same wire as `call.context()`. */ context?: Record; /** Prime the agent with what the line heard. Default true. */ history?: boolean; } /** Why the owner swap did not happen. The line is still the owner. */ type RouteFailureReason = "offline" | "unknown" | "no_phone_config" | "capacity" | "swap_failed"; type RouteResult = { ok: true; } | { ok: false; reason: RouteFailureReason; }; /** * The `Call` a line's handler receives — a real `Call` (`instanceof Call` is * true, and every event and control it has still works) plus the verbs that * make a menu possible: an awaitable `say`, a `listen` that races keypad * against speech, `ask` as the two together, and `routeTo` as the hand-over. * * None of them talks to a model. */ declare class LineCall extends Call { #private; constructor(data: CallInit, send: (data: Record) => void); /** * What the line heard and said — `[{ who, text, at }]`. * * Overrides `Call.transcript` (which derives `{role, content}` from the LLM * message list — a line has no LLM). The entries carry `role`/`content` * too, so anything written against a plain Call transcript still reads it. */ get transcript(): LineTranscriptEntry[]; /** True once the call has been handed to an agent. */ get routed(): boolean; /** * Speak, and resolve when the audio FINISHED PLAYING — * `{ interrupted: false }` — or when the caller talked over it, * `{ interrupted: true }`. Never rejects; a call that ends mid-sentence * resolves as interrupted. * * `voice`/`language` reconfigure the session for the rest of the call * (`session.configure`), sent before the reply so the line is heard in the * new voice from this sentence on. */ say(text: string, opts?: SayOptions): Promise; /** * Wait for the FIRST of: the keypad, the caller's speech, or the timeout. * * Both inputs come off the one `CallSession` the agent will keep using * after `routeTo` — same VAD, same STT, same turn detector. There is no * ``, no second recognizer, no HTTP round trip. * * `speech` is opt-in: a menu that only takes digits should not wait on VAD. * Every listener is removed the moment it resolves. */ listen(opts: ListenOptions): Promise; /** * `say` then `listen` — a question. * * The keypad is collected from BEFORE the first syllable: barge-in on a * menu is the normal case, and a caller who knows the menu presses over it. * A press that satisfies the listen **cuts the menu and resolves at once** * — the rest of the sentence is dead air to somebody who already answered. * Otherwise the timeout starts counting the moment the line stops speaking. */ ask(text: string, opts: ListenOptions): Promise; /** * Hand the LIVE call to an agent — no re-dial, no drop. The server swaps * the session's owner and config in place; the agent sees a normal * `call.started` with `routed_from`, `extension` and `line_transcript`. * * Resolves `{ ok: true }` once the swap landed, or `{ ok: false, reason }` * with the session untouched — an offline agent is the LINE's decision to * make (say so, forward, hang up, try another), not a 404. */ routeTo(agent: string, opts?: RouteOptions): Promise; /** Hang up, with a reason the call log keeps. */ hangup(reason?: string): void; /** * Set keyed context on the session. It SURVIVES `routeTo`, so the agent * inherits what the line learned before it ever saw the call. */ context(key: string, value: unknown): void; /** @internal The server acked `call.route` — the agent owns the session now. */ _applyRouted(agent: string): void; /** @internal The swap did not happen. The line is still the owner. */ _applyRouteFailed(agent: string, reason: RouteFailureReason): void; } interface PhoneLineEvents { [key: string]: (...args: any[]) => void; /** The server registered the line; the number is ours. */ ready: () => void; /** The registration was refused. `error.code` is the server's `LINE_*` code. */ error: (error: PinecallError) => void; /** An inbound call, connected and HELD for this handler. Fires after the extension window. */ call: (call: LineCall) => void | Promise; /** Ended at any stage, including mid-menu. `reason` is `"routed"` after a hand-over. */ "call.ended": (call: LineCall, reason: string) => void; } declare class PhoneLine extends TypedEventBus { #private; /** The number this line owns, E.164 or `sip:`. */ readonly number: string; /** How the server addresses this line: `line:`. */ readonly id: string; /** @internal — created by Pinecall.line() */ constructor(number: string, opts: LineOptions, send: (data: Record) => void); /** True once the SERVER acknowledged the line (`line.created`). */ get registered(): boolean; /** Resolves on `line.created`. Goes back to pending across a reconnect, like an agent's. */ get ready(): Promise; /** Calls this line is currently holding. */ get calls(): ReadonlyMap; /** * Declare where each extension goes: an agent slug, or code. * * Runs BEFORE the `call` listeners, and a match consumes the call. `"*"` * catches the no-extension and unmatched cases; with neither a match nor a * `"*"`, the call falls through to `line.on("call")`. */ extensions(map: ExtensionTable): this; /** Release the number. The server answers `line.destroyed`. */ destroy(): void; /** @internal The registry entry dispatch routes `line:` events to. */ get _agent(): Agent; /** * @internal Claim the number. Sent on connect and re-sent on every * reconnect, exactly like an agent's `agent.create` — a line that comes * back has to take its number back or the number is stranded. */ _register(): void; /** @internal `line.created` — the line exists server-side from here on. */ _markCreated(): void; /** @internal `line.error` — refused, with the server's code. */ _markError(code: string, message: string): void; /** @internal The socket dropped — `ready` goes back to pending. */ _markUnregistered(): void; /** @internal End every call this line is holding (disconnect). */ _endAllCalls(reason: string): void; /** @internal Find a call this line is holding, by id. */ _getCall(callId: string): LineCall | undefined; } /** * Wire types — on-the-wire event shape (snake_case). * * Internal only. NOT exported from index.ts. * Used by the dispatcher and handlers to type raw server messages. */ /** Base wire event — every server message has at least an `event` field. */ interface WireEvent { event: string; agent_id?: string; call_id?: string; session_id?: string; [key: string]: unknown; } /** * Logger — structured logging interface. * * Pinecall accepts an optional logger in its options; defaults to noopLogger. * fileLogger writes to PINECALL_LOG (port of old Pinecall._log). */ interface Logger { debug(msg: string, meta?: Record): void; info(msg: string, meta?: Record): void; warn(msg: string, meta?: Record): void; error(msg: string, meta?: Record): void; } /** * RegistrationCoordinator — the ONE seam between dispatch and the client's * registration state machine. * * Dispatch handlers used to reach back into the client through a bag of * optional underscore-prefixed methods (`_scheduleRegisterRetry?`, …), each * called with `?.` and a comment apologising for the shapes an "older * implementation" might return. That softness was not defensive, it was a * layering leak: the handler could not say what it needed, so it guessed. * * This interface is the requirement, stated once. It is REQUIRED on the * dispatch context — there is no "unwired" case to code around. */ /** Server guidance attached to an AGENT_CONFLICT/AGENT_IN_USE rejection. */ interface RegisterRetryHint { /** Server-suggested delay before the next attempt (escalates server-side). */ retryAfterS?: number; /** true = the name is held by a LIVE process (back off hard); * false = the holder is known dead (retry fast). */ holderAlive?: boolean; } interface RegistrationCoordinator { /** * Schedule a registration retry after AGENT_CONFLICT/AGENT_IN_USE. * `hint` carries the server's structured guidance (retry_after_s, * holder_alive) when present. * * Returns true when this is the FIRST conflict of the episode — callers * use it to log the human-facing banner exactly once, because a name * actively held elsewhere used to spam it every attempt for hours. */ scheduleRetry(agentId: string, hint?: RegisterRetryHint): boolean; /** * Terminal conflict: the server proved the name is held by a LIVE process * (AGENT_CONFLICT_FATAL). Stop retrying and surface a typed error the * developer can catch. */ fail(agentId: string): void; /** The server confirmed the registration — drop any pending retry. */ clear(agentId: string): void; } /** * EventHandler — strategy interface for wire event handling. * * Each handler is responsible for one concern (lifecycle, speech, bot, etc). */ /** * Everything a handler is allowed to know about the world. * * Every member is REQUIRED and named for what it does, not for the private * client method it happens to call. Handlers get capabilities, never the * client object itself — that direction of the dependency is what kept * `error.ts` importing the orchestrator that dispatches it. */ interface DispatchContext { /** Resolve an agent by wire ID. Returns null if no match. */ agent(wireId: string): Agent | null; /** Get an active call by ID from the resolved agent. */ call(agent: Agent, callId: string): Call | undefined; /** Logger instance. */ logger: Logger; /** Send raw message to server. */ send(data: Record): void; /** Called when server confirms authentication. */ onConnected(): void; /** Registration retry/conflict state machine (owned by the client). */ registration: RegistrationCoordinator; /** Emit a client-level event (the `Pinecall` instance's own emitter). */ emitClientEvent(event: string, ...args: unknown[]): void; /** Every agent registered on this client — the fallback when the server omits `agent_id`. */ allAgents(): Agent[]; /** A live WhatsApp session by id, for `wa-` prefixed call ids. */ whatsappSession(sessionId: string): WhatsAppSession | undefined; /** Every phone line on this client — how `line.*` and `call.route*` find their owner. */ lines(): PhoneLine[]; } interface EventHandler { /** List of event names this handler processes. */ readonly events: ReadonlyArray; /** Handle a wire event. Return true if handled, false to pass to next handler. */ handle(wire: WireEvent, ctx: DispatchContext): boolean; } /** * WhatsApp handler — WhatsApp-specific events. * * Handles: whatsapp.message, whatsapp.response, whatsapp.status, * whatsapp.session_started, whatsapp.session_ended * * On session_started, creates a WhatsAppSession object with history methods * and emits it as the event argument (like Call for voice calls). * Messages are saved incrementally via HistoryStore. * whatsapp.session_ended triggers the final save with status: "ended". */ declare class WhatsAppHandler implements EventHandler { #private; readonly events: readonly ["whatsapp.message", "whatsapp.response", "whatsapp.status", "whatsapp.session_started", "whatsapp.session_ended"]; /** @internal Get a WhatsAppSession handle by ID. Used by HistoryHandler. */ getSession(sessionId: string): WhatsAppSession | undefined; handle(wire: WireEvent, ctx: DispatchContext): boolean; } /** * CallLogView — THE reducer (CALL_LOG_SPEC.md §6). * * "Client SDKs maintain ONE reducer (log → {phase, messages, toolCalls, * turns, metrics}) fed by any pipe, deduped by seq." * * This is the whole point of the module: WS attach, WebRTC DataChannel, GET * polling and replay are four pipes carrying one envelope, and they must * land on one piece of state-building code. A second reducer would be a * second vocabulary in disguise. * * ── Semantics ──────────────────────────────────────────────────────────── * Ported from the proven `VoiceSession.handleDataChannelMessage` switch * (@pinecall/web, src/core/VoiceSession.ts:303-502) — word reassembly, * `mergeUserTurn` interim merging, tool-argument parsing — with four * deliberate corrections: * * 1. NO transport coupling and no `trackedTools` filter. The view exposes * every tool call; a UI that wants a subset filters at render time. * Filtering during reduction makes the state depend on widget config. * 2. NO `disconnect()` back-edge. VoiceSession called `this.disconnect()` * from inside the switch (:418) — a reducer reaching into a socket. * Here, terminal facts produce an *intent* on the state; the owner of * the transport decides what to do about it. * 3. Duration is derived from entry `ts`, never from wall clock, so a * replay of a finished call reproduces the same state as watching it * live (§10.5). * 4. `phase` gains "ended", and `bot.corrected` REPLACES the text of the * entry it supersedes (§2) rather than appending a second bubble. * * ── Idempotence and order independence ─────────────────────────────────── * `apply()` is idempotent by `seq` and independent of arrival order. It is * not a naive running fold: entries are retained in a seq-keyed map and the * state is the fold over them in seq order. Applying an entry newer than * everything seen (the live case) folds incrementally in O(1); an * out-of-order or backfilled entry rebuilds, which is what correctness * costs and what makes in-order === shuffled === resumed. * * ── Immutability ───────────────────────────────────────────────────────── * State is produced by structural sharing, never by mutation: an apply * yields a new state object, new arrays, and new objects for exactly the * entries it changed. Reference equality on a message therefore MEANS "this * line did not change" — the contract a memoized transcript line depends on. */ type CallPhase = "idle" | "ringing" | "listening" | "thinking" | "speaking" | "ended"; type MessageRole = "user" | "bot" | "system"; /** One transcript bubble. `seq` is the entry that created it — `bot.corrected.supersedes` points here. */ interface CallMessage { /** The seq of the entry that created this message. Stable identity. */ seq: number; role: MessageRole; text: string; /** Provider message id (`user.message.id`, `bot.speaking.id`, …). */ id?: string; /** True while a non-final `user.message` is the latest word on this turn. */ interim?: boolean; /** True between `bot.speaking` and `bot.finished`/`bot.interrupted`. */ speaking?: boolean; interrupted?: boolean; /** Set on the system bubble that mirrors a `tool.call`. */ toolCallId?: string; /** Word alignment when TTS provided it (`bot.speaking.words`). */ words?: WordTiming[]; /** True once a `bot.corrected` entry replaced this text. */ corrected?: boolean; } interface CallToolCall { id: string; name: string; args: Record; /** seq of the `tool.call` entry. */ seq: number; /** Present once the correlated `tool.result` arrives. */ result?: unknown; ms?: number; error?: string; done: boolean; } interface CallTurn { turn: number; role?: TurnRole; latency?: TurnLatency; startedAt?: number; endedAt?: number; } interface CallMetrics { /** Rolled-up distributions, present once `call.summary` lands. */ summary?: CallSummaryData["metrics"]; cost?: number; recordingUrl?: string; /** Mean end-to-end latency over the `turn.end` entries seen so far. */ e2eMean?: number; turnCount: number; } /** * Something the log says should happen to the transport, surfaced instead of * done. Correction #2 above: the reducer never touches a socket. */ interface CallIntent { kind: "disconnect"; reason: string; seq: number; } /** * One row of `state.custom`: the latest value per `(name, id)`, in first-seen * order. The wire stays append-only — every `call.log()` is its own entry * with its own seq — the upsert is a projection of this reducer only. */ interface CallCustomEntry { name: string; /** `data.id ?? String(seq)` — the upsert key together with `name`. */ id: string; value: V; /** seq of the entry that LAST set this value. */ seq: number; ts: number; /** Server-stamped turn id, when the session has turns. */ turn?: number; } interface CallLogState { phase: CallPhase; messages: CallMessage[]; toolCalls: CallToolCall[]; turns: CallTurn[]; metrics: CallMetrics; /** False once `call.ended` is applied. */ live: boolean; /** Highest seq applied. The cursor to resume from (`after=`). */ lastSeq: number; /** Call id, from the first entry that carried one. */ call: string | null; agent: string | null; /** Seconds, derived from entry `ts` — never from wall clock. */ duration: number; /** True after `log.caught_up`; never inferred from contiguity (§1). */ caughtUp: boolean; /** Declared gaps (§3). Never silently papered over. */ gaps: { from: number; resumeFrom: number; }[]; userSpeaking: boolean; botSpeaking: boolean; /** Reason from `call.ended`, if any. */ endedReason?: string; /** Human takeover state (`handoff.*`). */ handoff: "none" | "requested" | "active"; /** Skills currently loaded (`skill.loaded` / `skill.unloaded`). */ skills: string[]; /** Latest RAG citations (`docs.sources`). */ sources: unknown[]; /** Things the log asked the transport to do (§ correction #2). */ intents: CallIntent[]; /** Durable `custom` entries, upserted by (name, id). Ephemeral ones never land here. */ custom: CallCustomEntry[]; } /** * What a `log.gap` carries in `data.snapshot` — the consolidated state of * everything the server could still see when it declared the gap, so a * client lands with a populated view instead of an empty one (§3, ag-ui * "For Pinecall" 5 and 9). This is the wire contract between * `calls_api._snapshot()` and `CallLogView`: the server emits exactly these * keys, the reducer hydrates exactly these fields. * * Rows reuse the reducer's own row types, so hydration is a keyed merge * rather than a second fold: `messages` by `seq` (bot bubbles by `id`), * `tool_calls` by `id`, `turns` by `turn`, `custom` by `(name, id)`. * Scalars are values — the snapshot's word wins. Every key is optional: a * missing key leaves the local state untouched, and an unknown key is * ignored (§1 forward compatibility). * * Not carried, by design: `metrics.summary`/`cost` (only `call.summary` sets * them, and it is never skipped — a sealed cursor answers 204), `intents` * (transport asks, not call facts) and the `log.*` control state. */ interface LogGapSnapshot { phase?: CallPhase; live?: boolean; /** ts of `call.started` — the duration anchor. */ started_at?: number | null; ended_reason?: string; user_speaking?: boolean; bot_speaking?: boolean; handoff?: CallLogState["handoff"]; skills?: string[]; sources?: unknown[]; messages?: CallMessage[]; tool_calls?: CallToolCall[]; turns?: CallTurn[]; custom?: CallCustomEntry[]; } /** * The Call Log — envelope + closed vocabulary (CALL_LOG_SPEC.md §1, §2). * * ───────────────────────────────────────────────────────────────────────── * WIRE SHAPE. This module speaks the WIRE, verbatim. * * Envelope keys are exactly the seven of spec §1 (`seq`, `ts`, `call`, * `agent`, `type`, `ephemeral`, `data`) and every key INSIDE `data` is * snake_case, exactly as the server appends it. There is deliberately NO * codec in the path: the server emits an envelope, the browser applies that * same envelope, and `GET /v1/calls/{id}/events` returns the same bytes * during the call and after it (spec §10.4). A camelCase translation layer * would make "identical" a claim about a transform rather than about bytes. * * `src/protocol/events.ts` (camelCase, legacy SDK surface) is a DIFFERENT, * frozen vocabulary and stays untouched — see spec §8. * ───────────────────────────────────────────────────────────────────────── * * ZERO DEPENDENCIES. Nothing under `src/log/**` imports anything outside * itself. The `@pinecall/sdk` root entrypoint pulls in `ws` and node * builtins; the `./log` subpath must be usable from a browser bundle, so * the isolation is enforced by a test (`tests/log-browser-safe.test.ts`). * * FORWARD COMPATIBILITY. Unknown `type`s MUST be ignored (§1). The unions * below are closed for what a consumer may *rely* on, not for what may * arrive: `AnyLogEntry` therefore admits an unknown-type arm, and the * reducer's switch is exhaustive over the known arms. */ /** Every fact a session produces becomes exactly one of these. */ interface LogEntry> { /** * Monotonic per call, assigned only at the append point. The cursor AND * the dedupe key. May have holes after compaction — never assume * contiguity; "caught up" is signalled by `log.caught_up`, never inferred. */ seq: number; /** Server wall clock, float seconds. */ ts: number; /** Call id. `null` on the agent's lifecycle-only log (§2, "The agent log"). */ call: string | null; /** Agent id. */ agent: string; /** One vocabulary (§2). No per-channel dialects. */ type: T; /** `true` → delivered live, never persisted (§4). */ ephemeral: boolean; /** Type-specific payload (§2). Additive-only per type. */ data: D; } type CallDirection = "inbound" | "outbound"; /** `call.ringing` — outbound: exists before pickup. */ interface CallRingingData { direction: CallDirection; from: string; to: string; } /** `call.started` — `metadata` is the sealed token metadata. */ interface CallStartedData { direction: CallDirection; from: string; to: string; channel: string; metadata?: Record; } /** `call.ended` */ interface CallEndedData { reason: string; duration: number; } /** One latency distribution inside `call.summary`. */ interface MetricDistribution { p50: number; p90: number; p95: number; max: number; n: number; } interface CallSummaryMetrics { e2e: MetricDistribution; asr: MetricDistribution; llm_ttft: MetricDistribution; tts_ttfb: MetricDistribution; } /** `call.summary` — ALWAYS the last entry. History needs no second API. */ interface CallSummaryData { metrics: CallSummaryMetrics; cost?: number; reason: string; /** Recordings are referenced, never embedded (§8). */ recording_url?: string; } /** `user.speaking` — ephemeral. */ interface UserSpeakingData { active: boolean; } /** `user.message` — partials ephemeral, finals persisted. */ interface UserMessageData { id: string; text: string; final: boolean; language?: string; } /** One word of TTS alignment, carried INSIDE `bot.speaking`. */ interface WordTiming { w: string; t0: number; t1: number; } /** `bot.speaking` — word alignment inside the event when TTS provides it. */ interface BotSpeakingData { id: string; text: string; words?: WordTiming[]; } /** `bot.word` — ephemeral; live typing effect only. */ interface BotWordData { id: string; w: string; } /** `bot.finished` */ interface BotFinishedData { id: string; } /** `bot.interrupted` */ interface BotInterruptedData { id: string; at_word?: number; } /** * `bot.corrected` — the transcript self-heals. An EVENT, not a mutation: * consumers replace the text of the entry named by `supersedes`. */ interface BotCorrectedData { supersedes: number; id: string; text: string; } type TurnRole = "user" | "bot"; /** `turn.start` */ interface TurnStartData { turn: number; role: TurnRole; } /** Per-turn latency is first-class. */ interface TurnLatency { vad: number; asr: number; eou: number; llm_ttft: number; tts_ttfb: number; e2e: number; } /** `turn.end` */ interface TurnEndData { turn: number; latency: TurnLatency; } /** `tool.call` — reaches EVERY audience, correlated with `tool.result` by `id`. */ interface ToolCallData { id: string; name: string; /** Providers send either a parsed object or a JSON string. Both are legal. */ args: Record | string; } /** `tool.result` */ interface ToolResultData { id: string; name: string; result: unknown; ms: number; error?: string; } /** `docs.sources` — RAG citations. */ interface DocsSourcesData { sources: unknown[]; } /** `skill.loaded` / `skill.unloaded` */ interface SkillData { skill: string; by: string; } /** `audio.metrics` — ephemeral; rolled up into `call.summary`. */ interface AudioMetricsData { mos?: number; jitter?: number; loss?: number; [k: string]: unknown; } /** `handoff.requested` / `handoff.active` / `handoff.released` */ interface HandoffData { by: string; } /** `supervisor.said` / `supervisor.whispered` — audit trail of the §7 verbs. */ interface SupervisorData { text: string; by: string; } /** * `log.gap` (§3, anti-Slack rule) — a gap is DECLARED, never silently * papered over. `snapshot` is consolidated call state so the consumer can * render immediately and continue from `resume_from`. */ interface LogGapData { from: number; resume_from: number; /** Consolidated state — see `LogGapSnapshot` in view.ts for the exact keys. */ snapshot?: LogGapSnapshot; } /** `log.caught_up` (§5) — backlog drained, live entries follow. */ interface LogCaughtUpData { seq: number; } /** * `custom` — the one open extension point: `call.log(name, value)`. The * reducer never interprets `value`; it projects the latest value per * `(name, id)` into `state.custom` (upsert — the wire itself stays * append-only). Ephemeral ones are fanned out live and never stored. */ interface CustomData { name: string; value: unknown; /** Upsert key in the projection; absent → the entry's seq. */ id?: string; /** Server-stamped turn id, when the session has turns. */ turn?: number; } /** * The complete vocabulary. A fact that does not fit one of these is a * finding to report, not a new type to mint. */ interface LogDataMap { "call.ringing": CallRingingData; "call.started": CallStartedData; "call.ended": CallEndedData; "call.summary": CallSummaryData; "user.speaking": UserSpeakingData; "user.message": UserMessageData; "bot.speaking": BotSpeakingData; "bot.word": BotWordData; "bot.finished": BotFinishedData; "bot.interrupted": BotInterruptedData; "bot.corrected": BotCorrectedData; "turn.start": TurnStartData; "turn.end": TurnEndData; "tool.call": ToolCallData; "tool.result": ToolResultData; "docs.sources": DocsSourcesData; "skill.loaded": SkillData; "skill.unloaded": SkillData; "audio.metrics": AudioMetricsData; "handoff.requested": HandoffData; "handoff.active": HandoffData; "handoff.released": HandoffData; "supervisor.said": SupervisorData; "supervisor.whispered": SupervisorData; "log.gap": LogGapData; "log.caught_up": LogCaughtUpData; "custom": CustomData; } /** Every legal `type` value. Closed — see §2. */ type LogEventType = keyof LogDataMap; /** The payload that belongs to a given `type`. */ type LogData = LogDataMap[T]; /** The discriminated union of all known entries — what the reducer switches on. */ type KnownLogEntry = { [T in LogEventType]: LogEntry; }[LogEventType]; /** * An entry as it arrives off the wire: either a known one, or one whose * `type` this SDK version has never heard of. §1 requires the latter be * ignored rather than rejected, so it is part of the input type. */ type UnknownLogEntry = Omit, "type"> & { type: string; }; type AnyLogEntry = KnownLogEntry | UnknownLogEntry; /** * `observe()` — the Node reader of the Call Log. * * ONE verb to read a call (or an agent's lifecycle log) from a server * process: it opens `GET /v1/calls/{id}/events` (or * `/v1/agents/{slug}/calls`) with `Accept: text/event-stream`, feeds every * envelope into the SAME `CallLogView` reducer the browser uses, and hands * the caller three ways to consume it — `for await`, `on("entry")` / * `on("custom")`, and the reduced `state` snapshot. * * ── TWINS, NOT YET SHARED ──────────────────────────────────────────────── * * The SSE decoder, the idle watchdog, the backoff-with-jitter, the * `withListeners()` seam and the finish reasons in this file are a * DELIBERATE, SEMANTICALLY IDENTICAL port of * `@pinecall/web`'s `src/log/transport.ts` (branch `call-log-v2`, commit * `30cf4af`). Same constants, same clamps, same field parsing, same * `min(1000·2^n, 15000) + rand(0, 1000)` reconnect, same * `"summary" | "closed" | "error"` trichotomy, same "resume always carries * `after=`, never `Last-Event-ID`" rule. Read one, you have * read the other. * * They are twins rather than one shared module because the two packages sit * on opposite sides of a publish boundary: `@pinecall/sdk` cannot depend on * `@pinecall/web` (the web package depends on the SDK's contract, and a * cycle between two published packages is not a thing), and the reducer's * own answer to that — vendoring `src/log/{types,view}.ts` byte-for-byte * into webrtc, checked by `pnpm run log:sync-check` — buys its determinism * by being pure: no `fetch`, no timers, no environment. A transport is the * opposite: this one has no `document` to defer reconnects on (Node has no * `visibilitychange`) and no `WebSocket` half, while the browser twin has * both and needs them. Sharing them today would mean shipping a * lowest-common-denominator transport to both. When the divergence stops * paying for itself, the merge target is a third `@pinecall/log-wire` * package that both depend on — not a copy in either direction. * * The parts that MUST NOT drift are pinned by tests in both repos against * the same `fixtures/call-log-golden.json`: a replayed finished call reduces * to the same state here as it does in the browser. * * ── WHY NOT `EventSource` ──────────────────────────────────────────────── * * Same four reasons as the browser: it cannot send an `Authorization` * header, it hides `:` comment lines from JS (the idle watchdog's * heartbeat), it owns its own reconnect (no abort, no backoff, no jitter), * and it fires `onerror` on every reconnect. `fetch` + `ReadableStream` + * the ~60-line decoder below is the portable answer, and on Node 18+ both * are global. * * @example * ```ts * const obs = pc.observe({ agent: "lucia" }); * for await (const entry of obs) { * if (entry.type === "call.started") console.log("call", entry.call); * } * ``` */ /** What `FetchLike` must resolve to. `body` is what the stream reads from. */ interface ObserveResponseLike { ok: boolean; status: number; text(): Promise; body?: ReadableStream | null; } /** Minimal structural `fetch` — the injection seam for tests. */ type ObserveFetch = (url: string, init?: { headers?: Record; signal?: AbortSignal; }) => Promise; /** * Idle watchdog. `"auto"` is dormant until two heartbeats were seen, then * the window is `clamp(3 × observed cadence, 6 s, 30 s)`; a number is a * fixed window in ms armed from the first frame; `0` turns it off. * * Identical to the browser twin's `IdleReconnect`. */ type IdleReconnect = "auto" | number | 0; /** Why an observation ended. `"summary"` is the one clean end. */ interface ObserveFinishInfo { reason: "summary" | "closed" | "error"; error?: Error; lastSeq: number; } interface ObserveOptions { /** Call-scoped: one call's log. Exactly one of `call` / `agent`. */ call?: string; /** Agent-scoped: the agent's lifecycle log. Exactly one of `call` / `agent`. */ agent?: string; /** Start cursor. Default `0` — from the beginning of the log. */ after?: number; /** Server-side filter: only these entry types, plus the always-pass set. */ types?: readonly string[]; /** Server-side filter: skip ephemeral entries in the live tail. */ durable?: boolean; /** * An `observe` / `supervise` token. Omitted ⇒ one is minted with the * client's API key (`createToken({ channel: "stream", scope: "observe" })`). * * Minting needs an AGENT: a stream token's visibility is an agent set. * So `observe({ call })` WITHOUT a token also requires `agent` — the SDK * does not resolve a call id to its agent behind your back, because the * only endpoint that would answer needs the very token being minted. * Pass `{ call, agent }`, or pass a `token` you already hold. */ token?: string; /** Defaults to `https://voice.pinecall.io` (or the client's `apiUrl`). */ server?: string; /** Aborting it is exactly `close()`. */ signal?: AbortSignal; /** Half-open detection. Default `"auto"`. */ idleReconnect?: IdleReconnect; /** `false` disables auto-reconnect (an intentional close never reconnects). */ reconnect?: boolean; /** * Bound on the async-iterator's buffer, in entries. Default 1024. * See {@link Observation.dropped} for what an overflow costs. */ queueLimit?: number; /** Transport-level failures. State is never faked into the view. */ onError?: (error: Error) => void; /** Injection seam. Defaults to the global `fetch`. */ fetchImpl?: ObserveFetch; /** Used to mint a token when `token` is absent. */ apiKey?: string; /** REST base for the mint. Defaults to `server`. */ apiUrl?: string; } interface Observation extends AsyncIterable { /** The SAME `CallLogView` reducer state the browser renders from. */ readonly state: Readonly; /** The resume cursor: highest seq the view has accepted. */ readonly lastSeq: number; /** * Entries the async iterator never saw because the consumer was slower * than the wire and the queue hit `queueLimit`. The OLDEST queued entries * are dropped, never the newest — a slow tail should show recent truth. * * `state` is NOT affected: every entry is reduced into the view before it * is ever queued, so the reduced state is complete even when the iterator * skipped rows. `on("entry")` is likewise never dropped — it fires * synchronously. The queue is the only lossy surface, and only under * genuine backpressure. */ readonly dropped: number; /** True while entries can still arrive. */ readonly active: boolean; on(event: "entry", fn: (entry: AnyLogEntry, state: Readonly) => void): () => void; on(event: "custom", fn: (name: string, value: unknown, entry: LogEntry<"custom">) => void): () => void; on(event: "finish", fn: (info: ObserveFinishInfo) => void): () => void; /** Resolves once, when this observation ends for good. Never rejects. */ readonly done: Promise<{ reason: "summary" | "closed" | "error"; lastSeq: number; }>; /** Stop for good. Idempotent; never reconnects afterwards. */ close(): void; } /** `min(1000·2^n, 15000) + rand(0, 1000)` ms — the twin's exact curve. */ declare function observeBackoffDelay(attempt: number): number; /** A transport error that carries the HTTP status it came from. */ interface ObserveError extends Error { status?: number; } interface SseEvent { /** Sticky across events, as the spec says — an event with no `id:` inherits. */ id: string | undefined; event: string; data: string; } /** * Bytes → lines → events. Honours `\n`, `\r\n` and a lone `\r`; `id:` is * sticky across events; `retry:` is parsed and ignored (we schedule our own * reconnects); comment lines (`: ping`) are DROPPED here but reported to * `onComment` first, so the idle watchdog one stage earlier sees them. * * A line-for-line twin of `@pinecall/web`'s `sseDecoder`. */ declare function sseDecoder(handlers: { onEvent: (ev: SseEvent) => void; onComment?: (text: string) => void; onLine?: () => void; }): { push(chunk: Uint8Array): void; /** Body ended: flush a trailing event that lacked its blank line. */ end(): void; readonly lastId: string | undefined; }; /** * Open an SSE observation of one call, or of an agent's lifecycle log. * * Exactly one of `call` / `agent`. Without a `token` one is minted from * `apiKey` — which needs an agent, so `{ call }` alone must carry a token * (see {@link ObserveOptions.token}). * * Terminal facts: a `204` (sealed cursor, nothing left) and a body that ends * after `call.summary` both finish with `"summary"`; `401/403/404` finish * with `"error"` and never retry; anything else reconnects on * `min(1000·2^n, 15000) + rand(0, 1000)` carrying `after=`. */ declare function observe(opts: ObserveOptions): Observation; /** * SSE stream — creates SSE responses from agent events. * * Port of src.bkp/sse.ts — identical behavior. */ interface StreamOptions { agents?: string[]; } /** * Voice API — fetch available TTS voices. */ interface Voice { id: string; name: string; /** Friendly alias for use in `voice` config, e.g. "sarah" → `"elevenlabs/sarah"` */ alias?: string; provider: string; gender?: string; style?: string; languages: VoiceLanguage[]; description?: string; previewUrl?: string; } interface VoiceLanguage { code: string; name: string; flag?: string; nativeName?: string; region?: string; } interface FetchVoicesOptions { provider?: string; language?: string; apiUrl?: string; } declare function fetchVoices(opts?: FetchVoicesOptions): Promise; /** * Audio API — standalone speech-to-text, no agent and no call. * * Two endpoints, the batch one and the live one: * - `POST {apiUrl}/v1/audio/transcriptions` — one audio file in (multipart, * OpenAI shape), one transcript out: `json` (text + language + duration), * `verbose_json` (adds words and segments, with speaker labels when * `diarize` is on) or `text` (plain body). `transcribe()`. * - `WS {wsUrl}/v1/audio/transcriptions/stream` — raw PCM frames in, * `partial` / `final` frames out as the speech is recognised, one `done` * frame with the billing at the end. `transcribeStream()`. * * Auth is the `Authorization: Bearer` header on both — the socket too (Node's * `ws` sends headers; the `?api_key=` fallback the server accepts is never used * here, a key in a URL ends up in logs). Runs on Node ≥ 18 and in Electron * main; `ws` and `node:fs/promises` are imported lazily so a browser bundle * that only uses `transcribe()` with bytes never pays for them. */ type TranscriptionModel = "elevenlabs/scribe_v1" | "deepgram/nova-3" | "deepgram/nova-2" | "soniox/stt-async-preview" | (string & {}); interface TranscribeOptions { /** `"provider/model"` or `"provider"`; omitted → `elevenlabs/scribe_v1`. */ model?: TranscriptionModel; /** ISO-639-1 language code; omitted → auto-detect. */ language?: string; /** Label speakers (`words[].speaker`, `segments[].speaker`). Default false. */ diarize?: boolean; /** `"json"` (default) | `"verbose_json"` (words + segments) | `"text"`. */ format?: "json" | "verbose_json" | "text"; /** Name sent with the file part — the server infers the container from it. */ filename?: string; /** MIME type of the file part; inferred from `filename` / the path when omitted. */ contentType?: string; /** Abort the request from outside. */ signal?: AbortSignal; } interface TranscriptWord { word: string; /** Seconds from the start of the audio. */ start: number; end: number; /** Speaker label (`"0"`, `"1"`, …) when diarization is on. */ speaker?: string; } interface TranscriptSegment { id: number; start: number; end: number; text: string; speaker?: string; } interface Transcription { requestId: string; text: string; /** Detected or requested language; `""` when the wire had none (`format: "text"`). */ language: string; /** Audio duration in seconds; 0 when the wire had none (`format: "text"`). */ duration: number; /** Only with `format: "verbose_json"`. */ model?: string; words?: TranscriptWord[]; segments?: TranscriptSegment[]; } /** Bytes, a `Blob`/`File`, or — Node only — a path to read. */ type TranscribeInput = Uint8Array | ArrayBuffer | Blob | string; type StreamModel = "deepgram/nova-3" | "elevenlabs/scribe_v2_realtime" | "soniox/stt-rt-v5" | (string & {}); interface TranscribeStreamOptions { /** Omitted → `deepgram/nova-3`. */ model?: StreamModel; /** ISO-639-1 language code; omitted → auto-detect. */ language?: string; /** Sample rate of the PCM you write. Default 16000. */ sampleRate?: 8000 | 16000 | 24000 | 48000; /** `"linear16"` (default, s16le mono) | `"mulaw"`. */ encoding?: "linear16" | "mulaw"; /** Speaker labels on `final` segments (soniox / deepgram). */ diarize?: boolean; } interface StreamFinal { text: string; start?: number; end?: number; language?: string; speaker?: string; words?: TranscriptWord[]; } interface StreamReady { requestId: string; model: string; sampleRate: number; } interface StreamDone { audioSeconds: number; billedMinutes: number; } interface TranscribeStreamEvents { /** The server accepted the socket and is listening for audio. */ ready: (info: StreamReady) => void; /** Interim hypothesis for the current utterance — replaced by the next one. */ partial: (text: string) => void; /** A committed segment. */ final: (seg: StreamFinal) => void; /** The server finished after `end()` — billing for the session. */ done: (info: StreamDone) => void; /** A refusal (auth, args, upstream) or a socket failure. The stream is over. */ error: (err: AudioApiError) => void; /** The socket closed, with its close code. Always last. */ close: (code: number) => void; } type TranscribeStreamItem = { type: "partial"; text: string; } | { type: "final"; segment: StreamFinal; }; interface TranscribeStream { /** Set by the `ready` frame; `""` before. */ readonly requestId: string; /** Resolves on `ready`; rejects if the server refuses before that. */ readonly ready: Promise; /** Queue audio bytes. Buffered until `ready`, then sent in order as binary frames. */ write(chunk: Uint8Array | ArrayBuffer): void; /** Ask the server to commit what it has heard so far (a `final` follows). */ finalize(): void; /** No more audio: the server flushes, sends `done` and closes. Resolves on `done`. */ end(): Promise; /** Drop the socket now (close 1000) without waiting for `done`. */ close(): void; on(ev: K, fn: TranscribeStreamEvents[K]): this; off(ev: K, fn: TranscribeStreamEvents[K]): this; once(ev: K, fn: TranscribeStreamEvents[K]): this; /** `partial` and `final` frames in order; ends on `done`, throws on `error`. */ [Symbol.asyncIterator](): AsyncIterator; } interface TranscribeApiOptions { apiKey: string; /** Voice server base, e.g. https://voice.pinecall.io (the default). */ apiUrl?: string; } /** * `POST /v1/audio/transcriptions` — transcribe one file. * * `input` is the audio: bytes, a `Blob`/`File`, or (Node only) a path, read * lazily through `node:fs/promises`. The content type comes from * `contentType`, else the filename / path extension, else `audio/wav`. */ declare function transcribe(input: TranscribeInput, opts: TranscribeOptions & TranscribeApiOptions): Promise; /** * `WS /v1/audio/transcriptions/stream` — live transcription of PCM you write. * * Opens the socket immediately; `write()` before `ready` is buffered and sent * in order once the server is listening. `end()` tells the server there is no * more audio and resolves with the billing on `done`; `close()` hangs up now. * Node only (needs `ws` for header auth). */ declare function transcribeStream(opts: TranscribeStreamOptions & TranscribeApiOptions): TranscribeStream; /** * Audio API — standalone text-to-speech, no agent and no call. * * `POST {apiUrl}/v1/audio/speech` synthesises one utterance and streams the * bytes back as they are produced. This client resolves as soon as the * response headers arrive, so a desktop app can start playback on the first * chunk; the body is never buffered unless the caller asks for it * (`arrayBuffer()` / `toFile()`). * * Two wire modes, one result shape: * - `timestamps: false` (default) → a chunked binary body (`audio/pcm`, * `audio/wav` or `audio/mpeg`) that flows straight into `result.audio`. * - `timestamps: true` → `text/event-stream`; audio frames (base64) are * decoded into `result.audio`, word frames reach `result.words`, the done * frame resolves `result.done`, and an error frame rejects everything. * * Runs on Node ≥ 18 and in Electron main; `toFile` is the only Node-specific * bit and loads `node:fs` lazily so browser bundles are untouched. */ type SpeechFormat = "pcm" | "wav" | "mp3"; interface SpeechOptions { /** Text to speak — 1..5000 characters. */ input: string; /** `"provider/alias"` (e.g. `"elevenlabs/sarah"`) or a raw provider voice id. */ voice: string; /** `"provider/model"`, `"provider/auto"`, or omitted (auto by language). */ model?: string; /** ISO-639-1 language code, e.g. `"es"`. */ language?: string; /** `"pcm"` (default) | `"wav"` | `"mp3"`. pcm/wav are s16le mono. */ format?: SpeechFormat; /** 16000 (default) | 24000 — pcm/wav sample rate. */ sampleRate?: 16000 | 24000; speed?: number; /** Request word timestamps (switches the wire to SSE). */ timestamps?: boolean; /** Abort the request — and the synthesis behind it — from outside. */ signal?: AbortSignal; } interface SpeechWord { word: string; /** Seconds from the start of the audio. */ start: number; end: number; } interface SpeechDone { /** Characters billed for this request. */ characters: number; /** Audio duration in milliseconds. */ audioMs: number; } interface SpeechResult { requestId: string; format: SpeechFormat; sampleRate: number; channels: 1; bitDepth: 16; /** Raw audio bytes as they arrive (base64-decoded in SSE mode). Never buffered. */ audio: ReadableStream; /** Word timestamps — empty when `timestamps` is off or the provider has none. */ words: AsyncIterable; /** Resolves when synthesis finishes; rejects on a mid-stream error or cancel. */ done: Promise; /** Abort the request; the server cancels synthesis. */ cancel(): void; /** Drain `audio` into one buffer. */ arrayBuffer(): Promise; /** Drain `audio` into a file. Node only — `node:fs` is imported lazily. */ toFile(path: string): Promise; } interface SpeechApiOptions { apiKey: string; /** Voice server base, e.g. https://voice.pinecall.io (the default). */ apiUrl?: string; } interface FetchAudioVoicesOptions { provider?: string; language?: string; apiKey?: string; apiUrl?: string; } /** * A refusal from the audio endpoint — before streaming (HTTP status + the * server's `code`: BAD_VOICE, INSUFFICIENT_CREDITS, RATE_LIMITED, …) or * mid-stream (status 200, the `code` of the error frame). `status` is 0 when * the server could not be reached at all. */ declare class AudioApiError extends PinecallError { status: number; code: string; constructor(message: string, status: number, code: string); } declare function speech(opts: SpeechOptions & SpeechApiOptions): Promise; /** `GET /v1/audio/voices` — the voices `speech()` accepts, optionally filtered. */ declare function fetchAudioVoices(opts?: FetchAudioVoicesOptions): Promise; /** * Pinecall — main client class. The orchestrator. * * Composes Transport, Dispatcher, Reconnector, Logger, IdResolver. * Owns the agent registry and WebSocket lifecycle. * * Public API is identical to src.bkp/client.ts. */ /** * `pc.audio` — standalone speech, bound to this client's key and URL. No * agent, no call: `speech()` streams one utterance, `voices()` lists what it * accepts, `transcribe()` turns a file into text, `transcribeStream()` turns * live PCM into partial/final segments. See `src/api/audio.ts` and * `src/api/audio-stt.ts` for the wire contracts. */ interface AudioNamespace { /** Synthesise `input` with `voice`; resolves on headers, audio streams. */ speech(opts: SpeechOptions): Promise; /** Voices `speech()` accepts, optionally filtered by provider/language. */ voices(opts?: Omit): Promise; /** Transcribe one file (bytes, Blob, or a Node path). */ transcribe(input: TranscribeInput, opts?: TranscribeOptions): Promise; /** Open a live transcription socket; write PCM, read partial/final. Node only. */ transcribeStream(opts?: TranscribeStreamOptions): TranscribeStream; } interface PinecallOptions { /** API key. Falls back to PINECALL_API_KEY env var if not provided. */ apiKey?: string; /** Server URL. Default: wss://voice.pinecall.io */ apiUrl?: string; /** Auto-reconnect on disconnect. Default: true. */ autoReconnect?: boolean; /** Prompts directory for setPromptFile. Default: "prompts". */ promptsDir?: string; } interface PinecallEvents { [key: string]: (...args: any[]) => void; connected: () => void; disconnected: (reason: string) => void; reconnecting: (attempt: number, delay: number) => void; error: (err: Error) => void; "call.started": (call: Call) => void; "call.ended": (call: Call, reason: string) => void; "speech.started": (...args: any[]) => void; "speech.ended": (...args: any[]) => void; "user.speaking": (...args: any[]) => void; "user.message": (...args: any[]) => void; "eager.turn": (turn: Turn, call: Call) => void; "turn.pause": (...args: any[]) => void; "turn.end": (turn: Turn, call: Call) => void; "turn.resumed": (...args: any[]) => void; "turn.continued": (...args: any[]) => void; "bot.speaking": (...args: any[]) => void; "bot.word": (...args: any[]) => void; "bot.finished": (...args: any[]) => void; "bot.interrupted": (...args: any[]) => void; "message.confirmed": (...args: any[]) => void; "reply.rejected": (...args: any[]) => void; "audio.metrics": (...args: any[]) => void; "llm.toolCall": (...args: any[]) => void; "session.timeout": (...args: any[]) => void; } declare class Pinecall extends TypedEventBus { #private; /** Standalone TTS/STT — `pc.audio.speech()` / `voices()` / `transcribe()` / `transcribeStream()`. */ readonly audio: AudioNamespace; constructor(opts?: PinecallOptions); get connected(): boolean; /** Promise that resolves when the connection is established. */ get ready(): Promise; get agents(): ReadonlyMap; getAgent(id: string): Agent | undefined; /** Phone lines registered on this client, by number. */ get lines(): ReadonlyMap; connect(): Promise; disconnect(): Promise; agent(id: string, config?: AgentConfig): Agent; /** * Claim a phone number as a programmable LINE — its own STT and TTS, no * model. It answers first, resolves the dialled extension, speaks and * listens in code, and hands the LIVE call to an agent when the code says * so (`call.routeTo`). The destination agent does not have to be online for * the number to answer. * * Idempotent per number, like `pc.agent()`. `llm`/`prompt`/`tools`/ * `greeting` are refused here and now: a line has no model. * * @example * const line = pc.line("+12186633772", { stt: "soniox", voice: "elevenlabs/sarah" }); * line.extensions({ "10": "pres-restaurantes", "11": "pres-hoteles" }); * line.on("call", async (call) => { * const a = await call.ask("Press one for sales.", { digits: 1, timeout: 5000 }); * if (a.by === "keypad" && a.digit === "1") await call.routeTo("ventas"); * }); */ line(number: string, opts?: LineOptions): PhoneLine; removeAgent(id: string): boolean; /** * Mint a short-lived browser token for an agent. * * `opts` (optional, spec §5) narrows the token: `{ scope: "observe", * callId }` mints a read-only Call Log token for a single call. Omitting * it mints exactly the token this method has always minted. * * Ordered AFTER the agent's server-side registration: `pc.agent()` returns * synchronously and only queues `agent.create` on the socket, so a mint * issued in the next statement used to overtake it on the wire and come * back `404 Agent '' is not online` — a valid, healthy registration * refused purely because the HTTP request beat the WebSocket frame. For an * agent this client owns we wait for `agent.created` first. Agents owned by * another process are minted straight through (nothing local to wait on). */ createToken(channel: "webrtc" | "chat" | "stream", agentId: string | readonly string[], metadata?: Record, opts?: TokenScopeOptions): Promise; /** * Read the Call Log over SSE — the ONE way to observe a call from Node. * * Opens `GET /v1/calls/{id}/events` (or `/v1/agents/{slug}/calls`) with * `Accept: text/event-stream`, feeds every envelope into the same * `CallLogView` reducer the browser uses, and exposes it three ways: the * reduced `state`, `on("entry" | "custom" | "finish")`, and `for await`. * No WebSocket is opened — observation is read-only by construction. * * The token defaults to one minted with this client's API key * (`createToken({ channel: "stream", scope: "observe" })`), which needs an * agent: `observe({ call })` without a token must also pass `agent`. * * @example * ```ts * const obs = pc.observe({ agent: "lucia", types: ["custom", "call.ended"] }); * obs.on("custom", (name, value) => console.log(name, value)); * for await (const entry of obs) console.log(entry.seq, entry.type); * ``` */ observe(opts: ObserveOptions): Observation; stream(opts?: StreamOptions): Response; stream(res: ServerResponse, opts?: StreamOptions): void; send(data: Record): void; /** @internal Emit a typed event (the context's `emitClientEvent`). */ _emitWire(event: string, ...args: unknown[]): void; /** @internal Get an agent by ID. */ _getAgent(id: string): Agent | undefined; /** @internal Get all registered agents (the context's `allAgents`). Used when agent_id is missing. */ _allAgents(): Agent[]; /** @internal Get the WhatsApp handler (backs the context's `whatsappSession`). */ _getWhatsAppHandler(): WhatsAppHandler; } /** * Client → Server command types — PROTOCOL.md §8, §9, §12–§14. */ interface RegisterCommand { event: "register"; api_key: string; app_id?: string; mode?: "twilio" | "websocket" | "webrtc"; config?: SessionConfig; phones?: Record>; } interface BotReplyCommand { event: "bot.reply"; call_id: string; message_id: string; text: string; in_reply_to: string; } interface BotReplyStreamCommand { event: "bot.reply.stream"; call_id: string; message_id: string; action: "start" | "chunk" | "end"; in_reply_to?: string; token?: string; } interface BotCancelCommand { event: "bot.cancel"; call_id: string; message_id?: string; } interface BotClearCommand { event: "bot.clear"; call_id: string; } interface CallHangupCommand { event: "call.hangup"; call_id: string; } interface CallDialCommand { event: "call.dial"; to: string; from: string; greeting?: string; metadata?: Record; } interface CallForwardCommand { event: "call.forward"; call_id: string; to: string; message?: string; announce?: boolean; } interface CallDtmfCommand { event: "call.dtmf"; call_id: string; digits: string; } interface UpdateConfigCommand { event: "update_config"; config: Partial; phone?: string; } interface UpdateSessionConfigCommand { event: "update_session_config"; session_id: string; config: Partial; } interface AddPhoneCommand { event: "add_phone"; phone: string; config?: Partial; } interface RemovePhoneCommand { event: "remove_phone"; phone: string; } interface CallHoldCommand { event: "call.hold"; call_id: string; } interface CallUnholdCommand { event: "call.unhold"; call_id: string; } interface CallMuteCommand { event: "call.mute"; call_id: string; } interface CallUnmuteCommand { event: "call.unmute"; call_id: string; } interface PingCommand { event: "ping"; } interface ConnectCommand { event: "connect"; api_key: string; } interface AgentCreateCommand { event: "agent.create"; agent_id?: string; voice?: string | Record; language?: string; stt?: string | Record; config?: Record; } interface AgentResumeCommand { event: "agent.resume"; agent_id: string; } interface AgentConfigureCommand { event: "agent.configure"; agent_id: string; voice?: string | Record; language?: string; stt?: string | Record; interruption?: boolean | Record; config?: Record; } interface ChannelAddCommand { event: "channel.add"; agent_id: string; type: "phone" | "webrtc" | "mic"; ref?: string; voice?: string | Record; language?: string; stt?: string | Record; config?: Record; } interface ChannelConfigureCommand { event: "channel.configure"; agent_id: string; ref: string; voice?: string | Record; language?: string; stt?: string | Record; config?: Record; } interface ChannelRemoveCommand { event: "channel.remove"; agent_id: string; ref: string; } interface SessionConfigureCommand { event: "session.configure"; agent_id?: string; session_id: string; voice?: string | Record; language?: string; stt?: string | Record; } /** * Claim a phone number as a programmable LINE — no model, no prompt. * * `config` is the line's own pipeline, resolved through the same shortcuts an * agent's config is; `llm`/`prompt`/`tools` are refused server-side and are * refused in `pc.line()` before they ever reach the socket. */ interface LineCreateCommand { event: "line.create"; number: string; config: { stt?: string | Record; voice?: string | Record; language?: string; turn_detection?: string | Record; /** How long after connect to collect post-dial digits. 0 disables. */ extension_window_ms?: number; }; } /** Release the number. */ interface LineDestroyCommand { event: "line.destroy"; number: string; } /** * Hand the LIVE call to an agent — the owner swap. No re-dial, no drop: the * server rebuilds STT/TTS/turn on the same stream and the agent sees a normal * `call.started` with `routed_from`. */ interface CallRouteCommand { event: "call.route"; call_id: string; agent: string; language?: string; voice?: string | Record; stt?: string | Record; greeting?: string; prompt_vars?: Record; context?: Record; /** Prime the agent with what the line heard. Default true. */ history?: boolean; } type ClientCommand = RegisterCommand | BotReplyCommand | BotReplyStreamCommand | BotCancelCommand | BotClearCommand | CallHangupCommand | CallDialCommand | CallForwardCommand | CallDtmfCommand | UpdateConfigCommand | UpdateSessionConfigCommand | AddPhoneCommand | RemovePhoneCommand | CallHoldCommand | CallUnholdCommand | CallMuteCommand | CallUnmuteCommand | PingCommand | ConnectCommand | AgentCreateCommand | AgentResumeCommand | AgentConfigureCommand | ChannelAddCommand | ChannelConfigureCommand | ChannelRemoveCommand | SessionConfigureCommand | LineCreateCommand | LineDestroyCommand | CallRouteCommand; /** * ID generation — Stripe-style prefixed IDs. * * generateId() → "msg_a1b2c3d4e5f6" * generateId("greet") → "greet_a1b2c3d4e5f6" * * Uses crypto.getRandomValues() for proper randomness (browser-safe). */ declare function generateId(prefix?: string): string; /** * Reconnector — exponential backoff reconnection logic. * * Port of src.bkp/utils/reconnect.ts with maxAttempts addition. * When maxAttempts is exceeded, wait() rejects. */ interface ReconnectOptions { /** Initial delay in ms (default: 1000) */ initialDelay?: number; /** Maximum delay in ms (default: 30000) */ maxDelay?: number; /** Multiplier per attempt (default: 2) */ factor?: number; /** Add random jitter 0-25% (default: true) */ jitter?: boolean; /** Maximum attempts before giving up (default: Infinity) */ maxAttempts?: number; } declare class Reconnector { #private; constructor(opts?: ReconnectOptions); get attempt(): number; /** Calculate delay for the next attempt. */ nextDelay(): number; /** Wait for the next backoff delay. Rejects if maxAttempts exceeded. */ wait(): Promise; /** Reset attempt counter (call on successful connection). */ reset(): void; /** Cancel any pending wait. */ cancel(): void; } /** * Phone API — fetch account phone numbers. */ interface Phone { number: string; name: string; sid: string; isSdk?: boolean; } interface FetchPhonesOptions { apiKey: string; apiUrl?: string; } declare function fetchPhones(opts: FetchPhonesOptions): Promise; /** * Balance API — Twilio and account balance. */ interface FetchTwilioBalanceOptions { apiKey?: string; apiUrl?: string; } interface TwilioBalance { balance: string; currency: string; } declare function fetchTwilioBalance(opts?: FetchTwilioBalanceOptions): Promise; /** * Model access API — check whether the authenticated org can use a given * STT/TTS/LLM model (plan + managed/BYOK gates), before configuring an agent. * * Hits the Playground org API (authenticated with your API key), not the voice * server. Default base: https://playground.pinecall.io (override with * PINECALL_PLAYGROUND_URL or the `playgroundUrl` option). */ type ModelAccessReason = "ok" | "unknown_model" | "plan_restricted" | "byok_key_required"; interface ModelAccess { service: string; provider?: string; model: string; /** model is priced/known */ exists: boolean; /** Pinecall serves it with its own key (no token needed) */ managed: boolean; /** the model's provider is allowed on the org's plan */ planAllowed: boolean; /** the org has saved its own key for this provider */ hasKey: boolean; /** BYOK provider with no saved key → user must add one */ requiresKey: boolean; /** final verdict: planAllowed && (managed || hasKey) */ allowed: boolean; reason: ModelAccessReason; } interface FetchModelAccessOptions { service: "stt" | "tts" | "llm"; model: string; apiKey?: string; playgroundUrl?: string; } interface ListModelAccessOptions { apiKey?: string; playgroundUrl?: string; } /** Access decision for one (service, model). */ declare function fetchModelAccess(opts: FetchModelAccessOptions): Promise; /** Convenience: true if the org can use the model. */ declare function hasModelAccess(opts: FetchModelAccessOptions): Promise; /** Access for every priced model the org could use. */ declare function fetchModelCatalog(opts?: ListModelAccessOptions): Promise; /** * Knowledge base (RAG) REST client — the documents an agent can look things up in. * * Knowledge bases live on the PLAYGROUND API (the management plane), not on * the voice server: creating a KB, pushing docs and rebuilding the index are * account operations, and they happen whether or not any agent is online. * That is why this module takes its own `playgroundUrl` instead of the * `apiUrl` the rest of the SDK talks to. * * Knowledge bases are a paid feature. The server answers HTTP 402 for orgs on * a plan without them, and that arrives here as a typed * `KnowledgeApiError` with `code === "UPGRADE_REQUIRED"` — catchable, so a * consumer can offer the upgrade instead of parsing a message. */ declare const DEFAULT_PLAYGROUND_URL = "https://playground.pinecall.io"; interface KnowledgeBase { id: string; name: string; description?: string; docCount: number; status: string; } interface KnowledgeDoc { id: string; path: string; title: string; bytes: number; } /** A document as returned by `getDoc` — the listing fields plus the text. */ interface KnowledgeDocWithText extends KnowledgeDoc { text: string; } interface KnowledgeHit { score: number; text: string; heading?: string; doc_title?: string; doc_path?: string; } interface KnowledgeApiOptions { apiKey: string; /** * Management API base. Defaults to `PINECALL_PLAYGROUND_URL` and then to * https://playground.pinecall.io. Trailing slashes are stripped, so * "http://localhost:3000/" and "http://localhost:3000" are the same host. */ playgroundUrl?: string; } /** A document to upsert. `path` is the identity: pushing the same path updates. */ interface KnowledgeDocInput { path: string; title?: string; text: string; } /** One entry of a `pushDocs` batch — a failure never aborts the rest. */ interface PushResult { path: string; ok: boolean; doc?: KnowledgeDoc; error?: Error; } declare class KnowledgeApiError extends PinecallError { status: number; constructor(message: string, status: number, code?: string); } declare function listKnowledgeBases(opts: KnowledgeApiOptions): Promise; declare function createKnowledgeBase(opts: KnowledgeApiOptions, name: string, description?: string): Promise; declare function getKnowledgeBase(opts: KnowledgeApiOptions, kbId: string): Promise<{ knowledgeBase: KnowledgeBase; docs: KnowledgeDoc[]; }>; declare function deleteKnowledgeBase(opts: KnowledgeApiOptions, kbId: string): Promise; declare function reindexKnowledge(opts: KnowledgeApiOptions, kbId: string): Promise; /** * Upsert one document. The server keys on `path`, so pushing the same path * twice updates the document instead of duplicating it — which is what lets a * consumer re-push a whole folder on every build. */ declare function pushDoc(opts: KnowledgeApiOptions, kbId: string, doc: KnowledgeDocInput): Promise; /** * Push a batch. One bad document does not lose the other forty: every entry * comes back with its own ok/error, in the order given. */ declare function pushDocs(opts: KnowledgeApiOptions, kbId: string, docs: KnowledgeDocInput[]): Promise; declare function getDoc(opts: KnowledgeApiOptions, kbId: string, docId: string): Promise; declare function deleteDoc(opts: KnowledgeApiOptions, kbId: string, docId: string): Promise; /** Retrieval only — the top `k` chunks, no LLM in the loop. */ declare function queryKnowledge(opts: KnowledgeApiOptions, kbId: string, query: string, o?: { k?: number; }): Promise; export { type AddPhoneCommand, Agent, type AgentConfig, type AgentConfigureCommand, AgentConflictError, type AgentCreateCommand, type AgentEvents, type AgentMemory, type AgentResumeCommand, type AnalysisConfig, AudioApiError, type AudioMetricsEvent, type AudioNamespace, type BargeInEvent, type BotCancelCommand, type BotClearCommand, type BotFinishedEvent, type BotInterruptedEvent, type BotReplyCommand, type BotReplyStreamCommand, type BotSpeakingEvent, type BotWordEvent, Call, type CallDialCommand, type CallDtmfCommand, type CallEndedEvent, type CallEvents, type CallForwardCommand, type CallHangupCommand, type CallHeldEvent, type CallHoldCommand, type CallInit, type CallLogOptions, type CallLogRejectedEvent, type CallMuteCommand, type CallMutedEvent, type CallRejectedEvent, type CallRingingEvent, type CallStartedEvent, type CallUnheldEvent, type CallUnholdCommand, type CallUnmuteCommand, type CallUnmutedEvent, type CartesiaTTSConfig, type ChannelAddCommand, type ChannelConfig, type ChannelConfigureCommand, type ChannelRemoveCommand, type ClientCommand, type ConnectCommand, type ConversationRecord, type CreateTokenOptions, DEFAULT_EXTENSION_WINDOW_MS, DEFAULT_PLAYGROUND_URL, type DeepgramSTTConfig, type EagerTurnEvent, type ElevenLabsTTSConfig, type ErrorEvent, type ExtensionTable, type FetchAudioVoicesOptions, type FetchModelAccessOptions, type FetchPhonesOptions, type FetchTwilioBalanceOptions, type FetchVoicesOptions, type FetchWebRTCTokenOptions, type FluxSTTConfig, type ForwardOptions, type GladiaSTTConfig, type HistoryStore, type IdleReconnect, type InterruptionConfig, type InterruptionShortcut, JsonFileHistory, KnowledgeApiError, type KnowledgeApiOptions, type KnowledgeBase, type KnowledgeDoc, type KnowledgeDocInput, type KnowledgeDocWithText, type KnowledgeHit, LineCall, type LineOptions, type LineTranscriptEntry, type ListModelAccessOptions, type ListenOptions, type ListenResult, type MemoryConfig, type MemoryContact, type MemoryFact, type MemoryHit, type MemoryOp, type MemoryOpsEvent, type MessageConfirmedEvent, type ModelAccess, type ModelAccessReason, type Observation, type ObserveError, type ObserveFetch, type ObserveFinishInfo, type ObserveOptions, type ObserveResponseLike, type Phone as PhoneInfo, PhoneLine, type PhoneLineEvents, type PhoneNumberConfig, Pinecall, PinecallError, type PinecallEvents, type PinecallOptions, type PingCommand, type PollyTTSConfig, type PongEvent, type PushResult, type ReconnectOptions, Reconnector, type RegisterCommand, type RegisteredEvent, type RemovePhoneCommand, type ReplyOptions, type ReplyRejectedEvent, ReplyStream, type ReplyStreamOptions, RingingCall, type RouteFailureReason, type RouteOptions, type RouteResult, type SSEResponse, type STTConfig, type STTShortcut, type SayOptions, type SayResult, ServerAtCapacityError, type ServerEvent, type SessionConfig, type SessionConfigureCommand, type SessionTimeoutEvent, type Skill, type SkillActivation, type SkillConfig, type SonioxSTTConfig, type SpeakerFilterConfig, type SpeechApiOptions, type SpeechDone, type SpeechEndedEvent, type SpeechFormat, type SpeechOptions, type SpeechResult, type SpeechStartedEvent, type SpeechWord, type SseEvent, type StreamDone, type StreamFinal, type StreamModel, type StreamOptions, type StreamReady, type StreamSSEOptions, type TTSConfig, type TokenResponse, type TokenScope, type TokenScopeOptions, type Tool, type ToolCallEvent, type ToolCallItem, type ToolConfig, type TranscribeApiOptions, type TranscribeInput, type TranscribeOptions, type TranscribeSTTConfig, type TranscribeStream, type TranscribeStreamEvents, type TranscribeStreamItem, type TranscribeStreamOptions, type TranscriptSegment, type TranscriptWord, type Transcription, type TranscriptionModel, type Turn, type TurnContinuedEvent, type TurnEndEvent, type TurnPauseEvent, type TurnResumedEvent, type TwilioBalance, type UpdateConfigCommand, type UpdateSessionConfigCommand, type UserMessageEvent, type UserSpeakingEvent, type Voice, type VoiceLanguage, type VoiceShortcut, type WebRTCToken, type WhatsAppChannelConfig, WhatsAppSession, createKnowledgeBase, createToken, deleteDoc, deleteKnowledgeBase, fetchAudioVoices, fetchModelAccess, fetchModelCatalog, fetchPhones, fetchTwilioBalance, fetchVoices, generateId, getDoc, getKnowledgeBase, hasModelAccess, listKnowledgeBases, observe, observeBackoffDelay, pushDoc, pushDocs, queryKnowledge, reindexKnowledge, skill, speech, sseDecoder, tool, transcribe, transcribeStream };