Pinecall

Pinecall

The WebSocket client. Manages auth, reconnection, and agent multiplexing.

Auto-connects on construction. When you create a Pinecall instance with an API key, it connects immediately — no need to call connect().

Constructor#

new Pinecall(options)
OptionTypeDefaultDescription
apiKeystringPINECALL_API_KEY env varYour Pinecall API key. Auto-read from env if not provided.
apiUrlstringwss://voice.pinecall.ioServer URL
autoReconnectbooleantrueAuto-reconnect on disconnect
promptsDirstring"prompts"Prompts directory for setPromptFile

Example#

// Reads PINECALL_API_KEY from env automatically
const pc = new Pinecall();

// Or pass explicitly
const pc = new Pinecall({ apiKey: "pk_..." });

Agents can be created immediately — they queue and register when the connection is ready:

const pc = new Pinecall();
const agent = pc.agent("support", { /* ... */ }); // works before connected

Methods#

ready#

Promise<void> that resolves when the connection is established. Use it when you need to wait for the connection before proceeding (e.g. before dialing an outbound call).

await pc.ready;
const call = await agent.dial({ to: "+14155551234" });

connect()#

Manually open the WebSocket connection. Rarely needed — the constructor auto-connects when an API key is present. Idempotent (safe to call multiple times).

await pc.connect();

disconnect()#

Gracefully close the connection.

await pc.disconnect();

agent(id, config?)#

Create or retrieve an agent. If an agent with this ID already exists, returns it (idempotent).

const agent = pc.agent("support", {
  voice: "elevenlabs/sarah",
  language: "en",
  llm: "openai/gpt-5.4-nano",
  stt: "deepgram/flux",
  prompt: "You are a support agent. Be concise.",
  greeting: "Hi! How can I help you today?",
  phoneNumber: "+13186330963",
});

AgentConfig fields:

FieldTypeDescription
voicestring | VoiceConfigTTS voice shortcut (e.g. elevenlabs/sarah)
languagestringBCP-47 language code
sttstring | STTConfigSTT shortcut (e.g. deepgram/flux)
llmstring | LLMConfigLLM shortcut (e.g. openai/gpt-5.4-nano) or full config
promptstringSystem prompt for the LLM
promptVarsRecord<string, string>Default {{var}} values, seeded at registration so nothing ever renders as a literal {{VAR}}
preparingboolean | { timeoutMs }Opt in to the pre-turn barrier: the server holds each generation while your call.preparing handler refreshes per-turn variables
greetingstring | { text, addToHistory? } | (call) => stringGreeting spoken on inbound calls. Added to LLM history by default.
toolsTool[]Declarative tool definitions created with tool()
phoneNumberstring | PhoneNumberConfigPhone number or SIP URI to register (Twilio)
phoneNumbersArray<string | PhoneNumberConfig>Multiple phone numbers with per-number config (e.g. one per language)
whatsappWhatsAppChannelConfig[]WhatsApp channels (Meta Cloud API credentials)
sessionLimitsobjectSession timeout config (see Session Limits)
allowedOriginsstring[]Allowed origins for public browser token access (see Security)

Dynamic greetings with a function:

greeting: async (call) => {
  const customer = await db.findByPhone(call.from);
  return `Hi ${customer.name}! How can I help?`;
},

Greeting without LLM history (e.g. a standalone announcement):

greeting: { text: "Welcome! Please hold.", addToHistory: false },

See Agent for full API reference.

getAgent(id)#

Look up an agent by ID. Returns Agent | undefined.

const mara = pc.getAgent("mara");

removeAgent(id)#

Unregister an agent. Returns boolean indicating whether the agent existed.

const removed = pc.removeAgent("mara");

createToken(channel, agentId, metadata?)#

Generate a short-lived, single-use token for browser WebRTC or chat connections. Used to mint tokens for browsers.

const token = await pc.createToken("webrtc", "mara");
// { token, server, expiresIn }

For an agent this client owns, the mint is ordered after the server's registration ack (see agent.ready) — so registering and minting in consecutive statements works, with no delay of your own. Agents owned by another process are minted straight through.

Sealed session metadata — pass a third argument to bake trusted context into the token:

const token = await pc.createToken("chat", "mara", { userId: "u_123", plan: "pro" });

The metadata is sealed into the signed token on your server, so the browser cannot forge or alter it. It surfaces as call.metadata in your call.started handler — use it for per-user / multi-tenant context you can trust (auth identity, plan, tenant id). Works identically for "webrtc" and "chat".

With an Agent instance, use agent.createToken(channel, metadata?) (the agentId is implicit).

⚠️ This is not the client-supplied metadata prop on the widget / VoiceSession — that is set in the browser and can be forged. For anything used in authorization, seal it in the token here.

Multi-tenant pattern: sealed metadata lets ONE shared agent serve every tenant — the logged-in user's identity (tenant id, role, …) rides per-call in call.metadata, so tools scope by it in code. See Multi-Tenant → sealed token metadata.

See Security for the full token model.

createToken("stream", agents)#

The same method also mints observation tokens for the call log: pass "stream" and one agent slug — or a list of slugs — and the returned token lets its holder observe those agents' calls (live tail, replay, history) without participating. The agent set is sealed into the token's signature; the browser cannot widen it.

const t = await pc.createToken("stream", "mara");            // one agent
const t = await pc.createToken("stream", ["mara", "sales"]); // an agent set
// read-only: no supervise verbs, narrowed to one call
const t = await pc.createToken("stream", "mara", undefined, { scope: "observe", callId });
// → { token, server, expiresIn }

The fourth argument is { scope?: "observe" | "participate" | "supervise"; callId?: string }.

Consume it in the browser with @pinecall/web/log/react (useAgentCalls, useCall), or in Node with pc.observe(). See Observe calls.

observe(options)#

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() listeners, and for await. No WebSocket is opened — observation is read-only by construction.

const obs = pc.observe({ agent: "mara", types: ["custom", "call.ended"] });

obs.on("custom", (name, value, entry) => console.log(entry.call, name, value));
obs.on("entry", (entry, state) => {});
obs.on("finish", ({ reason, lastSeq }) => {});

for await (const entry of obs) console.log(entry.seq, entry.type);

obs.state      // the reduced CallLogState
obs.lastSeq    // the resume cursor
obs.dropped    // entries the ITERATOR never saw (state is never affected)
await obs.done;
obs.close();
optiondefaultwhat it is
call / agentexactly one. call = one call's log; agent = the agent's lifecycle log (which never ends)
after0start cursor
tokenmintedomit and one is minted with this client's API key ({ channel: "stream", scope: "observe" }) — which needs an agent, so observe({ call }) without a token must also pass agent
types / durablethe server-side filters, plus the always-pass set
queueLimit1024bound on the async iterator's buffer; on overflow the oldest entries are dropped and counted in obs.dropped. state and on("entry") are never lossy
idleReconnect"auto"half-open detection
reconnecttruefalse disables auto-reconnect
signalaborting it is exactly close()
server / apiUrl / apiKeythe client'soverrides
onErrortransport-level failures; state is never faked into the view

See Observe calls for the resume rules, the filters and the browser side.

stream(res?, options?)#

Open an SSE stream of agent events — in-process only (the HTTP endpoint must live in this same process, and there is no replay, no cursor, no history, and only the calls this process handles; for anything a user sees, read the call log instead). Works with any framework — returns a Web API Response or writes to a Node.js ServerResponse.

// Web API (Remix, Next.js, Hono, Bun)
app.get("/events", () => pc.stream());

// Express / Node.js
app.get("/events", (req, res) => pc.stream(res));

// Filtered to specific agents
app.get("/events", () => pc.stream({ agents: ["mara", "support"] }));
app.get("/events", (req, res) => pc.stream(res, { agents: ["mara"] }));

See Multi-tenant guide for the filtering pattern.

Events#

Subscribe via pc.on(event, handler).

EventSignatureWhen
connected()WebSocket auth succeeded
disconnected(reason)Connection closed
reconnecting(attempt, delay)Auto-reconnect attempt N
error(err)Protocol or transport error
pc.on("connected", () => console.log("Live"));
pc.on("disconnected", (reason) => console.log("Down:", reason));
pc.on("reconnecting", (n) => console.log(`Retry ${n}`));
pc.on("error", (err) => console.error(err));

What's next#

  • Agent — channels, events, hot-reload, dial
  • Call — per-session control
  • Security — token model and best practices