Voice
Governed server-side voice sessions and the application work required before a phone pilot.
Use session.voice() for application voice. It wraps OpenAI Realtime or an STT/LLM/TTS pipeline with
server-owned instructions, registered tools, session capability policy, authorization checks, redaction
hooks, bounded execution and durable usage/tool journals. provider.connect() remains a low-level
transport without session governance. Your application supplies telephony and caller verification.
Governed server session
Create the agent/session with the authenticated tenant and actor and a shared atomic SessionStore.
Use durable storage in production; the in-memory store is for tests. Resolve call and resource IDs
from trusted server state so a browser can't choose another caller's case or forge a call binding.
const voice = await session.voice({
provider,
callId: verifiedCall.id,
resourceId: verifiedCall.caseId,
instructions: 'Assist with the verified case. Never request SSNs or bank credentials in speech.',
tools: [lookupCase, requestSecureUpload],
limits: { durationMs: 300_000, maxTurns: 30, maxToolCalls: 12 },
costLimit: { perSession: 2, perCall: 0.25, perScope: 50, scopeKey: 'voice:2026-09-23' },
authorize: operation => authorizeCall(verifiedCall, operation),
prepareToolInput: (name, input) => validateAndRedactArguments(name, input),
toolOperationKey: (name, input) => resolveLogicalActionId(verifiedCall, name, input),
prepareToolOutput: (name, output) => modelSafeReceipt(name, output),
redactTranscript: text => redactSensitiveText(text),
onAudit: event => auditSink.write(event),
signal: requestSignal,
});
try {
for await (const event of voice.events()) {
if (event.type === 'audio_delta') await transport.play(event.audio);
if (event.type === 'speech_started') await transport.clearPlayback();
if (event.type === 'transcript') await transport.caption(event.text);
if (event.type === 'error') break;
}
} finally {
await voice.close();
}You'll supply these callbacks, including identity checks and PII detection. Input preparation must
validate the selected tool's schema and remove sensitive values before policy, approval or history
sees them. Output preparation runs after tool envelopes are unwrapped and before history/model exposure.
Use opaque references and safe receipts. Mutating tools must call governed Platform actions and use
context.toolCallId as the idempotency key. Don't use dynamic state writes or durable substeps from voice tools.
toolOperationKey must resolve a trusted logical action identity that stays stable across provider
reconnects. Harness hashes it with the call binding and tool name. Provider-generated call IDs change
on reconnect and must not be used for this callback. A genuinely new action needs a distinct key;
reusing a key with changed arguments fails closed. Keys must be nonempty and at most 512 characters.
The governor doesn't emit tool calls to clients and rejects client tool results; when the provider requests an action, it rechecks authority before execution, fences replaced connections, and retains only the application's safe completed result so a reconnect can replay that receipt without invoking the external action a second time.
Audio-frame authority and ownership checks are coalesced and cached for at most one second to avoid full durable-store reads for every 20 ms frame. Tools and text inputs always check immediately. Partial provider transcript chunks are discarded without durable-store reads. For instant disconnect on revocation, abort the trusted call signal. The bridge bounds queued input to 128 messages or 1 MiB and closes on overload. A started tool without a committed result requires application reconciliation; it isn't automatically retried, and creating a new call ID would bypass the original receipt instead of resolving it.
Only complete, application-redacted transcripts are forwarded; partial text is suppressed because PII can cross chunk boundaries. Harness doesn't persist raw audio. Providers still receive audio under their own data-handling terms. Secure sensitive-data handoff must bypass speech.
Providers and runtimes
Install ws in the server host: pnpm add ws. Provider WebSockets load this optional peer for
authorization headers. Loading the core SDK doesn't connect or require credentials. Never put
provider keys in a browser. Supplied WebSocket providers target Node 22+; Cloudflare Workers need a
compatible provider adapter or an authenticated Node voice sidecar.
import { OpenAIRealtimeVoiceProvider } from '@fabric-harness/sdk';
const provider = new OpenAIRealtimeVoiceProvider({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-realtime',
});The adapter uses the GA audio/session schema and response.output_audio.* events without the retired
beta header. PCM16 is 24 kHz mono; G.711 μ-law/A-law uses 8 kHz. Your transport must match the codec.
See OpenAI WebSockets.
For pipeline mode, compose PipelineVoiceProvider with Deepgram or Cartesia STT, a text model, and
ElevenLabs or Cartesia TTS; pass it to the same session.voice() interface.
See Voice Providers. ElevenLabs rejects unsupported G.711 A-law.
For budgeted pipeline barge-in, configure PipelineVoiceProvider({ interruptedLlmCostUsd, ... })
with a conservative per-request estimate for your model/context limits. When cancellation prevents
the LLM from reporting usage, this explicit estimate is charged so the conversation can continue;
without it, unknown usage stops the call for reconciliation. TTS estimates count submitted characters,
including interrupted synthesis, rather than implying that unplayed audio was free. Reconcile these
estimates with provider billing. OpenAI Realtime rejects sample rates that don't match its codec.
Cost and recovery
Every session requires a perSession ceiling, while perCall limits each individual model response.
Optional perScope and scopeKey aggregate voice spend across sessions within a tenant using atomic idempotent ledgers
in the session store. These are separate from text-loop cost stores. Reconnect repairs journal-to-ledger
updates and retains the original call deadline and turn/tool counts.
These estimated post-response limits can overshoot while a response is in flight, especially when several calls share a budget. Use provider billing caps and application admission/concurrency limits for financial hard stops. Telephony, recording, storage and verification costs aren't included.
Realtime text/audio counts are disjoint, including modality-specific cached input. Pipeline usage
charges each STT delta once, the LLM response and that response's TTS characters. Register negotiated
rates with registerModelPrices. Unknown costs stop a governed session. Interrupted input without a
billed response blocks same-call reconnect until application reconciliation. Disconnects cannot
guarantee final provider billing, so you'll need reconciliation against provider usage/invoices.
Audit metadata and safe tool receipts are durable. onAudit delivery is awaited and failures stop
processing; external audit delivery isn't exactly-once. Apply tenant access and retention controls
to voice: and voice-cost: records. Store loss removes replay/budget protection. Connections don't
survive process restart. Open voice in a server or activity, never deterministic workflow code.
Node endpoint and migration
WS /sessions/:id/voice is disabled unless startDevServer({ connectVoice }) is configured. The
callback receives authenticated principal, tenantId, sessionId and signal; resolve the authorized
application binding and return session.voice(...). The route requires voice:invoke and an existing
tenant-owned session. session:read alone is insufficient.
const handle = connectFabricVoice({
url: `wss://app.example.com/sessions/${sessionId}/voice`,
authToken: shortLivedVoiceToken,
tenantId,
onEvent(event) {
if (event.type === 'audio') speaker.write(event.audio);
if (event.type === 'speech_started') speaker.clear();
},
});
mic.on('frame', frame => handle.sendAudio(frame));Move model, voice, codec and instructions into the trusted server callback. Browser configuration
fields are deprecated and ignored; query parameters cannot configure the provider. Remove client
submitToolResult() calls. Install ws, grant voice:invoke and configure storage, limits, identity,
validators and redactors. Use short-lived query tokens with log redaction, or authenticated same-origin
cookies. fh add can scaffold a telephony bridge; it does not certify its security.
Custom implementations of the public FabricSession interface must add voice(options), returning
a governed session or rejecting with an unsupported-runtime error. Existing text-agent authoring
and the finite/persistent builders are unchanged.
AutoRefi readiness
AutoRefi can build against the governed interface. Before a phone pilot it still needs:
- Signed telephony ingress, matching codecs and playback interruption.
- OTP identity, caller-to-case authorization, consent and recording disclosure.
- Transcript redaction, secure sensitive-data handoff and retention rules.
- Accessible captions/alternative channels, human transfer/takeover and outage recovery.
- Idempotent Platform tools and reconciliation of uncertain effects/costs.
- A credentialed staging canary covering barge-in, disconnect, denied access and exhausted budgets.
Start with the credential-free governed smoke example in examples/voice-data-collector.
You'll still need a real-phone staging canary before certifying a production rollout.