npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pinecall/web

v0.6.0

Published

Pinecall web client — WebRTC voice, text chat, and React widgets for Pinecall agents

Readme

@pinecall/web

npm

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-dom

Entry 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, /modal and /chatbox entries are native Custom Elements — they work in any framework (React, Vue, Svelte, Angular, vanilla) and need no React. The original @pinecall/web React 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>

Orbopens 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.json

Development

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 publish

Watching 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() is sse → poll. transport: "auto" opens GET /v1/calls/{id}/events (or /v1/agents/{slug}/calls) with Accept: text/event-stream — fetch + ReadableStream + a small SSE decoder, not EventSource — and degrades to polling only if the stream never delivers. transport: "sse" / "poll" pin a pipe. observation.kind says which one is carrying entries.
  • ws only on request. transport: "ws" attaches over WS /v1/attach; it is the pipe for the supervise verbs (say, whisper, takeover, release, end, transfer, via send({verb: …}) with a supervise token). send() returns false on sse and poll. Observation never opens a WebSocket on its own.
  • Reconnect is free. Every (re)open carries after=<view.lastSeq> — the SSE pipe too, it never sends Last-Event-ID — and the view dedupes by seq, so replay overlap is harmless and a dropped pipe loses nothing. Backoff is min(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 — : ping on SSE, {"type":"ping"} on WS — and trips at clamp(3×cadence, 6 s, 30 s); a number is a fixed window; 0 is off) turns a half-open pipe into a reconnect.
  • Ended calls stop the pipe. The SSE body ends after call.summary (a sealed cursor answers 204), the WS honours the reducer's {kind:"disconnect"} intent, polling stops on live:false. onFinish fires exactly once with "summary" (clean end), "closed" (you called close()) or "error" (gave up: reconnect:false and the pipe died, or a 401/403/404).
  • types / durable are server-side filters, threaded into every pipe's URL; seq stays intact and log.gap, log.caught_up, call.ended, call.summary always 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/log byte for byte so this package gains no runtime dependency — see src/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.timeout fires (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:

  1. Switches the orb to the idle-warning state (blinking colorWarning)
  2. On session.timeout, auto-disconnects and resets to idle

License

MIT