@pinecall/web
v0.6.0
Published
Pinecall web client — WebRTC voice, text chat, and React widgets for Pinecall agents
Maintainers
Readme
@pinecall/web
The web client for Pinecall agents — real-time WebRTC voice, text chat, and drop-in React widgets, in one package.
Migrating from
@pinecall/voice-core/@pinecall/voice-widget/@pinecall/chat-core? They are now a single package. See Entry points below — the React widget moves to the package root, vanilla voice to/core, and chat to/chat+/chat/react.
Install
npm install @pinecall/web
# React is a peer dep — only needed for the widget + chat/react entries
npm install react react-domEntry points
| Import | What | Needs React |
|--------|------|-------------|
| @pinecall/web | React widgets — VoiceWidget, ContactHub, ChatView, useVoice, useVoiceSession, presets | ✅ |
| @pinecall/web/core | VoiceSession — framework-agnostic WebRTC voice client | ❌ |
| @pinecall/web/chat | ChatSession — framework-agnostic text chat client | ❌ |
| @pinecall/web/chat/react | usePinecallChat — React hook over ChatSession | ✅ |
| @pinecall/web/orb | <pinecall-orb> — framework-agnostic voice orb (Custom Element) | ❌ |
| @pinecall/web/orb/react | <Orb> — thin React wrapper for <pinecall-orb> | ✅ |
| @pinecall/web/modal | <pinecall-modal> — glass call modal (Custom Element): orb or wave visual, live captions, text-during-call, transcript view | ❌ |
| @pinecall/web/modal/react | <CallModal> — thin React wrapper for <pinecall-modal> | ✅ |
| @pinecall/web/chatbox | <pinecall-chat> — docked chatbox (Custom Element): text chat that can escalate to a WebRTC voice call, with conversation continuity | ❌ |
| @pinecall/web/chatbox/react | <ChatBox> — thin React wrapper for <pinecall-chat> | ✅ |
| @pinecall/web/log | Call Log — CallLogView reducer + sse() / tail() / poll() / observe() transports | ❌ |
| @pinecall/web/log/react | useCall, useAgentCalls — React hooks over the Call Log | ✅ |
Web Components vs React widget: the
/orb,/modaland/chatboxentries are native Custom Elements — they work in any framework (React, Vue, Svelte, Angular, vanilla) and need no React. The original@pinecall/webReact widget stays available unchanged.
Quick Start
React widget
import { VoiceWidget } from "@pinecall/web";
<VoiceWidget agent="mara" name="Mara" preset="midnight" />Vanilla voice (any framework)
import { VoiceSession } from "@pinecall/web/core";
const session = new VoiceSession({ agent: "mara" });
session.subscribe(() => console.log(session.getState()));
await session.connect();Chat hook
import { usePinecallChat } from "@pinecall/web/chat/react";
const chat = usePinecallChat({ agent: "florencia" });Web Components (any framework)
<!-- voice orb -->
<pinecall-orb agent="mara" name="Mara" preset="midnight"></pinecall-orb>
<!-- call modal: orb or wave visual, captions, text-during-call -->
<pinecall-modal agent="mara" name="Mara" visual="wave"></pinecall-modal>
<!-- docked chatbox: text chat + a call button to escalate to voice -->
<pinecall-chat agent="mara" name="Mara" greeting="Hi! How can I help?"></pinecall-chat>
<script type="module">
import "@pinecall/web/orb";
import "@pinecall/web/modal";
import "@pinecall/web/chatbox";
// function/object props are set as PROPERTIES (not attributes):
const modal = document.querySelector("pinecall-modal");
modal.tokenProvider = async () => (await fetch("/api/token")).json();
// the chatbox tokenProvider is channel-aware (text vs voice):
document.querySelector("pinecall-chat").tokenProvider =
async (channel) => (await fetch(`/api/token?channel=${channel}`)).json();
</script>Orb — opens attribute: "inline" (captions beside the orb, default), "modal" (opens a <pinecall-modal>), or "chat" (opens a <pinecall-chat>). One orb, any presentation.
Chatbox — text-first; a call button escalates to a WebRTC voice call and the conversation continues (prior transcript carried over). Attributes: greeting (first bot bubble, client-side), auto-call (start in a call), no-call (pure text). Its tokenProvider is channel-aware: (channel: "chat" | "webrtc") => {token, server}.
Common attributes: agent, server, name, label, preset, avatar, visual (orb | wave). Properties: config, metadata, tokenProvider, theme. Events: pinecall:status, pinecall:transcript, pinecall:error, pinecall:open, pinecall:close. Theme via the --vw-* / --pm-* CSS custom properties (e.g. --pm-user / --pm-bot for speaker colors).
In React, prefer the wrappers so object/function props bind cleanly:
import { CallModal } from "@pinecall/web/modal/react";
<CallModal agent="mara" name="Mara" visual="wave"
tokenProvider={async () => (await fetch("/api/token")).json()} />Structure
web/
├── src/
│ ├── index.ts @pinecall/web — React widgets barrel
│ ├── core/ @pinecall/web/core — VoiceSession (vanilla)
│ ├── chat/ @pinecall/web/chat[/react] — ChatSession + React hook
│ ├── orb/ @pinecall/web/orb[/react] — <pinecall-orb> custom element
│ ├── modal/ @pinecall/web/modal[/react] — <pinecall-modal> call modal
│ ├── chatbox/ @pinecall/web/chatbox[/react] — <pinecall-chat> chatbox
│ ├── log/ @pinecall/web/log[/react] — Call Log observer (vendor/ = SDK reducer)
│ └── widget/ React components (VoiceWidget, ContactHub, ChatView…)
├── tests/ vitest (jsdom) — mock /v1/attach + golden fixture
├── docs/ diagrams + legacy changelogs
├── examples/ demo pages (orb/modal/chatbox.html) + token-server + react app
├── tsup.config.ts Build (12 entries → ESM + CJS + DTS)
└── tsconfig.jsonDevelopment
pnpm install
pnpm build # build all 4 entries (ESM + CJS + DTS)
pnpm dev # tsup watch
pnpm typecheck
pnpm test # vitest (jsdom)Publishing
npm version <patch|minor|major>
pnpm release # build + npm publishWatching a call — @pinecall/web/log
A call is an append-only event log with per-call monotonic seq
(CALL_LOG_SPEC.md). Live, late, reconnecting, replaying and history are all
cursors over it — so there is one reducer and several pipes into it.
import { useCall } from "@pinecall/web/log/react";
function Transcript({ token }: { token: string }) {
// `observe` token, call-scoped. transport "auto" (the default) = SSE on
// GET /v1/calls/{id}/events, degrading to GET polling if the stream can
// never be opened. No WebSocket is involved.
const call = useCall({ call: "CA_abc", token });
return (
<ul>
{call.messages.map((m) => (
<li key={m.seq}>{m.role}: {m.text}</li>
))}
</ul>
);
}Framework-free:
import { createCallLogView, observe } from "@pinecall/web/log";
const view = createCallLogView();
view.subscribe((state) => render(state));
const o = observe(view, {
token,
call: "CA_abc",
types: ["user.message", "bot.finished", "custom"], // server-side filter
durable: true, // skip ephemerals
onEntry: (entry, state) => {}, // every applied entry, never throttled
onFinish: ({ reason }) => {}, // "summary" | "closed" | "error", once
});Observe
observe()issse → poll.transport: "auto"opensGET /v1/calls/{id}/events(or/v1/agents/{slug}/calls) withAccept: text/event-stream— fetch +ReadableStream+ a small SSE decoder, notEventSource— and degrades to polling only if the stream never delivers.transport: "sse"/"poll"pin a pipe.observation.kindsays which one is carrying entries.wsonly on request.transport: "ws"attaches overWS /v1/attach; it is the pipe for the supervise verbs (say,whisper,takeover,release,end,transfer, viasend({verb: …})with asupervisetoken).send()returnsfalseonsseandpoll. Observation never opens a WebSocket on its own.- Reconnect is free. Every (re)open carries
after=<view.lastSeq>— the SSE pipe too, it never sendsLast-Event-ID— and the view dedupes byseq, so replay overlap is harmless and a dropped pipe loses nothing. Backoff ismin(1000·2^n, 15000) + rand(0, 1000)ms, deferred while the document is hidden; an idle watchdog (idleReconnect:"auto"learns the server's heartbeat cadence —: pingon SSE,{"type":"ping"}on WS — and trips atclamp(3×cadence, 6 s, 30 s); a number is a fixed window;0is off) turns a half-open pipe into a reconnect. - Ended calls stop the pipe. The SSE body ends after
call.summary(a sealed cursor answers204), the WS honours the reducer's{kind:"disconnect"}intent, polling stops onlive:false.onFinishfires exactly once with"summary"(clean end),"closed"(you calledclose()) or"error"(gave up:reconnect:falseand the pipe died, or a 401/403/404). types/durableare server-side filters, threaded into every pipe's URL;seqstays intact andlog.gap,log.caught_up,call.ended,call.summaryalways pass.useAgentCalls(agent, {token})watches the agent's lifecycle log:{calls, live}, one row per call. That log never ends.- The reducer itself is vendored from
@pinecall/sdk/logbyte for byte so this package gains no runtime dependency — seesrc/log/vendor/README.md.
The hooks — useCall / useAgentCalls
Everything observe() takes (transport, types, durable,
idleReconnect, server, after, reconnect, …) plus:
| option | type | default | what it does |
|---|---|---|---|
| enabled | boolean | true | false keeps the view but opens nothing — a paused dashboard tile. It still resumes from the stored cursor when it is enabled later. |
| throttle | number \| boolean | true | Coalesce React notifications only. true = one render per macrotask (a whole SSE chunk or WS burst → one render). A number = at most one render per n ms, leading + trailing — a real throttle, so bot.word at speech rate never starves the UI. false = a render per applied entry. The reducer always runs at wire speed and view.state is current mid-window. |
| reconnectOnMount | boolean \| (() => CallCursorStorage) | true | Resume where the last mount left off. true = window.localStorage, key pc:log:<call> (or pc:log:agent:<slug>), value {seq, ts}. Read once when the observation opens and used as the after= seed — only when you passed no after and the view is cold. Written through as entries land (one write per macrotask), cleared when call.summary is applied, ignored and removed when older than 24 h. A function supplies any three-method storage (tests, SSR, a cookie jar); storage that is missing or throws behaves as false. |
| onEntry | (entry, state) => void | — | Every applied entry, in seq order, before React is notified, never throttled, never twice for one seq. state is the state after the entry. |
| onCustom | (name, value, entry) => void | — | The typed view of onEntry for call.log() entries. Ephemeral customs arrive here and nowhere else — they never enter s.custom, because the server never stored them and a replay must reproduce the same state. |
| onFinish | ({reason, error?, lastSeq}) => void | — | Once per observation: "summary", "closed", "error". |
| onDegrade | (err, to) => void | — | The sse → poll fallback fired. |
useCall returns the whole CallLogState plus { custom, view, transport, send, close }.
Typing your own log vocabulary. The generic parameter names the call.log()
entries this call emits, and types both s.custom and onCustom:
type Log = {
"crm.lookup": { customer: string; tier: "gold" | "platinum" };
"ui.toast": string;
};
const s = useCall<Log>({
call,
token,
onCustom: (name, value, _entry) => {
if (name === "crm.lookup") console.log(value.tier); // narrowed
},
});
s.custom.forEach((row) => {
if (row.name === "crm.lookup") render(row.value.customer);
});Declare all three onCustom parameters (_entry if you do not want it): the
narrowing comes from a union of parameter tuples, and TypeScript only applies
it when the arity matches.
s.custom is the durable custom entries, upserted by (name, id) —
call.log("crm.lookup", v, { id: "c1" }) twice leaves one row with the newer
value, at the newer seq.
Widget Theme & Orb States
The <VoiceWidget> orb cycles through visual states as the session progresses. Each state has a configurable color (RGB triplet):
| Orb State | CSS Class | Theme Property | Default Color | When |
|-----------|-----------|---------------|---------------|------|
| Idle | — | orbFrom/orbMid/orbTo | Pearl gradient | Not connected |
| Connecting | .connecting | colorConnecting | 245, 158, 11 (amber) | Establishing WebRTC |
| Active | .active | colorActive | 76, 175, 80 (green) | Connected, waiting |
| User speaking | .user-speaking | colorUserSpeaking | 52, 211, 153 (emerald) | User talking |
| Agent speaking | .speaking | colorSpeaking | 248, 113, 113 (rose) | Agent talking |
| Thinking | .thinking | colorThinking | 139, 92, 246 (violet) | Processing |
| Idle warning | .idle-warning | colorWarning | 255, 160, 0 (orange) | User silent too long, call will timeout |
Idle Warning
When the server emits session.idle_warning, the orb switches to the idle-warning state — a blinking amber/orange animation. This warns the user that the call will end due to inactivity.
// Customize the warning color via theme
<VoiceWidget
agent="mara"
theme={{ colorWarning: "255, 60, 60" }} // red warning
/>The idle warning is cleared when:
- The user starts speaking
- The session disconnects
session.timeoutfires (auto-disconnect)
Theme Presets
5 built-in presets: dark (default), midnight, aurora, sunset, light.
<VoiceWidget agent="mara" preset="midnight" />Custom Theme
Override individual colors on top of any preset:
<VoiceWidget
agent="mara"
preset="dark"
theme={{
colorActive: "0, 200, 100",
colorWarning: "255, 80, 0",
ringColor: "100, 100, 200",
}}
/>All theme properties accept RGB triplets (e.g. "255, 160, 0") for use with CSS rgba().
Session Limits (via @pinecall/sdk)
Session limits are configured on the agent (server-side SDK) and flow through to the WebRTC widget automatically:
// Server-side (agent.js)
const agent = pc.deploy("my-agent", {
// ...voice, stt, llm config...
sessionLimits: {
idle_timeout_seconds: 20, // hang up after 20s of silence
idle_warning_seconds: 10, // warn 10s before timeout
max_duration_seconds: 600, // hard cap at 10 minutes
},
});
agent.on("session.idle_warning", (event, call) => {
call.say("Are you still there?");
});The widget receives session.idle_warning via DataChannel and:
- Switches the orb to the idle-warning state (blinking
colorWarning) - On
session.timeout, auto-disconnects and resets to idle
License
MIT
