@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 ◀── directiveContents
- Prerequisites
- Install
- Configure
- Usage — Voice
- Usage — SMS
- Session routing (
sessionKey) - Connect / auth / reconnect
- Troubleshooting
- For maintainers
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
connectionIdandmanualSecret— or ansk_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
connectionIdandmanualSecretfrom the dashboard (or skip both and use ansk_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.
sk_-key auto-discovery (recommended). Hand it a Saperlysk_API key; it binds every manual line the key is scoped to (a single-line key → one line, a workspace key → all). SetapiKey/SAPERLY_API_KEY.- Explicit many. List the lines:
connections: [{ connectionId, manualSecret }, …]. - 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 routingbaseUrl 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 arequest_idyour reply echoes so the right call gets the directive. - Auth. The connection's
manualSecret, presented as the websocket subprotocolSec-WebSocket-Protocol: bearer.<secret>(the browser-compatible escape hatch). Under Node'sws, a plainAuthorization: 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-sendshello. 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
errorframe is logged so the next reply can be corrected.
Troubleshooting
- Not connecting / agent never answers. Check
baseUrl(no trailing/v2; it derivesws/wss), theconnectionId/manualSecretpair (or thesk_key), and that the line is in manual mode — a non-manual connection has nomanualSecretand the upgrade fails closed. dropping undecodable framein 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-protocolis 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 ownbun.lock, not in the monorepo workspace), so it depends on the package byfile:path ("@saperly/voice-protocol": "file:../../packages/voice-protocol") for local dev and tests. It is bundled intodist/index.jsat build time (along witheffect), so it does not need to be published separately — both aredevDependencieshere, and the published tarball is self-contained.- Bundle model.
bun run buildruns tsdown (tsdown.config.ts): it bundles the entry +@saperly/voice-protocol+effectinto ESMdist/index.jsand emitsdist/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 offapi. - Dual entry (
extensionsvsruntimeExtensions).package.json'sopenclawblock declares bothextensions: ["./src/index.ts"](the source entry a workspace/git-checkout gateway loads directly) andruntimeExtensions: ["./dist/index.js"](the prebuilt JS an installed npm package loads, so the gateway never compiles TypeScript at runtime).filesshipsdist+openclaw.plugin.json+README.md;prepublishOnlyrebuildsdistbefore pack. - Publish. From
connectors/openclaw:npm pack --dry-runto inspect the tarball, thennpm publish(the package is public;prepublishOnlyruns the build). Bumpversionfirst.@saperly/voice-protocoldoes not need publishing — it's bundled. - Registry caches
configSchema. After editingopenclaw.plugin.json, runopenclaw plugins registry --refreshor the gateway boots on the stale schema.
