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

@loop-voice-agent/web

v0.4.1

Published

Browser SDK for the Loop Voice Agent platform — start a voice call from a publishable key and an agent id.

Readme

@loop-voice-agent/web

Browser SDK for the Loop Voice Agent platform. Start a voice call from a publishable key and an agent id — the prompt, model and voice never reach the browser.

Temporary package name. This is published under @loop-voice-agent/web while the product name is being decided. When it changes, a new package will be published and consumers update their import line; the API will not change as part of that rename.

Install

npm install @loop-voice-agent/web

ESM only. Modern bundlers (Vite, Next, Rollup, webpack 5) consume it directly.

Use

import { VoiceAgent } from "@loop-voice-agent/web";

const client = new VoiceAgent(import.meta.env.VITE_VOICE_PUBLIC_KEY, {
  baseUrl: "https://api.example.com",
});

client.on("call-start", () => setStatus("live"));
client.on("call-end", ({ endedReason }) => setStatus(`ended: ${endedReason}`));
client.on("message", (m) => {
  if (m.type === "transcript" && m.transcriptType === "final") append(m.role, m.transcript);
});
client.on("error", ({ code, message }) => showError(code, message));

await client.start(agentId, {
  variables: { candidate_name: "Asha", role: "SDE" },
  language: "hi",
  channel: "audio",
  metadata: { applicant_id: "A-123" },
});

// …later
client.setMuted(true);
client.stop();

The agent is audible with no extra wiring — the SDK creates one hidden <audio> element when the agent's track arrives and removes it when the call ends.

Driving playback yourself

Pass autoPlayAudio: false and the SDK touches no DOM at all: nothing is created, nothing is appended, nothing is played. Use it for a Web Audio graph, a visualiser or per-agent volume control.

const client = new VoiceAgent(key, { baseUrl, autoPlayAudio: false });
client.on("call-start", () => {
  audioEl.srcObject = client.getRemoteStream();
});

Upgrading from 0.3.x — two changes to be aware of:

  • Playback used to be the integrator's job. If your app already attaches getRemoteStream() to an element of its own, either delete that code or set autoPlayAudio: false; leaving both in place plays the agent twice.
  • getRemoteStream() now returns ONE stream the SDK owns, carrying every remote track. It used to return whichever stream the browser reported last, which on a video call was the avatar — video only, no audio in it.

0.4.1 reverts one part of 0.4.0: the encoder is no longer told to hold resolution at all costs. Measured on real calls that instruction cost the frame rate — camera 11.0 → 1.8 fps, shared screen 3.2 → 1.3 fps — and the pinned full-resolution encode starved the audio path as well. The bitrate ceiling stays raised; the trade-off goes back to the browser.

Credentials

Two key classes exist, and only one belongs in a browser:

| Key | Where it belongs | Authority | | ------- | ---------------- | ----------------------------------------------- | | vpk_… | Browser bundle | Start a call — and only from registered origins | | vak_… | Your server only | Create agents, place PSTN calls, read history |

A publishable key is safe to ship because its authority is capped and it only works from origins registered against it. Register your app's origin on the key, or every call fails with a 403 — that specific failure means the origin is missing, not that the key is wrong.

Events

| Event | Payload | When | | -------------- | --------------------------- | ----------------------------------------- | | call-start | — | Media is flowing | | call-end | { endedReason, callId? } | The call ended, cleanly or otherwise | | speech-start | — | The agent started speaking | | speech-end | — | The agent stopped speaking | | message | TranscriptMessage \| … | A message from the server, usually a turn | | error | { code, message, cause? } | The call could not start |

error and call-end are mutually exclusive for a single failure: a call that never started emits error, and one that started and then ended emits call-end. You never have to de-duplicate the two.

Classify an ending with the exported helper rather than by matching strings yourself:

import { isAbnormalEndedReason } from "@loop-voice-agent/web";

client.on("call-end", ({ endedReason }) => {
  if (isAbnormalEndedReason(endedReason)) showRetry();
  else showComplete();
});

Screen sharing

Pass an already-captured stream — the SDK never calls getDisplayMedia itself, because browsers only grant it from a user gesture and capturing inside the SDK would raise a second picker mid-call:

const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
await client.start(agentId, { screenStream });

Prefer passing it to start() when you have it up front: the track goes into the initial offer and skips a renegotiation round trip while the user waits. When the stream only arrives mid-call:

await client.startScreenShare(screenStream);

Networking

start() performs both hops for you:

  1. POST {baseUrl}/v1/calls/web with the publishable key → session bundle
  2. WebRTC offer → POST {voiceWorkerUrl}/v1/calls/web/sessions → SDP answer

Behind symmetric NAT you need ICE servers; without them only host candidates are tried and the call fails with pipeline-error-connection-failed:

new VoiceAgent(key, {
  baseUrl,
  iceServers: [{ urls: "stun:stun.example.com:3478" }],
});

API

  • new VoiceAgent(publicKey, options?) — baseUrl, iceServers, connectTimeoutMs, autoPlayAudio (default true), fetch, getUserMedia
  • start(agentId, context?) — variables, language, channel, metadata
  • stop() · setMuted(bool) · isMuted() · destroy()
  • startScreenShare(stream) · isScreenSharing()
  • getStatus() · getCallId() · getLocalStream() · getRemoteStream()
  • on(event, handler) · once(...) · off(...) — on returns an unsubscribe
  • createRemoteAudioSink(document?) — the element the SDK would have made, for callers who set autoPlayAudio: false but still want it

License

MIT