Voice agent architectures

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.

The four shapes

  1. Single-agent pipeline. VAD → STT → agent → TTS. GloveVoice (glove-voice) or useGloveVoice (glove-react/voice). See Voice pipeline.
  2. Single-agent realtime. A speech-to-speech (S2S) model hears the caller, speaks, and calls the agent's tools. RealtimeAgent + s2sDrivenModel (glove-voice-s2s). See Realtime voice.
  3. Layered, realtime front. An S2S front agent stays in the conversation and delegates real work to a text worker.
  4. Layered, pipeline front. STT → a fast text front agent → TTS. The front delegates to a heavy worker over glove-mesh.
Single-agent pipelineSingle-agent realtimeLayered, realtime frontLayered, pipeline front
Latency to first soundCascade: about 1.3–1.6 s. Text reaches TTS a sentence at a time; a tool call before any text adds its full duration500–800 ms for S2S APIs. A slow tool is dead air: the provider waits for its resultFront at S2S speed; the worker's answer is a second spoken turn later0.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 controlAny TTSAdapter (ElevenLabs built in). Everything the model writes is spokenThe provider's voices (marin on OpenAI). Words and audio come from one modelSame as single-agent realtimeAny TTS. <speech> tags decide exactly what is spoken
ModelsAny glove-core model, which must be fast and capable at onceS2S providers: openai, openai-webrtc, openai-live, geminiFront: an S2S provider. Worker: any glove-core modelFront: a fast streaming text model. Worker: any heavy model
Tool-heavy workRuns inside the spoken turn; the caller waitsRuns through Tool.run with no Executor: requiresPermission is not enforced, pushAndWait throwsDelegated; 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
InterruptionsBuilt in: VAD barge-in calls interrupt()Provider-native; the OpenAI WebSocket adapter truncates context to what was heardProvider-native; the worker keeps runningYou build it; the front is told what was heard via <user-interruption>
Cost and complexityLeast code: one hookLittle code; the whole call runs on a realtime modelTwo agents, a mesh and inject; the heavy model runs only for delegated workMost code (STT, endpointing, TTS streaming, a parser, a relay queue); a cheap front on every turn
Reference examplecoffee, lolas2s-agents2s-rooms, layered-voice /s2s, foundry-braind-stormlayered-voice, server-voice

Latency figures are the examples' own claims and measurements; see Latency budgets.

How to choose

  • Start with the single-agent pipeline when tools are quick and a second before the answer is fine. It is one hook.
  • Use single-agent realtime when tools are fast and the provider's voice and turn-taking are good enough. You give up control of the exact words for latency.
  • Layer as soon as a turn needs multi-step tool work such as lookups, research or bookings. The caller hears an acknowledgement at once and the answer when it is ready.
  • Pick a realtime front for the lowest latency and provider barge-in with the least code. Pick a pipeline front for your own TTS voice, control over exactly what is said, a free choice of front model, and a metric for every leg of latency.

Why layer at all

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:

  • The front agent owns the conversation. It hears every line, decides whether and what to say, acknowledges requests at once, relays results, and says it is still checking while work is pending. It has almost no tools.
  • The worker owns the work. Full tool surface, heavy model, never speaks to the caller. It replies to the front with in_reply_to set and never just acknowledges: an acknowledgement would resolve the front's pending item before any answer exists.
  • Cost follows the split. The cheap front runs on every turn; the heavy model only on delegated turns.

Delegation mechanics

StepPipeline front (layered-voice)Realtime front, mesh (s2s-rooms)Realtime front, tool (layered-voice /s2s)
DispatchThe front calls glove_mesh_send_message({ to: "worker", blocking: true, content })The same tool, called by the S2S model through RealtimeAgentThe model calls a delegate_to_worker function tool; the page POSTs { request } to /api/s2s/delegate
blocking: trueAdds a pending mesh:waiting:<message id> item to the front's inbox and returns at once. It does not wait for the workerNo mesh. The function call stays open until the worker returns
AcknowledgementA <speech> ack in the same turn; then the front's turn endsSpoken “in the same breath” as the tool callSpoken while the call is open
Worker runThe orchestrator runs worker.processRequest(…) on its own queue for new mesh:from:* items in the worker's inboxA research station signal in its own processThe route runs worker.processRequest(…) and returns the worker's final message text
ResultThe 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 waitingGlove adds [Inbox: N blocking item(s) still pending …] to each front turn, so the front says it is still checkingRealtimeAgent does not run Glove's loop, so there is no reminder; a prompt rule does that jobThe open tool call

Layered with a realtime front

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.

Delegate over the mesh (s2s-rooms)

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):

realtime-front.tstypescript
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.”

Delegate through a tool (layered-voice /s2s)

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.

Delegate durable work to Foundry

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.

Layered with a pipeline front

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.

the shapetext
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 ─▶ TTS

The orchestrator

Front 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.

layered-session.tstypescript
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.

Inbound tags: what the front is told

<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.”

TagSent whenDelivered asThe front should
<user-interruption>The caller barged in. Quotes the estimated heard prefix as plain textA message appended to history on the front queue, before the interrupting line's turnAnswer the person; never re-deliver the cut remainder
<speech-failure>TTS failed; the room heard none of the lineA message appended to historyRe-say what matters at a natural opening
<worker-result>A worker reply landedThe prompt of a relay turn; findings arrive in the inbox blockRelay the key facts in one or two sentences
<worker-trouble>The delegation errored, or the worker finished without replyingThe prompt of a relay turn, after stale mesh:waiting items are clearedLevel with the asker and offer to retry
<transcript-correction>STT's final text differs from the partial already dispatchedThe prompt of a full front turnSay the corrected take, or stay silent if nothing changes

Fallbacks

  • Promised, but did not dispatch. If a spoken turn matches 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.
  • Researched, but did not reply. The worker gets one retry prompt. If it still sends nothing, its final message text is salvaged into the front's inbox and relayed.
  • Errored. Stale mesh:waiting items are marked consumed and a <worker-trouble> turn runs, so the caller is not told “still checking” forever.
  • Wrong envelope. server-voice's 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.

<speech> tags: speak only what should be heard

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.

Why use tags

  • Audio starts on the first spoken token. The parser emits in-tag text as soon as it is unambiguous, so TTS starts while the model is still generating the rest of the line, a tool call, or silent notes. GloveVoice's SentenceBuffer waits for ., ? or ! first.
  • Internal text stays silent. Notes like “Not addressed to me — noting the hull id” and narration around tool calls never reach the speaker, but stay in the model's history.
  • Silence is an explicit, free decision. In layered-voice this replaced a separate addressing classifier, which “removes a whole model round-trip from every utterance”.
  • Protocol health is measurable. The parser counts spoken and discarded characters, blocks and unclosed tags, and keeps the raw output for evals.

The protocol and the prompt

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:

front-agent.ts (system prompt excerpt)text
# 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.”

The parser

speech-tag-parser.tstypescript
// 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 inEmitted, per chunkfinish()
"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)

Wiring it to TTS

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.

speak-front-turn.tstypescript
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).

With GloveVoice

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.

Fast models for the front

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:

  • Time to first spoken token: little or no reasoning before output, and a fast host.
  • Instruction following: <speech> tags, silence when a line is not for it, and the delegation call in the same turn as its acknowledgement.
  • Not capability. It has almost no tools. Put capability in the worker, whose latency hides behind the front's 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.

agents.tstypescript
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_REASONINGlow (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_SORTOpenRouter routing: throughput (default on OpenRouter), latency, price, or off
FRONT_MODEL / WORKER_MODELOverride the model per role
  • Measured in the layered-voice example: 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.
  • Measured in the layered-voice example: the default front, Kimi K2 served via Groq, reached first token in 263 ms (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.
  • Measured in the server-voice example (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.

Latency budgets

These are the examples' own figures, not guarantees.

ShapeFigureSource
Cascade (STT → LLM → TTS)Bottoms out around 1.3–1.6 s voice-to-voiceglove-voice-s2s README
Speech-to-speech500–800 ms voice-to-voice for production S2S APIsglove-voice-s2s README; layered-voice's /s2s page logs the real number as s2s_voice_to_voice_ms
Layered pipeline, browser-hostedTypically 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_5layered-voice README
Layered pipeline, server room~937 ms from speech end to commit; ~1.5 s voice-to-voice on a live roomserver-voice PRODUCTION.md §3
Delegated answersWorker time plus one relay turn, logged as delegation_roundtrip_mslayered-voice

Barge-in and interruptions

ShapeWhat happensWhat the model learns
Single-agent pipelineIn 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() yourselfNothing: GloveVoice does not record how much was heard
Realtime, single or layeredProvider-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 runningOpenAIRealtimeSocketAdapter sends conversation.item.truncate with audio_end_ms estimated from the playback clock (truncateToHeard)
Layered, pipeline frontYou 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 coughA <user-interruption> notice quoting the heard prefix, estimated from playback time

Example map

ShapeExampleRead first
Single-agent pipelinecoffee, lolaapp/components/chat.tsx, app/components/lola.tsx
Single-agent realtimes2s-agentapp/lib/agent.ts, app/page.tsx
Layered, realtime front (mesh)s2s-roomslib/s2s-front-agent.ts, signals/s2s-room.ts
Layered, realtime front (tool)layered-voice /s2sapp/api/voice/s2s-token/route.ts, app/s2s/page.tsx, app/api/s2s/delegate/route.ts
Layered, realtime front (Foundry)foundry-braind-stormagents/briefing-line/agent.ts, briefing.tools.ts, briefing-room.ts
Layered, pipeline frontlayered-voiceapp/lib/server/session.ts, front-agent.ts, speech-parser.ts, events.ts, models.ts, app/lib/client/useVoice.ts
Layered, pipeline front, server-hostedserver-voicePRODUCTION.md, lib/voice-session.ts, lib/speech-parser.ts

avatar-rooms and livekit-rooms build on the realtime-front shape; see Realtime voice.