A Glove voice agent is one agent or two, and the part that talks is either a cascaded pipeline (STT → text model → TTS) or a speech-to-speech realtime model. That gives four shapes. This page compares them, explains why you would split an agent in two, and shows the wiring for both layered shapes, including <speech> tags and fast front models.
GloveVoice (glove-voice) or useGloveVoice (glove-react/voice). See Voice pipeline.RealtimeAgent + s2sDrivenModel (glove-voice-s2s). See Realtime voice.| Single-agent pipeline | Single-agent realtime | Layered, realtime front | Layered, pipeline front | |
|---|---|---|---|---|
| Latency to first sound | Cascade: about 1.3–1.6 s. Text reaches TTS a sentence at a time; a tool call before any text adds its full duration | 500–800 ms for S2S APIs. A slow tool is dead air: the provider waits for its result | Front at S2S speed; the worker's answer is a second spoken turn later | 0.9–1.3 s typical (layered-voice); ~1.5 s on a live server-voice room. Delegated answers arrive in a relay turn |
| Voice quality and control | Any TTSAdapter (ElevenLabs built in). Everything the model writes is spoken | The provider's voices (marin on OpenAI). Words and audio come from one model | Same as single-agent realtime | Any TTS. <speech> tags decide exactly what is spoken |
| Models | Any glove-core model, which must be fast and capable at once | S2S providers: openai, openai-webrtc, openai-live, gemini | Front: an S2S provider. Worker: any glove-core model | Front: a fast streaming text model. Worker: any heavy model |
| Tool-heavy work | Runs inside the spoken turn; the caller waits | Runs through Tool.run with no Executor: requiresPermission is not enforced, pushAndWait throws | Delegated; the front keeps talking; the result is injected with rt.inject(…, { respond: true }) | Delegated; the front's turn ends after a spoken acknowledgement; a relay turn speaks the result |
| Interruptions | Built in: VAD barge-in calls interrupt() | Provider-native; the OpenAI WebSocket adapter truncates context to what was heard | Provider-native; the worker keeps running | You build it; the front is told what was heard via <user-interruption> |
| Cost and complexity | Least code: one hook | Little code; the whole call runs on a realtime model | Two agents, a mesh and inject; the heavy model runs only for delegated work | Most code (STT, endpointing, TTS streaming, a parser, a relay queue); a cheap front on every turn |
| Reference example | coffee, lola | s2s-agent | s2s-rooms, layered-voice /s2s, foundry-braind-storm | layered-voice, server-voice |
Latency figures are the examples' own claims and measurements; see Latency budgets.
One model cannot be both fast enough to hold a conversation and capable enough for multi-step tool work. From Building the voice stack: realtime models “are tuned for conversational latency, not for reasoning over a database, and the moment one is asked to do both it either stalls mid-sentence or invents an answer confidently.” Text models that reason well think for seconds before their first token, which is dead air on a call. So split the jobs:
in_reply_to set and never just acknowledges: an acknowledgement would resolve the front's pending item before any answer exists.| Step | Pipeline front (layered-voice) | Realtime front, mesh (s2s-rooms) | Realtime front, tool (layered-voice /s2s) |
|---|---|---|---|
| Dispatch | The front calls glove_mesh_send_message({ to: "worker", blocking: true, content }) | The same tool, called by the S2S model through RealtimeAgent | The model calls a delegate_to_worker function tool; the page POSTs { request } to /api/s2s/delegate |
blocking: true | Adds a pending mesh:waiting:<message id> item to the front's inbox and returns at once. It does not wait for the worker | No mesh. The function call stays open until the worker returns | |
| Acknowledgement | A <speech> ack in the same turn; then the front's turn ends | Spoken “in the same breath” as the tool call | Spoken while the call is open |
| Worker run | The orchestrator runs worker.processRequest(…) on its own queue for new mesh:from:* items in the worker's inbox | A research station signal in its own process | The route runs worker.processRequest(…) and returns the worker's final message text |
| Result | The reply resolves the front's pending item and lands as a resolved inbox item; a relay turn framed <worker-result> speaks it, with the findings injected as [Inbox: N item(s) resolved] | The reply POSTs to the room's /mesh; the room calls rt.inject(`<worker-result>…</worker-result>`, { respond: true }) | adapter.sendToolResult(callId, result); the model speaks it |
| While waiting | Glove adds [Inbox: N blocking item(s) still pending …] to each front turn, so the front says it is still checking | RealtimeAgent does not run Glove's loop, so there is no reminder; a prompt rule does that job | The open tool call |
The S2S model is the front agent: persona, turn-taking, barge-in and voice in one model. The front is a plain Glove whose model slot carries the realtime config, and RealtimeAgent exposes its tools, including the mesh send that mountMesh folds on.
In examples/s2s-rooms, buildS2SFrontAgent (lib/s2s-front-agent.ts) builds the front with s2sDrivenModel({ label: "s2s-front", ...s2s }). The room signal calls mountMesh(front, { adapter, identity }), starts new RealtimeAgent({ agent: front, excludeTools: ["glove_mesh_broadcast", "glove_mesh_acknowledge"] }), and when the worker's reply reaches its /mesh endpoint calls rt.inject(`<worker-result>${message.content}</worker-result>`, { respond: true }). There the worker runs as a separate research signal. The same wiring with both agents in one process (agents.ts is in Fast models for the front):
import { mountMesh, MeshNetwork, InMemoryMeshAdapter } from "glove-mesh";
import { RealtimeAgent, s2sDrivenModel } from "glove-voice-s2s";
import { buildAgent, workerModel } from "./agents";
const FRONT_PROMPT = `You are Nova. You cannot look anything up. For any lookup, call glove_mesh_send_message
(to: "worker", blocking: true, content: the request, standing on its own) and in the same breath say a
short acknowledgement. The tool returns as soon as the request is dispatched; that does NOT mean the
answer is ready. Relay <worker-result> messages out loud. Never invent a number.`;
const WORKER_PROMPT = `Research requests from "front" with your tools, then reply ONCE with
glove_mesh_send_message (to "front", in_reply_to the message id). Never just acknowledge.`;
export async function startRealtimeFront(io: {
play: (pcm: Int16Array, sampleRate: number) => void; // app-owned: the caller's speaker
clearPlayback: () => void; // app-owned: drop queued audio
}): Promise<RealtimeAgent> {
const network = new MeshNetwork();
const frontMesh = new InMemoryMeshAdapter(network, "front");
const workerMesh = new InMemoryMeshAdapter(network, "worker");
// The front's model slot carries the realtime config. Fold your real tools onto the worker.
const front = buildAgent("front", s2sDrivenModel({ label: "s2s-front", provider: "openai", voice: "marin" }), FRONT_PROMPT);
const worker = buildAgent("worker", workerModel(), WORKER_PROMPT);
await mountMesh(front, { adapter: frontMesh, identity: { id: "front", name: "Voice Front", description: "Realtime voice." } });
await mountMesh(worker, { adapter: workerMesh, identity: { id: "worker", name: "Worker", description: "Full tool surface." } });
const rt = new RealtimeAgent({ agent: front, excludeTools: ["glove_mesh_broadcast", "glove_mesh_acknowledge"] });
// A delegation reached the worker: research in the background. Never await it here.
let queue: Promise<void> = Promise.resolve();
workerMesh.subscribe(async (msg) => {
if (msg.kind !== "direct" || msg.from !== "front") return;
queue = queue
.then(() => worker.processRequest("Handle the new request(s) in your inbox, then reply."))
.then(() => {}, () => {});
});
// The worker's threaded reply: speak it in the live call.
frontMesh.subscribe(async (msg) => {
if (msg.kind === "direct" && msg.from === "worker") {
rt.inject(`<worker-result>${msg.content}</worker-result>`, { respond: true });
}
});
rt.adapter.on("audio", (pcm, format) => io.play(pcm, format.sampleRate));
rt.adapter.on("interrupted", () => io.clearPlayback()); // provider-native barge-in
await rt.start();
return rt; // feed caller audio with rt.sendAudio(pcm) at rt.adapter.inputFormat.sampleRate
}Never await the worker inside a mesh handler. In-memory delivery awaits each subscriber in turn, so the front's tool call, and the caller, would wait for the whole research run. Also note that the realtime path never runs Glove's loop, so the reply's inbox item is not injected for you: inject is the delivery.
The realtime prompt is short because the provider handles the audio channel. Its key rules, from s2s-front-agent.ts: “The tool returns as soon as the request is dispatched — that does NOT mean the answer is ready.” and “If asked while a lookup is pending, say you're still checking.”
Without a mesh, the front calls a function tool and the worker runs behind an HTTP route. In examples/layered-voice, app/api/voice/s2s-token/route.ts mints an ephemeral Realtime client secret with createOpenAIRealtimeToken and a delegate_to_worker tool whose only parameter is request. app/s2s/page.tsx handles tool_call on OpenAIRealtimeAdapter by POSTing to app/api/s2s/delegate/route.ts, which runs the same worker agent and returns its final message text, then answers with adapter.sendToolResult(callId, result). The code is the pattern in Browser Realtime sessions. The call stays open while the worker researches; the instructions tell the model to acknowledge, keep chatting, and never invent results.
In examples/foundry-braind-storm, the briefing-line agent (agents/briefing-line/agent.ts) is a Foundry agent whose model is s2sDrivenModel({ provider: "gemini", … }) (Gemini Live), run by RealtimeAgent in briefing-room.ts. Its launch_campaign_workforce tool calls the Foundry client (client.send(…)) and returns at once with a durable batch run id; the workforce then runs as observable Foundry runs. Use this when delegated work needs a run id, trace, approvals and transcript. See Voice hosts delegate durable work.
The front is a normal streaming text agent, and only its <speech> text reaches TTS. In examples/layered-voice both agents run in one Next.js server process; the browser receives spoken text over SSE and streams it into ElevenLabs. In examples/server-voice VAD, STT, endpointing and TTS also run in a server room, and the worker runs in its own process.
caller ─▶ VAD ─▶ STT ─▶ FRONT (fast text model) ── <speech> text ──▶ TTS ─▶ caller
│ glove_mesh_send_message({ to: "worker", blocking: true })
▼ (the front's turn ends after its spoken ack)
WORKER (heavy model + tools) on a background queue
│ glove_mesh_send_message({ to: "front", in_reply_to })
▼
relay turn: <worker-result> ─▶ FRONT ─▶ <speech> text ─▶ TTSFront turns run one at a time on a queue. Worker runs serialize on their own queue, alongside front turns. A relay waits behind any front turn in flight and is skipped when a user turn already used the result. speakTurn is speakFrontTurn from Wiring it to TTS.
import type { IGloveRunnable } from "glove-core";
import { mountMesh, MeshNetwork, InMemoryMeshAdapter } from "glove-mesh";
import { buildAgent, frontModel, workerModel } from "./agents";
const FRONT_PROMPT = `You are Nova, the voice at the front desk. Only text inside <speech>...</speech> is heard.
You cannot look anything up. For any lookup, call glove_mesh_send_message (to: "worker", blocking: true,
content: the request) and, in the SAME turn, say a short acknowledgement inside <speech> tags.`;
const WORKER_PROMPT = `Research requests from "front" with your tools, then reply ONCE with
glove_mesh_send_message (to "front", in_reply_to the message id). Never just acknowledge.`;
const DRAIN = 'Handle the new request(s) in your inbox, then reply to "front" via glove_mesh_send_message with in_reply_to set.';
const RELAY = "<worker-result>A delegated request finished; the findings are in the resolved inbox block. Relay them inside <speech> tags in one or two sentences.</worker-result>";
export class LayeredVoiceSession {
private network = new MeshNetwork();
private front = buildAgent("front", frontModel(), FRONT_PROMPT);
private worker = buildAgent("worker", workerModel(), WORKER_PROMPT); // fold its real tools
private frontQueue: Promise<unknown> = Promise.resolve(); // front turns, one at a time
private workerQueue: Promise<unknown> = Promise.resolve(); // worker runs, beside them
private dispatched = new Set<string>();
/** `speakTurn` runs one front turn and speaks its <speech> text: speakFrontTurn below. */
constructor(private readonly speakTurn: (front: IGloveRunnable, prompt: string) => Promise<string>) {}
async start(): Promise<void> {
await mountMesh(this.front, { adapter: new InMemoryMeshAdapter(this.network, "front"), identity: { id: "front", name: "Voice Front", description: "The voice front desk." } });
await mountMesh(this.worker, { adapter: new InMemoryMeshAdapter(this.network, "worker"), identity: { id: "worker", name: "Worker", description: "Full tool surface." } });
}
/** Resolves when the FRONT turn ends. Delegated work keeps running. */
handleUtterance(text: string): Promise<void> {
return this.onFrontQueue(async () => {
await this.speakTurn(this.front, text);
await this.kickWorker();
});
}
/** Hand new delegations (mesh:from:* items in the worker's inbox) to one background run. */
private async kickWorker(): Promise<void> {
const items = (await this.worker.store.getInboxItems?.()) ?? [];
const fresh = items.filter((i) => i.status === "resolved" && i.tag.startsWith("mesh:from:") && !this.dispatched.has(i.id));
if (fresh.length === 0) return;
for (const i of fresh) this.dispatched.add(i.id);
this.workerQueue = this.workerQueue
.then(async () => {
await this.worker.processRequest(DRAIN);
this.queueRelay(); // the reply is now in the front's inbox
})
.catch(() => {});
}
/** Speak the result in a relay turn, behind any front turn already queued. */
private queueRelay(): void {
void this.onFrontQueue(async () => {
const resolved = (await this.front.store.getResolvedInboxItems?.()) ?? [];
if (!resolved.some((i) => i.tag.startsWith("mesh:"))) return; // a user turn already used it
await this.speakTurn(this.front, RELAY);
await this.kickWorker(); // the relay may delegate a follow-up
});
}
private onFrontQueue(fn: () => Promise<void>): Promise<void> {
const run = this.frontQueue.then(fn);
this.frontQueue = run.catch(() => {});
return run;
}
}layered-voice's session.ts does more: it kicks the worker 200 ms after the front's glove_mesh_send_message tool_use event instead of waiting for the turn to end, coalesces replies into one relay, records metrics, and adds the fallbacks below.
<speech> is the outbound channel. The front also receives tagged notices about events it cannot see (app/lib/server/events.ts). Its prompt says: “Treat any other <tag>-wrapped notice the same way: information about the session, not speech.”
| Tag | Sent when | Delivered as | The front should |
|---|---|---|---|
<user-interruption> | The caller barged in. Quotes the estimated heard prefix as plain text | A message appended to history on the front queue, before the interrupting line's turn | Answer the person; never re-deliver the cut remainder |
<speech-failure> | TTS failed; the room heard none of the line | A message appended to history | Re-say what matters at a natural opening |
<worker-result> | A worker reply landed | The prompt of a relay turn; findings arrive in the inbox block | Relay the key facts in one or two sentences |
<worker-trouble> | The delegation errored, or the worker finished without replying | The prompt of a relay turn, after stale mesh:waiting items are cleared | Level with the asker and offer to retry |
<transcript-correction> | STT's final text differs from the partial already dispatched | The prompt of a full front turn | Say the corrected take, or stay silent if nothing changes |
PROMISE_RE (“one moment”, “let me check”, “hold on”, “bear with me”…) and the front never called glove_mesh_send_message, the orchestrator runs one corrective turn that demands the call and says “Output NOTHING else — no <speech> tags”. It logs promised_without_delegation.mesh:waiting items are marked consumed and a <worker-trouble> turn runs, so the caller is not told “still checking” forever.salvageWrappedSpeech reclaims a reply wrapped in a tag named after a room participant instead of <speech>, because a silent malformed turn in history teaches the model to repeat it.In a pipeline front the model writes text, and something must decide what becomes audio. The <speech> protocol makes that explicit: only text inside <speech>…</speech> goes to TTS. Notes, reasoning about who was addressed, and text around tool calls stay silent. No tags means the agent says nothing.
No Glove package implements this protocol today. The parser is an example-level utility in examples/layered-voice/app/lib/server/speech-parser.ts, with an identical copy plus salvageWrappedSpeech in examples/server-voice/lib/speech-parser.ts. Copy it into your app; the version below is self-contained.
GloveVoice's SentenceBuffer waits for ., ? or ! first.Exact lowercase <speech> and </speech>, no attributes, always closed; a turn may contain several blocks. A tag with attributes (<speech lang="en">) is not recognised, so that block is silent. The rules from layered-voice's front-agent.ts:
# The speech protocol — CRITICAL
Your raw output is NOT spoken. Only text you wrap in <speech>...</speech> tags is converted to audio (ElevenLabs) and heard in the room, streamed as you generate it. Everything outside the tags is silent and invisible to the people around you.
- To say something out loud: <speech>One sec, let me pull that up.</speech>
- To stay quiet: emit NO speech tags at all. You may write a short silent note to yourself outside tags (e.g. "Not addressed to me — noting the hull id.") or nothing.
- Inside the tags, write for the ear: plain spoken sentences only — no markdown, lists, emoji, symbols, or URLs. Say numbers and ids the natural spoken way: "about four hundred eighty thousand credits", "hull K-E-S zero-zero-seven". Keep it to a breath or two.
- Use the exact lowercase tags <speech> and </speech>, and always close them.Pair it with the delegation rule from the same prompt: “Saying ‘let me check’ out loud does NOTHING by itself — if you do not call the tool in this turn, nobody looks anything up and the customer waits forever.”
// Streaming parser for the <speech>…</speech> protocol.
// Adapted from examples/layered-voice/app/lib/server/speech-parser.ts.
// Only text inside <speech> tags is emitted; everything else is silent.
const OPEN = "<speech>";
const CLOSE = "</speech>";
/** Length of the longest suffix of `buf` that is a proper prefix of `tag`. */
function partialTagSuffix(buf: string, tag: string): number {
const max = Math.min(buf.length, tag.length - 1);
for (let n = max; n > 0; n--) {
if (buf.endsWith(tag.slice(0, n))) return n;
}
return 0;
}
export interface SpeechParseStats {
/** Characters emitted as speech (including the spaces between blocks). */
spokenChars: number;
/** Characters outside the tags, plus stripped markup. Never spoken. */
discardedChars: number;
/** Number of <speech> blocks opened this turn. */
blocks: number;
/** The turn ended inside an unclosed <speech> block. */
unclosed: boolean;
}
export class SpeechTagParser {
private buf = "";
private inside = false;
private spoken = "";
private rawText = "";
private _stats: SpeechParseStats = { spokenChars: 0, discardedChars: 0, blocks: 0, unclosed: false };
/** `onSpeech` receives each newly unambiguous piece of in-tag text. */
constructor(private readonly onSpeech: (text: string) => void) {}
/** Protocol stats for the turn. Complete after finish(). */
get stats(): SpeechParseStats {
return { ...this._stats };
}
/** Everything the model generated this turn: tags, silent notes and all. */
get raw(): string {
return this.rawText;
}
/** Feed one streamed chunk. */
push(chunk: string): void {
if (!chunk) return;
this.rawText += chunk;
this.buf += chunk;
this.drain();
}
/** End of turn. Speaks the rest of an unclosed block. Returns all spoken text. */
finish(): string {
this.drain();
if (this.inside) {
this._stats.unclosed = true;
// Drop a held-back partial tag such as "</spee" instead of speaking it.
const hold = Math.max(partialTagSuffix(this.buf, CLOSE), partialTagSuffix(this.buf, OPEN));
const text = hold ? this.buf.slice(0, this.buf.length - hold) : this.buf;
this._stats.discardedChars += hold;
if (text) this.emit(text);
} else {
this._stats.discardedChars += this.buf.length;
}
this.buf = "";
this.inside = false;
return this.spoken.replace(/\s+/g, " ").trim();
}
private emit(text: string): void {
if (!text) return;
// Never let markup reach TTS: strip stray speech-tag fragments and any
// leftover angle brackets inside a block.
const clean = text.replace(/<\/?speech>?/g, "").replace(/[<>]/g, "");
this._stats.discardedChars += text.length - clean.length;
if (!clean) return;
this.spoken += clean;
this._stats.spokenChars += clean.length;
this.onSpeech(clean);
}
private drain(): void {
for (;;) {
if (this.inside) {
const i = this.buf.indexOf(CLOSE);
if (i >= 0) {
this.emit(this.buf.slice(0, i));
this.buf = this.buf.slice(i + CLOSE.length);
this.inside = false;
this.emit(" "); // audible gap between consecutive blocks
continue;
}
// Emit everything except a suffix that could be the start of </speech>.
const hold = partialTagSuffix(this.buf, CLOSE);
const safe = this.buf.length - hold;
if (safe > 0) {
this.emit(this.buf.slice(0, safe));
this.buf = this.buf.slice(safe);
}
return;
}
const i = this.buf.indexOf(OPEN);
if (i >= 0) {
this._stats.discardedChars += i; // text before the tag is silent
this.buf = this.buf.slice(i + OPEN.length);
this.inside = true;
this._stats.blocks += 1;
continue;
}
// Outside a block: keep only a suffix that could be the start of <speech>.
const hold = partialTagSuffix(this.buf, OPEN);
this._stats.discardedChars += this.buf.length - hold;
this.buf = hold ? this.buf.slice(this.buf.length - hold) : "";
return;
}
}
}Outside a block it holds back only a suffix that could still become <speech>; inside, only a suffix that could become </speech>. Everything else is emitted at once: fed one character at a time, <speech>Hi.</speech> emits H, i, . as they arrive. Text outside blocks is discarded. Each closing tag emits one space so blocks do not run together. Stray tag fragments and </> inside a block are stripped. finish() speaks the rest of an unclosed block (minus a dangling partial tag), sets stats.unclosed, and returns the spoken text; an empty string means the agent stayed silent.
| Chunks streamed in | Emitted, per chunk | finish() |
|---|---|---|
"Not for me, noting the hull. <spe" · "ech>One sec, let me " · "pull that up.</spe" · "ech>" | nothing · "One sec, let me " · "pull that up." · " " | "One sec, let me pull that up." |
"<speech>Checking now.</speech>" · "calling the worker" · "<speech>Back in a moment.</speech>" | "Checking now.", " " · nothing · "Back in a moment.", " " | "Checking now. Back in a moment." |
"<speech>Your warranty ran out in May" · " last year</spee", then the turn ends | "Your warranty ran out in May" · " last year" | "Your warranty ran out in May last year", unclosed: true |
"Not addressed to me. Noting the id KES-0007." | nothing | "" (silent turn) |
Subscribe to the front agent's text_delta events, push each chunk into a fresh parser per turn, and send spoken text straight into a streaming TTS socket. ElevenLabs buffers input on its own schedule, so layered-voice forces generation with flush: true once at about 60 spoken characters, then at sentence-ending punctuation when at least 120 characters have passed since the last forced flush (app/lib/client/useVoice.ts). Ignore text_delta between compaction_start and compaction_end: the compaction summary streams too.
import type { IGloveRunnable, SubscriberAdapter } from "glove-core";
import { ElevenLabsTTSAdapter } from "glove-voice";
import { createElevenLabsTTSToken } from "glove-voice/server";
import { SpeechTagParser } from "./speech-tag-parser";
const EARLY_FLUSH_CHARS = 60; // force the first generation after ~60 spoken chars
const MIN_FLUSH_GAP_CHARS = 120; // then at sentence ends, at most every 120 chars
/**
* Run one front-agent turn and stream ONLY its <speech> text into TTS.
* Resolves with everything that was spoken ("" means the agent stayed silent).
*/
export async function speakFrontTurn(
front: IGloveRunnable,
prompt: string,
playAudio: (pcm: Uint8Array) => void, // app-owned: send PCM to the caller
): Promise<string> {
const out = {
tts: null as ElevenLabsTTSAdapter | null,
opened: Promise.resolve(false),
sentChars: 0,
charsSinceFlush: 0,
earlyFlushSent: false,
};
let compacting = false;
const parser = new SpeechTagParser((text) => {
if (!out.tts) {
// Open TTS on the first spoken token, so a silent turn costs nothing.
out.tts = new ElevenLabsTTSAdapter({
getToken: () => createElevenLabsTTSToken(process.env.ELEVENLABS_API_KEY!),
voiceId: process.env.ELEVENLABS_VOICE_ID!,
model: "eleven_flash_v2_5",
generationConfig: { chunkLengthSchedule: [60, 120, 160, 250] },
});
out.tts.on("audio_chunk", playAudio);
out.opened = out.tts.open().then(() => true, () => false); // text queues until open
}
out.sentChars += text.length;
out.charsSinceFlush += text.length;
const flush =
(!out.earlyFlushSent && out.sentChars >= EARLY_FLUSH_CHARS) ||
(out.earlyFlushSent && /[.?!]/.test(text) && out.charsSinceFlush >= MIN_FLUSH_GAP_CHARS);
if (flush) {
out.earlyFlushSent = true;
out.charsSinceFlush = 0;
}
out.tts.sendText(text, flush ? { flush: true } : undefined); // flush forces generation now
});
const subscriber: SubscriberAdapter = {
async record(type, data) {
if (type === "compaction_start") compacting = true;
if (type === "compaction_end") compacting = false;
// Compaction summaries also stream as text_delta. Never speak them.
if (type === "text_delta" && !compacting) {
parser.push((data as { text: string }).text);
}
},
};
front.addSubscriber(subscriber);
try {
await front.processRequest(prompt);
} finally {
front.removeSubscriber(subscriber);
}
const spoken = parser.finish(); // also speaks the rest of an unclosed block
if (out.tts && (await out.opened)) out.tts.flush(); // end of text for this turn
return spoken;
}layered-voice splits this across the network: the server parses and streams spoken deltas over SSE, and the browser feeds them to ElevenLabsTTSAdapter with a prewarmed socket and a 12 s stall watchdog that reports a <speech-failure>. server-voice does it all in the room. One socket per front turn is enough because the front's tools return at once; GloveVoice opens one per model response because tools can outlast ElevenLabs' idle timeout (about 20 s).
GloveVoice has no option to filter or transform text before TTS, and useGloveVoice takes the same GloveVoiceConfig. Its source says “All text across the turn is spoken — tool-calling responses included”: every text_delta goes through SentenceBuffer (regex /[^.?!]*[.?!]+\s*/g) into TTS. To use <speech> tags, feed TTS yourself from a subscriber, as speakFrontTurn does. The layered-voice README gives a second reason for layered agents: useGloveVoice “assumes an in-browser agent and can't speak the proactive relay, which arrives outside the initiating turn.”
The only seam inside GloveVoice is the createTTS factory. An adapter wrapper that runs sendText through a SpeechTagParser works, with caveats you own: it still receives sentence-sized chunks, so audio starts at the first sentence end; it must emit done itself when nothing was spoken, because GloveVoice returns to listening on the last adapter's done; and narrate(text) uses the same factory, so narrated text without tags would be silent.
The front speaks on every turn, so its time to first spoken token is the largest part of the latency you control. Optimise it for:
<speech> tags, silence when a line is not for it, and the delegation call in the same turn as its acknowledgement.examples/layered-voice/app/lib/server/models.ts states the split: “the front agent runs a small/fast tier; the worker runs the heavy tier.” Its defaults by VOICE_PROVIDER: openrouter (default) uses moonshotai/kimi-k2-0905 for the front and minimax/minimax-m2.5 for the worker; anthropic uses claude-haiku-4-5-20251001 and claude-sonnet-4-20250514.
import { Glove, Displaymanager, MemoryStore, createAdapter, type IGloveRunnable, type ModelAdapter } from "glove-core";
// Front: optimise for time to first spoken token and for following the protocol.
export const frontModel = () =>
createAdapter({
provider: "openrouter",
model: process.env.FRONT_MODEL ?? "moonshotai/kimi-k2-0905",
stream: true, // <speech> text must stream into TTS
reasoning: {
reasoningObject: { effort: "low" }, // thinking before the first token is dead air
echo: false,
extraBody: { provider: { sort: "throughput" } }, // OpenRouter: fastest host for these weights
},
});
// Worker: optimise for capability and tool use. The front hides its latency.
export const workerModel = () =>
createAdapter({ provider: "openrouter", model: process.env.WORKER_MODEL ?? "minimax/minimax-m2.5", stream: true, reasoning: true });
// One store (with inbox support, which the mesh needs) and one adapter per agent.
export function buildAgent(id: string, model: ModelAdapter, systemPrompt: string): IGloveRunnable {
return new Glove({
store: new MemoryStore(id),
model,
displayManager: new Displaymanager(),
systemPrompt,
serverMode: true,
compaction_config: { compaction_instructions: "Summarize briefly. Keep pending requests." },
}).build();
}| Env (layered-voice) | Effect |
|---|---|
FRONT_REASONING | low (default): reasoningObject: { effort: "low" }. budget:<n>: a hard cap, { max_tokens: n }. off: { enabled: false }, which some providers reject for reasoning models. none: no reasoning field. Anthropic ignores it |
FRONT_PROVIDER_SORT | OpenRouter routing: throughput (default on OpenRouter), latency, price, or off |
FRONT_MODEL / WORKER_MODEL | Override the model per role |
gpt-oss-120b on OpenRouter took 979 ms to first token with default routing and 160 ms with provider.sort: "throughput", on an identical prompt. Who serves the weights mattered more than the weights.gpt-oss-120b: 310 ms) with better addressing judgment on its rubric. Its occasional acknowledgement without a tool call is caught by the delegation nudge in Fallbacks.PRODUCTION.md §16), scoring correct silence on cases from real calls: a fast tier scored 2/5 at 313 ms to first spoken token, a mid tier 4/5 at 1853 ms, a strong tier 5/5 at 3213 ms. Its findings: “A better prompt does not fix a model that won't follow it” and “Serving matters more than weights for latency.”Measure time to the first spoken token: reasoning and silent notes before the first <speech> are dead air. layered-voice logs it as front_ttft_ms, and pnpm eval -- --model <id> scores a front model on addressing, delegation, content and speed. With a realtime front, the front model is the S2S provider's; the worker is still yours to choose.
These are the examples' own figures, not guarantees.
| Shape | Figure | Source |
|---|---|---|
| Cascade (STT → LLM → TTS) | Bottoms out around 1.3–1.6 s voice-to-voice | glove-voice-s2s README |
| Speech-to-speech | 500–800 ms voice-to-voice for production S2S APIs | glove-voice-s2s README; layered-voice's /s2s page logs the real number as s2s_voice_to_voice_ms |
| Layered pipeline, browser-hosted | Typically 0.9–1.3 s (README section “The sub-1.5s latency budget”). Per leg: end of speech 450 ms for questions; STT finalize ~0 ms (dispatch from the live partial); first spoken token 160–600 ms; TTS first audio ~75–150 ms with eleven_flash_v2_5 | layered-voice README |
| Layered pipeline, server room | ~937 ms from speech end to commit; ~1.5 s voice-to-voice on a live room | server-voice PRODUCTION.md §3 |
| Delegated answers | Worker time plus one relay turn, logged as delegation_roundtrip_ms | layered-voice |
| Shape | What happens | What the model learns |
|---|---|---|
| Single-agent pipeline | In VAD mode, GloveVoice calls interrupt() while thinking or speaking: on speech_real_start with a confirming VAD (Silero), else on speech_start. It aborts the request, destroys TTS, stops playback and clears non-blocking display slots. Skipped while a pushAndWait resolver is pending. In manual mode, call interrupt() yourself | Nothing: GloveVoice does not record how much was heard |
| Realtime, single or layered | Provider-native. Clear queued playback on the adapter's interrupted event. Gemini: activityHandling: "NO_INTERRUPTION" turns barge-in off. GPT-Live: manual interrupt() mutes output until resumeOutput?.(). A layered worker keeps running | OpenAIRealtimeSocketAdapter sends conversation.item.truncate with audio_end_ms estimated from the playback clock (truncateToHeard) |
| Layered, pipeline front | You build it. layered-voice cuts TTS on confirmed speech (250 ms sustained on the energy VAD, speech_real_start with Silero) and voids queued speech. server-voice decides on the server: pause on any speech-ish frame within 85–128 ms, cut when the transcript grows or the VAD confirms, resume mid-word if it was a cough | A <user-interruption> notice quoting the heard prefix, estimated from playback time |
| Shape | Example | Read first |
|---|---|---|
| Single-agent pipeline | coffee, lola | app/components/chat.tsx, app/components/lola.tsx |
| Single-agent realtime | s2s-agent | app/lib/agent.ts, app/page.tsx |
| Layered, realtime front (mesh) | s2s-rooms | lib/s2s-front-agent.ts, signals/s2s-room.ts |
| Layered, realtime front (tool) | layered-voice /s2s | app/api/voice/s2s-token/route.ts, app/s2s/page.tsx, app/api/s2s/delegate/route.ts |
| Layered, realtime front (Foundry) | foundry-braind-storm | agents/briefing-line/agent.ts, briefing.tools.ts, briefing-room.ts |
| Layered, pipeline front | layered-voice | app/lib/server/session.ts, front-agent.ts, speech-parser.ts, events.ts, models.ts, app/lib/client/useVoice.ts |
| Layered, pipeline front, server-hosted | server-voice | PRODUCTION.md, lib/voice-session.ts, lib/speech-parser.ts |
avatar-rooms and livekit-rooms build on the realtime-front shape; see Realtime voice.
GloveVoice, VAD, speech gating, push-to-talkRealtimeAgent, providers, live context, avatars, LiveKitmountMesh, blocking sends, replies