@stackbilt/cf-voice
v0.1.0
Published
Cloudflare Workers AI voice primitives — STT, TTS, DO base class, and voice UI renderer
Readme
@stackbilt/cf-voice
Cloudflare Workers AI voice primitives. Drop a real-time voice interface into any Worker in ~30 lines.
What's included:
BaseVoiceAgent— Durable Object base class with Deepgram Flux STT + Workers AI TTS pre-wiredemailSummaryAction— detect "email me that summary" intent and trigger a side effectextractIds— pull session/canonical identity out of the opaque agents SDK connection contextrenderVoiceUI— parameterized orb UI (idle/listening/thinking/speaking/error states, transcript feed, toast notifications)- SQL migrations for voice session history and email event audit log
- Wrangler config guide
Stack: @cloudflare/voice · agents SDK · Workers AI (no external API keys)
Installation
npm install @stackbilt/cf-voicePeer dependencies:
npm install @cloudflare/voice agentsQuick start
1. Create your Durable Object
import { BaseVoiceAgent } from '@stackbilt/cf-voice';
import type { VoiceTurnContext } from '@stackbilt/cf-voice';
interface Env {
AI: Ai;
}
export class MyVoiceDO extends BaseVoiceAgent<Env> {
async handleTurn(transcript: string, ctx: VoiceTurnContext): Promise<string> {
// Return whatever text the agent should speak.
return `You said: ${transcript}`;
}
}2. Wire the route in your Worker
import { renderVoiceUI } from '@stackbilt/cf-voice';
import { routeAgentRequest } from 'agents';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (new URL(request.url).pathname === '/voice') {
return new Response(renderVoiceUI({ productName: 'My App' }), {
headers: { 'Content-Type': 'text/html' },
});
}
return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 });
},
};
export { MyVoiceDO };3. Add bindings to wrangler.toml
See wrangler-snippet.md for the full config block. The essentials:
[ai]
binding = "AI"
[durable_objects]
bindings = [{ name = "MyVoiceDO", class_name = "MyVoiceDO" }]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyVoiceDO"]That's it. Open /voice in a browser, tap the orb.
API
BaseVoiceAgent<TEnv>
Abstract Durable Object class. Extend it and implement handleTurn.
abstract class BaseVoiceAgent<TEnv extends VoiceEnv> {
// Override to change the TTS voice character. Default: 'asteria'
protected readonly speaker: string;
// Wire up VoiceActions to handle intents before handleTurn is called.
protected readonly actions: VoiceAction<TEnv>[];
// Last user utterance — available to actions and subclass methods.
lastQuestion: string;
// Last agent response — available to actions and subclass methods.
lastAnswer: string;
// Extract session + canonical IDs from a turn context.
ids(ctx: VoiceTurnContext): SessionIds;
// Implement this. Return the text the agent will speak.
abstract handleTurn(transcript: string, ctx: VoiceTurnContext): Promise<string>;
}VoiceEnv minimum:
interface VoiceEnv {
AI: { run(model: string, input: Record<string, unknown>): Promise<unknown> };
}emailSummaryAction(opts)
Built-in VoiceAction that detects "email me a summary of that" intent and calls your send function.
import { emailSummaryAction } from '@stackbilt/cf-voice';
export class MyVoiceDO extends BaseVoiceAgent<MyEnv> {
protected readonly actions = [
emailSummaryAction<MyEnv>({
send: async (env, { question, answer, sessionId, canonicalId }) => {
const ok = await myEmailService.send({ question, answer });
return ok ? { ok: true, sent: 1 } : { ok: false, reason: 'send failed' };
},
}),
];
async handleTurn(transcript, ctx) { /* ... */ }
}The action fires when the transcript matches phrases like "email me that", "send me a summary", "email this". It reads lastQuestion/lastAnswer from the agent instance and passes them to your send function. If nothing has been discussed yet, it speaks a prompt asking the user to ask a question first.
Options:
| Field | Type | Description |
|---|---|---|
| send | (env, payload) => Promise<{ ok: true; sent: number } \| { ok: false; reason: string }> | Called when intent is detected. |
| nothingYetMessage | string? | Spoken when lastQuestion is empty. |
renderVoiceUI(opts?)
Returns a complete HTML page as a string. Mount it on any route.
Key options:
| Option | Default | Description |
|---|---|---|
| agentDOName | 'VoiceSessionDO' | DO class name registered in wrangler.toml |
| productName | 'Voice' | Shown in the header |
| heading | 'Ask anything.' | Large heading on the page |
| subheading | 'Tap the circle to start talking.' | Subtext under the heading |
| examples | [] | Chips that send pre-set text: [{ label: 'Missed calls?', text: 'How many missed calls this week?' }] |
| accentColor | '#d97706' | Amber accent used for the orb, chips, and agent response bubbles |
| backHref / backLabel | '/' / '← Back' | Header back link |
| identityParam | 'canonical_id' | URL query param forwarded to the agent as identity context |
| showSignalChip | false | Fetches /signals/active and shows open issue count in the header |
| footerText | 'Powered by Stackbilt LLC' | Footer attribution |
The client loads VoiceClient from esm.sh/@cloudflare/[email protected]/client — no bundler needed.
extractIds(ctx)
Pulls sessionId and canonicalId from a VoiceTurnContext. The agents SDK doesn't expose these in a typed way; this function probes the known locations across runtime versions.
import { extractIds } from '@stackbilt/cf-voice';
const { sessionId, canonicalId } = extractIds(ctx);isEmailSummaryIntent(transcript)
The raw intent detector used by emailSummaryAction. Export it if you want to build custom action logic around the same heuristic.
import { isEmailSummaryIntent } from '@stackbilt/cf-voice';
isEmailSummaryIntent('can you email me a summary of that'); // trueVoice actions
A VoiceAction is checked before handleTurn. The first action whose detect() returns true handles the turn.
interface VoiceAction<TEnv> {
detect(transcript: string): boolean;
handle(
transcript: string,
ctx: VoiceTurnContext,
env: TEnv,
agent: { lastQuestion: string; lastAnswer: string; ids(ctx): SessionIds },
): Promise<string>;
}You can build your own and pass them in protected readonly actions = [...].
Database migrations
Optional. Copy from migrations/ if you want D1 audit records:
0001_voice_sessions.sql— one row per call (identity_id,started_at,ended_at,transcript)0002_voice_email_events.sql— one row per email send attempt with status, recipients, and provider message IDs
These are generic — identity_id is whatever identifier makes sense in your system (canonical_id, user_id, etc).
Reference consumer
See example-consumer.ts for how opsbrain's original 70-line VoiceSessionDO reduces to ~25 lines using this package.
Requirements
- Cloudflare Workers with
nodejs_compatflag - Workers AI binding (
[ai]) VoiceSessionDOregistered asnew_sqlite_classes(required by the agents SDK — seewrangler-snippet.md)@cloudflare/voice ^0.1.3andagents ^0.12.0as peer dependencies
