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

@trysaperly/voice-openclaw

v0.1.0

Published

openclaw voice channel: bridges a Saperly manual-mode websocket to an openclaw agent so the agent is phone-reachable in its own context (its tools + memory), with Telnyx doing STT/TTS — media never touches this process.

Readme

@trysaperly/voice-openclaw — give your openclaw agent a phone number

Give your openclaw agent a real phone number. People call it or text it, and your agent answers — in its own context, with its tools and its memory. The caller speaks; the transcribed turn is delivered to your agent as input; the agent's reply is spoken back over the phone. A text gets a text back.

Media never touches your machine or Saperly. Telnyx holds the audio and does all the speech-to-text and text-to-speech in its own network; this connector only moves text in and directives out over one websocket. That is the whole point — and it is why this is not the openclaw voice-call plugin (that one streams raw call audio into its own gateway; this one never sees a single audio frame).

   ☎ caller ──speech──▶ Telnyx (STT, in-network) ──▶ Saperly
                                                       │  one text websocket
                                                       ▼
                                       @trysaperly/voice-openclaw (this connector)
                                                       │  runs YOUR agent per turn
                                                       ▼
                                       YOUR openclaw agent (tools + memory)
                                                       │  spoken reply / directive
                                                       ▼
   ☎ caller ◀──speech── Telnyx (TTS, in-network) ◀── Saperly ◀── directive

Contents

Prerequisites

  • An openclaw gateway you already run (Node ≥ 22, openclaw ≥ 2026.6.8). This connector installs into it as a plugin.

  • A Saperly line in manual mode, plus its connectionId and manualSecretor an sk_ API key (which auto-discovers your manual lines for you).

    Set the line to manual mode in the Saperly dashboard (Connections → set mode to manual), or via the API:

    curl -X PATCH https://api.saperly.com/connections/{id} \
      -H "authorization: Bearer sk_…" \
      -H "content-type: application/json" \
      -d '{ "mode": "manual" }'

    Saperly wires up the Telnyx side automatically — you don't provision anything on Telnyx. Once it's in manual mode, grab the connection's connectionId and manualSecret from the dashboard (or skip both and use an sk_ key).

Install

Install the plugin into your gateway from npm, then enable it:

openclaw plugins install npm:@trysaperly/voice-openclaw
openclaw plugins enable saperly-voice

(openclaw plugins install also accepts clawhub:@trysaperly/voice-openclaw or git:github.com/<owner>/<repo>@<ref> if you host it elsewhere. The plugin id is saperly-voice; the npm package is @trysaperly/voice-openclaw.)

Then allow + configure it in your openclaw config (openclaw.json5):

{
  plugins: {
    enabled: true,
    allow: ["saperly-voice"],
    entries: {
      "saperly-voice": { enabled: true, config: { /* see Configure */ } },
    },
  },
}

On load it registers the saperly_voice_reply tool and opens one websocket per bound line. The published package ships prebuilt JS (dist/index.js, declared as openclaw.runtimeExtensions), so the gateway loads it without compiling TypeScript or installing any plugin dependencies — @saperly/voice-protocol and effect are bundled in.

After changing openclaw.plugin.json: the plugin registry caches the config schema. If you add or change a config field (e.g. sessionKey), refresh it or the gateway rejects the "new" key: openclaw plugins registry --refresh.

Monorepo / local development. Working from a checkout of the Saperly repo instead of an npm install? The package also declares "openclaw": { "extensions": ["./src/index.ts"] }, so a workspace/git-checkout gateway can load the channel straight from source (no build step). See For maintainers.

Configure

You give the connector either an sk_ key (it discovers your lines) or one-or-more explicit connectionId + manualSecret pairs. There are three modes; in all of them environment variables win over the config file, so you can keep secrets out of openclaw.json5.

  1. sk_-key auto-discovery (recommended). Hand it a Saperly sk_ API key; it binds every manual line the key is scoped to (a single-line key → one line, a workspace key → all). Set apiKey / SAPERLY_API_KEY.
  2. Explicit many. List the lines: connections: [{ connectionId, manualSecret }, …].
  3. Explicit one. A single connectionId + manualSecret.

Config-file form (plugins.entries.saperly-voice.config):

{
  "saperly-voice": {
    enabled: true,
    config: {
      baseUrl: "https://api.saperly.com", // no trailing /v2; ws/wss is derived
      // (1) auto-discovery — prefer the env var so the key stays out of the file:
      // apiKey: "sk_…",
      // (3) or bind one specific line:
      connectionId: "conn_123",
      // manualSecret: "mc_…",        // prefer SAPERLY_MANUAL_SECRET
      client: "saperly-voice",        // optional label for the wide-event trail
      // sessionKey: "saperly-voice:peer:{peer}", // optional — see Session routing
    },
  },
}

Equivalent environment variables (these override the file):

SAPERLY_BASE_URL=https://api.saperly.com
SAPERLY_API_KEY=sk_live_…            # (1) auto-discovery — OR the pair below:
SAPERLY_CONNECTION_ID=conn_123       # (3) explicit single line
SAPERLY_MANUAL_SECRET=mc_abc123
SAPERLY_CLIENT=saperly-voice        # optional label
SAPERLY_SESSION_KEY=saperly-voice:peer:{peer}   # optional — see Session routing

baseUrl normalization. Paste your dashboard URL verbatim. https://…wss://…, http://localhost:8787 (or http://host.docker.internal:8787) → ws://…, and a bare host defaults to wss. The websocket URL is derived as <base→ws(s)>/v2/manual/{connectionId}/ws.

Which agent runs? By default the channel's main agent answers, on its configured default model. Override per channel with agentId, provider, and model in the plugin config.

Usage — Voice

Dial the number. Your agent gets the opening turn (it greets the caller), then each thing the caller says arrives as a fresh turn on the same session for that call, so the conversation has continuity — its tools and memory are all available.

The agent's reply is spoken back via Telnyx TTS. To do more than speak, the agent calls the saperly_voice_reply tool (always echo the turn's request_id):

| kind | Effect | | ------------- | ---------------------------------------------------------------- | | speak | Say text via TTS, then keep listening. | | speak + end_call: true | Say a final line, then hang up. | | wait | Listen without speaking (optional timeout_ms). | | hangup | End the call (optional reason). | | transfer | Forward the call to to (E.164 or SIP URI). | | send_dtmf | Play digits. | | reject | Decline an inbound call before it's answered. |

Invalid args (e.g. speak with no text, an unknown kind) are rejected with a correctable message and never reach the live call. Answer within ~18 s or the caller hears a short hold line (Saperly falls back at ~20 s; the connector pre-empts it). A call_ended event closes the turn out and clears any pending work for that call.

The agent can also place an outbound call: address the target as saperly-voice:+15551234567 and Saperly originates the call, routing the answered leg back to your agent over the same socket.

Usage — SMS

The same number receives texts. An inbound SMS is delivered to your agent as a text turn, and the agent's reply is sent back as an outbound SMS — there is no call leg, so only the reply text matters (call-control directives are a no-op for SMS).

Carrier reality: US long-code numbers do not receive international inbound SMS. To test texting, send from a US number.

Session routing (sessionKey)

By default, each conversation gets its own openclaw session — one call is one session, one SMS thread is another. You control this with the optional sessionKey template (sessionKey in plugin config, or SAPERLY_SESSION_KEY). Placeholders the connector fills:

| Placeholder | Filled with | | ------------------ | ---------------------------------------------------------------- | | {conversationId} | the call's id (voice) or the SMS thread key (sms:{numberId}:{peer}) | | {peer} | the other party's number (the caller / texter) | | {line} | your line's number | | {connectionId} | the bound connection id |

| sessionKey | Routing | | ----------------------------- | ----------------------------------------------------------- | | (unset — default) | saperly-voice:{conversationId} — every conversation isolated | | saperly-voice:main | one shared "main chat" — everything lands in one session | | saperly-voice:peer:{peer} | one session per person — their calls + texts share memory | | saperly-voice:line:{line} | one session per line |

Trade-off. A fixed or shared key (…:main, or …:line:{line} on a busy line) merges every caller into one context and interleaves concurrent turns — perfect for a single personal agent, wrong for a multi-tenant deployment. There, prefer the default (per-conversation) or …:peer:{peer}.

Concurrent calls. An inbound call is admitted by a brief, fail-closed accept gate (~8s): the agent must answer the inbound_call (a speak opener or wait) before the line is picked up, or the call is declined. With the default (per-conversation) key, each call runs in its own session, so simultaneous calls are admitted independently. A shared/fixed key routes every call through one session — if your runtime serializes same-session runs, a second call arriving while the first is mid-turn can miss the accept window and be declined. For a line that takes overlapping calls, keep the default or use …:peer:{peer}.

In explicit-many mode you can give one line its own routing: add a per-entry sessionKey inside its connections[] object, and it overrides the top-level one.

An absent {peer}/{line} falls back to the conversation id, so a missing value always splits sessions rather than silently merging two distinct conversations into one. (For voice, {peer}/{line} are remembered from the opening call frame and reused for every later turn of that call.)

Connect / auth / reconnect

  • Connect. The connector dials out to GET <baseUrl>/v2/manual/<connectionId>/ws (it needs no public URL of its own). One long-lived socket per bound line multiplexes every live call and text; each turn carries a request_id your reply echoes so the right call gets the directive.
  • Auth. The connection's manualSecret, presented as the websocket subprotocol Sec-WebSocket-Protocol: bearer.<secret> (the browser-compatible escape hatch). Under Node's ws, a plain Authorization: Bearer <secret> header also works.
  • Reconnect. The socket is long-lived. On any drop — including 1012 (a newer socket superseded this one) — it reconnects with jittered backoff (1 s → 30 s) and re-sends hello. In-flight turns are released so nothing leaks.
  • Bad frames. Every inbound frame is schema-decoded; an undecodable one is dropped (never crashes the connector), and an advisory error frame is logged so the next reply can be corrected.

Troubleshooting

  • Not connecting / agent never answers. Check baseUrl (no trailing /v2; it derives ws/wss), the connectionId/manualSecret pair (or the sk_ key), and that the line is in manual mode — a non-manual connection has no manualSecret and the upgrade fails closed.
  • dropping undecodable frame in the logs. The connector bundle is older than Saperly's current frame protocol. Rebuild it (see For maintainers) — a stale bundle silently drops frames it can't decode.
  • Socket closes with 1012. A newer connection superseded this one (e.g. you started a second instance on the same line). The connector re-binds automatically; if it flaps, make sure only one instance binds each line.
  • No inbound texts. US long codes don't receive international SMS — text from a US number to test (see Usage — SMS).

For maintainers

cd connectors/openclaw
bun install
bun run typecheck     # tsc --noEmit, strict (exactOptionalPropertyTypes, no any)
bun test              # protocol / bridge / config / channel unit tests
bun run build         # tsdown → dist/index.js + dist/index.d.ts (prebuilt runtime entry)
  • @saperly/voice-protocol is the shared websocket frame contract (the same Effect Schema frames + directive validation Saperly's server uses — one source of truth, no mirror to drift). This connector is standalone (its own bun.lock, not in the monorepo workspace), so it depends on the package by file: path ("@saperly/voice-protocol": "file:../../packages/voice-protocol") for local dev and tests. It is bundled into dist/index.js at build time (along with effect), so it does not need to be published separately — both are devDependencies here, and the published tarball is self-contained.
  • Bundle model. bun run build runs tsdown (tsdown.config.ts): it bundles the entry + @saperly/voice-protocol + effect into ESM dist/index.js and emits dist/index.d.ts, keeping only the host-supplied openclaw SDK (openclaw/plugin-sdk/*) external. Ambient type shims (src/openclaw-sdk.d.ts) let the connector type-check standalone; the rest of the host API is read structurally off api.
  • Dual entry (extensions vs runtimeExtensions). package.json's openclaw block declares both extensions: ["./src/index.ts"] (the source entry a workspace/git-checkout gateway loads directly) and runtimeExtensions: ["./dist/index.js"] (the prebuilt JS an installed npm package loads, so the gateway never compiles TypeScript at runtime). files ships dist + openclaw.plugin.json + README.md; prepublishOnly rebuilds dist before pack.
  • Publish. From connectors/openclaw: npm pack --dry-run to inspect the tarball, then npm publish (the package is public; prepublishOnly runs the build). Bump version first. @saperly/voice-protocol does not need publishing — it's bundled.
  • Registry caches configSchema. After editing openclaw.plugin.json, run openclaw plugins registry --refresh or the gateway boots on the stale schema.