Skip to content

Anvil Voice

anvil-serving voice is the local voice-agent runtime built in this repo. It runs an OpenAI Realtime-compatible WebSocket server and wires a voice cascade:

Realtime client
  -> anvil-serving voice proxy run
  -> VAD
  -> STT endpoint
  -> Anvil Serving Capability Gateway / OpenAI Chat Completions endpoint
  -> TTS endpoint
  -> Realtime audio output

The voice loop stays within the operator-declared local or private topology while Anvil Serving's Capability Gateway handles the LLM turn. STT and TTS are replaceable, topology-owned out-of-process serves: operator topology declares their resource owner, execution host, and lifecycle boundary. The LLM request selects one configured capability alias such as llm.voice; its physical Compute A/B placement is independent of that capability name.

Routing is fail-closed. An unavailable STT, TTS, capability alias, or selected tier returns an error; the request path does not substitute a provider, capability alias, or endpoint. Lifecycle changes, capability-alias changes, and promotion remain separate managed operations with explicit previews and human gates.

Evidence terminology. In this guide, current means the latest public evidence decision recorded in the benchmark portal. It does not report an operator's live route assignment, model placement, or service health. Inspect those private facts through the topology owner's status surfaces.

The voice pipeline is different from voice sidecar. voice sidecar renders commands or compose manifests for Hugging Face's speech-to-speech project, where that project owns the Realtime server. anvil-serving voice owns the Realtime server and cascade itself.

One-shot audio gateway

In the public Mini-to-Dark reference topology, the Realtime pipeline uses topology-owned audio serves through a topology-derived bridge. The optional one-shot audio gateway is for private HTTP clients that need dictation or complete read-aloud audio without learning raw host/port details. It is not part of the existing Workbench Realtime relay and must not replace that relay's WebSocket URL or token. Declare [[router.audio_routes]] together with [server].auth_env; each endpoint remains 404 unless it has a bound route for its purpose.

client -- bearer token --> gateway /v1/audio/* -- private hop --> topology-owned STT/TTS serve

POST /v1/audio/transcriptions accepts JSON {"purpose":"stt","audio_b64":"...","format":"wav|pcm16|webm_opus","is_final":true} and returns text, is_final, duration_ms, model, and request_id. duration_ms is null when the source container has no reliably derivable duration (for example, webm_opus). pcm16 requires an explicit sample_rate and is wrapped as WAV before the multipart hop to the routed STT serve. Interim audio is rejected: this is a one-shot boundary, not a streaming STT protocol.

POST /v1/audio/speech accepts JSON {"purpose":"tts","input":"...","response_format":"pcm16"} and returns JSON {"audio_b64":"...","format":"pcm16","sample_rate":24000,"model":"...","request_id":"..."}. The gateway requests the routed TTS serve's qualified raw PCM16 contract, validates its media type and whole sample framing, then base64-encodes the bytes. WAV and MP3 output remain unavailable until those upstream contracts have separate qualification.

Both endpoints use the same bearer/x-api-key authentication as /v1/responses. Decisions log only opaque route id, byte counts (including rejected bounded upstream responses), outcome, latency, and correlation ids; audio bytes/base64, transcript text, and synthesis input are excluded. The gateway has a separate bounded concurrency pool for parsed upstream operations and input/output caps. It has no provider or chat fallback: a configured raw serve failure is a typed error from that route. See configuration and ADR-0024.

Any Workbench HTTP voice integration must use the Capability Gateway base URL plus ANVIL_ROUTER_TOKEN, send this canonical shape, use the returned sample_rate for playback, and keep the existing Realtime relay separate. Older audio/model/format adapter shapes are not part of this contract.

Command summary

Command What it does What it does not do
anvil-serving voice up Starts co-located managed STT/TTS, then the managed Realtime proxy. Does not span hosts, native/external lifecycle, or separate controller owners.
anvil-serving voice down Stops the co-located managed Realtime proxy, then managed STT/TTS. Does not tear down audio if the proxy stop fails.
anvil-serving voice audio up Validates the voice manifest and starts manifest-owned managed/native STT/TTS lifecycle. Does not start the Realtime WebSocket server or the LLM router.
anvil-serving voice audio down Stops native STT/TTS processes and stops/removes managed STT/TTS containers. Does not stop the LLM router; a foreground voice proxy run process stops with Ctrl+C.
anvil-serving voice audio status Reports bounded readiness and lifecycle state for both topology-owned audio serves. Does not mutate either serve.
anvil-serving voice audio logs Reads a bounded tail from both topology-owned audio serves. Does not follow logs indefinitely.
anvil-serving voice proxy run Starts the Realtime WebSocket server in the foreground after probing the LLM, STT, and TTS endpoints. Does not silently continue when required endpoints are unreachable.
anvil-serving voice proxy up Starts the Mini-owned Realtime proxy as a persistent background process. Does not start STT/TTS models or the audio bridge.
anvil-serving voice proxy down Stops only the persistent Realtime proxy process recorded by Anvil. Does not stop audio models or unrelated processes.
anvil-serving voice proxy restart Performs a bounded stop/start of the persistent Realtime proxy. Does not replace a foreground proxy run process.
anvil-serving voice proxy status Reports persistent process identity and readiness. Does not infer health from a PID alone.
anvil-serving voice proxy logs Reads a bounded tail of persistent proxy logs. Does not stream without a bound.
anvil-serving voice benchmark Runs one configured end-to-end voice turn and prints latency/quality metrics as JSON. Does not change the configured capability alias or prove subjective audio quality by itself.
anvil-serving voice corpus prepare Builds a deterministic multi-sample English STT corpus in an evidence workspace. Does not commit the public archives or change a serve.
anvil-serving voice corpus validate Validates corpus JSONL, WAV/FLAC metadata, paths, hashes, and case count. Does not contact an STT endpoint.
anvil-serving voice profiles list Lists manifest profiles or validates the resolved manifest for one profile. Does not mutate lifecycle or start the Realtime server.
anvil-serving voice proxy bridge On Mini, forwards loopback STT/TTS proxy ports to topology-owned Dark endpoints. Does not bind publicly, manage models, add auth, or inspect audio traffic.
anvil-serving voice sidecar Validates or renders a Hugging Face speech-to-speech sidecar. Does not run anvil-serving's native Realtime cascade.

Removed module-level paths

The old module-level paths voice start, voice stop, voice run, and voice bridge remain removed tombstones. They exit with replacement guidance and do not import or invoke an operational handler.

Manifest-backed commands take --config <voice.toml>. If omitted, $ANVIL_SERVING_HOME/voice.toml (default ~/.anvil-serving/voice.toml) is used when present; otherwise the shipped example manifest is used. up, down, run, and benchmark also accept --profile <name> to apply [voice.profiles.<name>] before validation. Relative managed manifest_path values inside the voice manifest resolve against the voice manifest's own directory, so a host-level ~/.anvil-serving/voice.toml can refer to manifest_path = "serves.toml". proxy run and benchmark also accept --candidate-overlay <toml> and --candidate <label> so bounded A/B tests can compose one audio topology with one LLM candidate without copying manifests. benchmark additionally accepts --candidate-base-url, --candidate-model, and --candidate-api-key-env for a voice candidate that is already loaded on a direct OpenAI-compatible endpoint. Those flags create an in-memory LLM overlay for that benchmark run only; they do not write the voice manifest, gateway config, or configured capability alias.

Audio and proxy operations require --topology <operator-topology.toml>. The topology, not the machine where the command was typed, establishes the resource owner, execution host/runtime, transport, endpoint meaning, and Mini capacity policy. Controller-side voice_manage and voice_proxy_manage calls may use ANVIL_VOICE_TOPOLOGY instead of a topology argument. Missing, split, or ambiguous ownership fails before any serve or process object is constructed.

Why these commands exist

Voice has four separate operational concerns:

  1. Audio model lifecycle: STT and TTS endpoints may be Docker containers, native processes on a voice host, or manually managed services. voice audio up/down owns only this layer.
  2. Realtime session serving: voice proxy run owns a foreground WebSocket server; voice proxy up/down/restart/status/logs own the persistent Mini service. Neither surface owns model lifecycle.
  3. Evidence capture: voice benchmark, voice corpus, and the hardware validation scripts measure whether the configured STT -> LLM -> TTS path is usable on the target host.
  4. Private audio forwarding: voice proxy bridge listens only on Mini loopback and forwards to topology-derived Dark STT/TTS addresses.

Keeping those concerns separate avoids a common failure mode: a command that appears to start "voice" but only starts one part of the pipeline. voice audio up makes STT/TTS available; voice proxy run is the user-facing Realtime server.

The one exception is the deliberately narrow voice up / voice down aggregate for a co-located Docker-managed stack. It requires matching host-relative STT/TTS endpoints, container service-DNS proxy endpoints on the audio-owned anvil-voice network, one shared serves manifest, and voice group membership for STT, TTS, and the managed proxy. Bring-up runs audio before the proxy; tear-down reverses the order. The aggregate refuses split ownership, so the public Mini-to-Dark reference path remains explicit.

Manifest shape

A voice manifest has one [voice] section and three endpoint subsections. The Capability Gateway models audio as two purposes — stt and tts — and each resolves to exactly one configured audio route. The model values below illustrate the public reference configuration; they are not part of the contract. Substitute the exact identities advertised by the topology-owned STT and TTS serves. The benchmark portal records the latest public evidence decision for evaluated configurations; its current label does not describe a live operator route or deployment.

[voice]
name = "anvil-voice"
realtime_host = "127.0.0.1"
realtime_port = 8765

[voice.llm]
base_url = "http://127.0.0.1:8000/v1"
model = "llm.voice"
stream = true
api_key_env = "ANVIL_ROUTER_TOKEN"
history_max_turns = 8
history_max_message_chars = 1200
tool_result_max_chars = 12000

[voice.stt]
base_url = "http://127.0.0.1:30010/v1"
model = "parakeet"
lifecycle = "managed"

[voice.tts]
base_url = "http://127.0.0.1:30011/v1"
model = "kokoro"
lifecycle = "managed"
response_format = "pcm"
protocol = "openai"

[voice.profiles.dark-audio.stt]
base_url = "http://100.64.0.10:30110/v1"
model = "tdt-0.6b-v3"
lifecycle = "external"

[voice.profiles.dark-audio.tts]
base_url = "http://100.64.0.10:30111/v1"
model = "kokoro"
lifecycle = "external"
response_format = "pcm"

Manifest hygiene follows the rest of the repo:

  • Use 127.0.0.1 for same-host URLs.
  • Use private tailnet or direct addresses for cross-device endpoint URLs.
  • Store credentials in environment variables and reference only the env-var name with *_env keys.
  • Do not embed credentials in URLs.
  • A non-loopback realtime_host requires realtime_token_env.
  • Use profiles for repeatable topology switches instead of copying manifests or maintaining one-off shell scripts.
  • voice.llm.history_max_turns controls session-local memory for completed user/assistant turns. Set it to 0 for deterministic validation prompts or lower it to cap prompt cost; the default is 8.
  • voice.llm.history_max_message_chars trims each remembered user or assistant message before it is replayed into the next LLM request; the default is 1200.
  • voice.llm.tool_result_max_chars trims very large realtime tool outputs before the continuation LLM request; the default is 12000.
  • voice.llm.speech_chunk_max_chars caps each speakable LLM chunk before it is sent to TTS. Sentence punctuation still wins, but long first sentences are split on word boundaries so first audio does not wait for a large clause; the default is 72.
  • voice.tts.protocol defaults to openai, which calls {base_url}/audio/speech and consumes raw signed 16-bit PCM. Set it to gepard for Gepard's Cartesia-wire streaming TTS, where Anvil Voice connects to {base_url}/tts/websocket and consumes chunk messages carrying base64 PCM. Keep response_format = "pcm" because the pipeline still emits raw PCM internally. The older cartesia spelling is accepted as a local wire-protocol alias; it is not a full Cartesia cloud integration.
  • voice.llm.model remains the manifest-owned Anvil capability alias. Realtime clients may send session.model, but Anvil Voice does not let that field override local routing.

For native audio endpoints, add the lifecycle metadata to the STT/TTS section:

lifecycle = "native"
workdir = "~/code/mlx-audio"
start_command = ".venv/bin/python -m mlx_audio.server --host 127.0.0.1 --port 30010"
pid_file = "/tmp/anvil-voice/stt.pid"
log_file = "/tmp/anvil-voice/stt.log"
ready_timeout = 120.0
stop_timeout = 5.0

STT/TTS lifecycle modes

voice.stt.lifecycle and voice.tts.lifecycle choose what voice audio up/down can manage.

Lifecycle Use When voice audio up voice audio down
managed The audio serve is declared in a serves manifest named by manifest_path (use a dedicated serves.voice.toml, not the shared serves.toml — see below). Delegates to the same serve adapter used by anvil-serving serves. Stops the matching serve.
native The audio serve is a same-host process, such as MLX Audio on a Mac Mini or laptop. Starts start_command without a shell, writes pid_file, logs to log_file, and probes /models. Stops the PID it started; if no PID is present but the endpoint is up, uses optional stop_command.
external Another supervisor or operator owns the process. Skips lifecycle and reports that it was skipped. Skips lifecycle and reports that it was skipped.

Native lifecycle commands are trusted operator manifest content, similar to a serves.toml up command. They are parsed with shlex and executed as argv without a shell.

Keep audio serves out of the shared model-serves manifest

Declare managed STT/TTS serves in a separate manifest (for example serves.voice.toml, referenced from the voice manifest via manifest_path), not in the host's main serves.toml. Generic lifecycle operations over the main manifest — anvil-serving serves down and the serves_manage MCP tool — walk every entry, stop it, and remove its container by default. If that manifest's LLM containers are already absent, the operation can complete without exposing the risk created by placing always-on audio in the same lifecycle scope. This failure mode was observed on the Primary Node reference host on 2026-07-13, when routine lifecycle work cleanly stopped both audio serves twice.

For the same reason, give the audio compose file its own explicit Compose project (name: anvil-voice-audio). Compose derives the default project from the directory, so audio services defined in a second file in the same directory share the main project and may be removed as orphans by an invocation against the other file. See examples/primary-node/serves.voice.toml and examples/primary-node/docker-compose.voice-audio.yml for the reference shape.

The bridge-port publishes follow the directory's loopback-only default: set VOICE_AUDIO_PUBLISH to the host's tailnet address (see .env.example) so Mini's realtime proxy can reach :30110/:30111; without it the audio serves bind loopback only. Audio serve entries declare engine = "audio" — the truthful non-LLM label in serves status — rather than omitting engine, which would fall back to a legacy marker guess.

Multi-device expansion

Companion Node and Primary Node are reference devices, not fixed product roles. The same voice topology can expand to other laptops or hosts when the configured endpoints are reachable over Tailscale or another private or direct network path. See Device topologies for the broader role model.

Common layouts:

  • Voice and audio on one laptop: declare all resources on that host in a non-reference topology, then run voice audio up and voice proxy run there; keep STT/TTS base_url values on 127.0.0.1; use native or managed lifecycle there.
  • Voice on one laptop, Capability Gateway on another host: keep STT/TTS local to the voice laptop and point [voice.llm].base_url at the gateway's private tailnet or direct address.
  • Voice host separate from audio host: set STT/TTS base_url values to the remote private addresses and use lifecycle = "external" unless voice audio up is being run on the audio host itself through local CLI or a controller.
  • Mini forwarding to loopback-only STT/TTS on Dark: run voice proxy bridge on Mini. It binds Mini-local 127.0.0.1 proxy ports and derives the Dark private target address and model ports from topology. Point the Mini voice profile at those loopback proxy ports.

lifecycle = "native" is intentionally same-host. It starts the manifest command on the host where anvil-serving voice audio up runs; it is not a remote shell transport. For remote lifecycle, run the command on the resource-owning host or use an anvil-serving controller on that host.

Any service bound beyond loopback needs the appropriate token env var and private network controls. Tailscale reachability is the transport requirement; it is not a replacement for router, Realtime, or controller auth.

voice proxy bridge is loopback-only. Non-loopback and wildcard listeners are rejected; there is no acknowledgement flag that weakens this invariant.

Optional Mini-local audio lab

The checked-in Mini-local topology is an optional, public same-host audio lab template. It places STT/TTS on the 16 GB Mac Mini while the LLM turn targets the Primary Node Capability Gateway in the reference design:

  • STT: http://127.0.0.1:30010/v1
  • TTS: http://127.0.0.1:30011/v1
  • LLM: the reference Dark Capability Gateway over a private network

This is not the reference OpenClaw Talk or candidate benchmark topology. Companion Node's 16 GB RAM is reserved for OpenClaw Gateway, Anvil Voice Realtime/proxy, Claude Code, and Codex. Do not run STT, TTS, or LLM model serves on Mini during reference validation. Use this manifest only when explicitly testing the optional Mini-local audio mode.

The checked-in manifest is examples/voice/companion-node.toml. It uses lifecycle = "native" for both audio endpoints and starts MLX Audio with PID and log files under /tmp/anvil-voice-mini.

This manifest is a portable public template, not a live deployment record. Install a private operator copy for another laptop or gateway host, then replace the LLM base_url, expected endpoint host, expected route/model fields, MLX Audio workdir, and lifecycle fields for that device.

The public Mini-to-Dark reference topology intentionally rejects this manifest because Mini is model-free in that design. To use the optional lab mode, supply a separate private topology that declares Mini as the STT/TTS owner and explicitly permits model workloads; do not overlay or weaken the public reference topology.

OpenClaw Anvil Voice provider

OpenClaw can use Anvil Voice as a speech-to-speech realtime provider. In that topology the OpenClaw Gateway owns the browser or call audio relay, while Anvil Voice owns STT, the configured llm.voice capability turn, and TTS:

OpenClaw Talk or Voice Call
  -> OpenClaw Gateway realtime provider "anvil"
  -> ws://127.0.0.1:8765/v1/realtime
  -> anvil-serving voice proxy run
  -> STT -> [voice.llm] Anvil Serving Capability Gateway -> TTS

Use examples/voice/openclaw-anvil-voice.toml for the public Mini gateway reference layout. It keeps the Realtime server on Mini loopback, routes the LLM turn to the reference Dark Capability Gateway over a private address, and selects topology-owned STT/TTS from the Dark host or a Mini-side proxy to Dark for reference validation. It is a portable template, not evidence of a live deployment. Mini-local STT/TTS is declared only as an explicit optional profile. The template also declares profiles for repeatable switching:

When OpenClaw sends realtime tools in session.update or response.create, Anvil Voice forwards them to the Chat Completions LLM request. If the model emits a function call, Anvil Voice surfaces the standard Realtime response.output_item.added, response.function_call_arguments.done, and response.output_item.done events, plus an OpenClaw compatibility conversation.item.done with item.type = "function_call". It waits for OpenClaw to submit the matching function_call_output, then resumes the same spoken response. This is the path used by OpenClaw's openclaw_agent_consult tool for normal agent tools, memory, workspace context, and time-sensitive information lookups.

  • dark-audio: Dark-host STT/TTS reached through private bridge ports 30110 and 30111.
  • gepard-fast-tts: Dark-host STT plus the experimental Gepard Fast TTS candidate on Dark port 39111. Gepard is Cartesia-compatible, so the TTS profile uses protocol = "gepard" and a base URL without /v1.
  • mini-dark-audio-proxy: Mini-local proxy ports 30110 and 30111 that forward to Dark-host STT/TTS. Use this only after that Mini-side proxy is actually listening.
  • mini-audio: optional Mini-local MLX Audio STT/TTS, with conversational LLM prompt. Do not use it for reference OpenClaw Talk validation or LLM candidate A/B on the 16 GB Mini.
  • mini-validation: Mini-local audio plus the intentional I understand. validation prompt.
  • Historical candidate-qwen3-32b, candidate-gemma4-12b, and candidate-gemma4-e4b profiles: retained LLM-only shortcuts from the July 2026 comparison campaign. They preserve the Dark-host audio path and point the LLM stage at candidate ports 39000 through 39002. For a new qualification, prefer the reusable overlays in examples/voice/candidates/ so audio topology and LLM candidate remain independent.

The mini-audio profile lowers voice.llm.speech_chunk_max_chars to 56 for the Mini-local TTS path. In retained July 2026 Talk measurements, this reduced first-audio latency versus the 72 character cross-topology default, while a more aggressive 48 character split produced TTS stream errors on some sentence fragments.

Public Mini-to-Dark reference operation

The checked-in public reference topology is examples/primary-node/operator-topology.toml. It is a portable design and validation contract, not a report of live placement or health. Install a private deployment copy with real addresses as ~/.anvil-serving/operator-topology.toml; the checked-in documentation addresses are intentionally not live-routable. The reference design declares Dark as the sole STT/TTS model owner and Mini as the Realtime proxy plus loopback-forwarding owner. The canonical end-to-end flow is:

Each host's deployed copy must also declare that controller's real command_host and command_runtime (or set ANVIL_COMMAND_HOST and ANVIL_COMMAND_RUNTIME). The Dark controller must identify as the Dark audio runtime; the Mini controller must identify as Mini native. A controller cannot derive or self-assert the resource owner's identity.

TOPOLOGY=~/.anvil-serving/operator-topology.toml
VOICE_CONFIG=examples/voice/openclaw-anvil-voice.toml

# Preview, then validate the topology-owned Dark audio selection.
anvil-serving voice audio up --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile dark-audio --dry-run
anvil-serving voice audio up --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile dark-audio --confirm
anvil-serving voice audio status --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile dark-audio

# In a dedicated Mini terminal, forward Mini loopback ports to Dark.
anvil-serving voice proxy bridge --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy

# Start and inspect the persistent Mini Realtime proxy.
anvil-serving voice proxy up --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy --confirm
anvil-serving voice proxy status --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy
anvil-serving voice proxy logs --topology "$TOPOLOGY" --config "$VOICE_CONFIG" --profile mini-dark-audio-proxy --tail 200

Use voice proxy run instead of proxy up when a foreground process is desired. proxy down and proxy restart require --confirm. Audio and proxy lifecycle are intentionally independent; neither command starts the other. For the separate co-located Dark manifest, voice up --dry-run and voice down --dry-run preview the managed whole-stack aggregate. They are not valid shortcuts for this Mini/Dark sequence.

Before starting the Mini Realtime proxy, make the topology-owned STT/TTS serves ready through their owning managed lifecycle. The Mini-facing dark-audio profile has external lifecycle, so voice audio up validates the manifest and reports that audio is externally managed; it does not start those serves:

anvil-serving voice profiles list --config examples/voice/openclaw-anvil-voice.toml
anvil-serving voice audio up --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio --dry-run
anvil-serving voice audio up --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio --confirm
anvil-serving voice proxy run --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio

Historical July 2026 candidate campaign examples

The following commands retain the setup used for the July 2026 Gepard and voice-LLM candidate campaign. They are reproducibility examples, not live deployment instructions or evidence that a route is active. Start candidates only through the managed lifecycle, preview every mutation, and keep any capability-alias change or promotion behind a separate human gate.

For the retained Gepard Fast TTS campaign, start it on the Dark reference host through the managed serves surface. The service requires HF_TOKEN for first-run model access. Keep that token in the shell, in ~/.env, or in a gitignored examples/primary-node/.env copied from examples/primary-node/.env.example; never commit it. anvil-serving serves fills missing command environment variables from ~/.env, then ~/.anvil-serving/.env, then the manifest-adjacent .env; shell environment variables still win. Gepard also requires a Postgres voice store, and the Dark experiment compose starts an internal gepard-postgres container with the required voices table initialized. Set GEPARD_DATABASE_URL only when you want to use an external Postgres instead of the managed local store. The checked-in Gepard defaults (TTS_GPU_MEMORY_UTILIZATION=0.12, TTS_MAX_NUM_SEQS=4) are a co-residency profile for evaluating TTS beside another Compute B workload; raise them via env vars only when the reservation ledger shows enough free VRAM.

On the Dark reference host, leave VOICE_TTS_CANDIDATE_PUBLISH unset for same-host benchmark loops:

anvil-serving serves up tts-gepard-fast --manifest examples/primary-node/serves.toml --dry-run
anvil-serving serves up tts-gepard-fast --manifest examples/primary-node/serves.toml --confirm
anvil-serving voice benchmark \
  --config examples/voice/primary-node.toml \
  --profile gepard-fast-tts \
  --evidence-out .anvil/evidence/voice-gepard-fast-tts.json

If private operator files are installed under ~/.anvil-serving, the same flow is shorter and checkout-independent:

anvil-serving serves up tts-gepard-fast --dry-run
anvil-serving serves up tts-gepard-fast --confirm

That primary-node.toml profile marks Gepard as managed and names the tts-gepard-fast serve. If Mini must reach this candidate directly, start the Dark service with VOICE_TTS_CANDIDATE_PUBLISH=100.64.0.10.

From the Mini gateway, use the OpenClaw profile only after the Dark service is up and reachable on Dark's private address:

anvil-serving voice proxy run --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile gepard-fast-tts
anvil-serving voice benchmark \
  --config examples/voice/openclaw-anvil-voice.toml \
  --profile gepard-fast-tts \
  --evidence-out .anvil/evidence/voice-gepard-fast-tts.json

The OpenClaw/Mini profile marks Gepard as external because Mini must not host or manage the model process.

For the retained candidate LLM comparison, start the matching opt-in serve through the managed serves surface. Leave VOICE_CANDIDATE_PUBLISH unset for same-host benchmark runs; set it to the Dark host's private/tailnet address only when Mini must reach the direct candidate endpoint:

anvil-serving serves up voice-qwen3-32b --manifest examples/primary-node/serves.toml --dry-run
anvil-serving serves up voice-qwen3-32b --manifest examples/primary-node/serves.toml --confirm
anvil-serving voice proxy run \
  --topology "$TOPOLOGY" \
  --config examples/voice/openclaw-anvil-voice.toml \
  --profile dark-audio \
  --candidate-overlay examples/voice/candidates/qwen3-32b-nvfp4.toml \
  --candidate qwen3-32b-nvfp4
anvil-serving voice benchmark \
  --config examples/voice/openclaw-anvil-voice.toml \
  --profile dark-audio \
  --candidate-overlay examples/voice/candidates/qwen3-32b-nvfp4.toml \
  --candidate qwen3-32b-nvfp4 \
  --evidence-out voice-evidence/qwen3-32b-dark-audio.json

Mini forwarding and OpenClaw configuration

To keep OpenClaw and Realtime on Mini while using STT/TTS on the reference Dark host, first make sure Dark's local STT/TTS endpoints are already running and reachable on the Dark host:

curl -s -o /dev/null -w "stt %{http_code}\n" http://127.0.0.1:30010/v1/models
curl -s -o /dev/null -w "tts %{http_code}\n" http://127.0.0.1:30011/v1/models

For STT, a 404 can still prove the HTTP server is listening; connection refusal means the local audio endpoint is not up.

On Mini, start the loopback-only forwarding bridge. Listener and target ports, the Dark host address, and ownership all come from topology; command-line target overrides are only for explicit diagnostics:

anvil-serving voice proxy bridge \
  --topology "$TOPOLOGY" \
  --config examples/voice/openclaw-anvil-voice.toml \
  --profile mini-dark-audio-proxy

Dark's target ports must be reachable from Mini only through the intended private network/ACL. The bridge itself never exposes Mini beyond loopback. Then run the Mini Realtime server with the proxy profile:

anvil-serving voice proxy run --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile mini-dark-audio-proxy

For a persistent service instead of a foreground terminal:

anvil-serving voice proxy up --topology "$TOPOLOGY" --config examples/voice/openclaw-anvil-voice.toml --profile mini-dark-audio-proxy --confirm

When testing a candidate LLM against Dark audio, compose the same candidate overlay with either dark-audio or mini-dark-audio-proxy.

Then render or apply the matching OpenClaw config. The --voice flag adds the Talk realtime block next to the normal anvil model provider config:

anvil-serving harness sync openclaw \
  --config configs/example.toml \
  --base-url http://100.64.0.10:8000/v1 \
  --voice \
  --voice-realtime-url ws://127.0.0.1:8765/v1/realtime \
  --voice-consult-model llm.voice \
  --voice-consult-thinking-level off \
  --voice-consult-bootstrap-context-mode lightweight \
  --out ./openclaw.anvil.json

The generated Talk config selects the OpenClaw provider id anvil and points it at the Anvil Voice Realtime server. It also pins forced OpenClaw agent consults to the low-latency llm.voice model and disables consult thinking for lower spoken-turn latency. It also keeps forced consults on OpenClaw's lightweight bootstrap path so workspace bootstrap files such as MEMORY.md are not injected into every spoken turn, without changing the session's normal selected model:

{
  talk: {
    consultModel: "llm.voice",
    consultThinkingLevel: "off",
    consultBootstrapContextMode: "lightweight",
    realtime: {
      mode: "realtime",
      transport: "gateway-relay",
      brain: "agent-consult",
      consultRouting: "force-agent-consult",
      provider: "anvil",
      providers: {
        anvil: {
          realtimeUrl: "ws://127.0.0.1:8765/v1/realtime",
          model: "llm.voice",
          silenceDurationMs: 200
        }
      }
    }
  }
}

--voice-consult-model is optional when the router config exposes the llm.voice capability alias. During configuration generation, harness sync openclaw --voice writes llm.voice when that alias is present; if it is absent, sync writes llm.primary into the generated OpenClaw config. This is a visible configuration-time selection, not per-request fallback. Anvil Voice and the Capability Gateway do not retry a failed llm.voice request on llm.primary. Pass --voice-consult-model llm.primary to select the primary LLM explicitly. --voice-consult-thinking-level defaults to off so old Talk configs that carried consultThinkingLevel: "low" are reset during sync; raise it only when an operator deliberately trades latency for reasoning. --voice-consult-bootstrap-context-mode defaults to lightweight and replaces stale talk.consultBootstrapContextMode values during sync; set it to full only when the voice workflow needs the normal OpenClaw agent bootstrap context.

Same-host Anvil Voice can omit a realtime token. If the Realtime server binds to a private/tailnet address, set voice.realtime_token_env in the voice manifest and pass --voice-api-key-env ANVIL_VOICE_REALTIME_TOKEN to the harness sync command. The emitted OpenClaw config references the env var by name; it does not contain the token value.

Realtime server

voice proxy run validates the manifest, probes the configured LLM/STT/TTS endpoints, then binds the Realtime WebSocket server at:

ws://<realtime_host>:<realtime_port>/v1/realtime

It refuses to start if required endpoints are unreachable. A 401, 403, 404, or 405 from a probe still proves that something is listening; a connection failure or 5xx response blocks startup.

Loopback binds may omit realtime_token_env for trusted local development. Non-loopback binds require a bearer token env var in the manifest.

Assistant transcript events

Audio-only sessions keep the established event stream. A client that explicitly sets output_modalities = ["audio", "text"] (the legacy modalities spelling is also accepted) additionally receives:

  1. response.output_audio_transcript.delta after each successfully synthesized TTS text chunk;
  2. one response.output_audio_transcript.done before the matching response.done.

Each delta and terminal carries response_id, item_id, output_index, and content_index. The terminal transcript equals the concatenated deltas. Text is reported when the exact selected candidate starts producing valid audio, so any conservative text normalization used by the selected TTS path is reflected without waiting for the whole synthesis chunk. Lexical boundary metadata preserves spaces between words and sentences without inventing spaces inside hard-split tokens or CJK. Failed or empty synthesis is never labeled complete. A synthesis failure or the bounded transcript-size limit produces one correlated assistant_transcript_unavailable error and no transcript terminal; synthesis failure also ends the response as failed. Raw audio, credentials, and answer text are excluded from that error.

The official SDK proof harness requests both modalities and validates delta/terminal correlation, equality, and terminal-before-response ordering. See ADR-0025.

Benchmark and validation

Use voice benchmark for a quick configured end-to-end sample. The benchmark is an endpoint client and is not owned by the Realtime proxy. Use --scope audio for an LLM-free TTS-to-STT loop that can qualify the Dark audio stage independently. For reference OpenClaw Talk and candidate A/B, keep Mini model-free and use dark-audio or mini-dark-audio-proxy. Run mini-audio only when explicitly validating the optional same-host/local-audio mode; running it from a non-gateway checkout only tests that checkout's loopback and is a topology negative control.

anvil-serving voice benchmark --config examples/voice/openclaw-anvil-voice.toml --profile dark-audio
anvil-serving voice benchmark --scope audio --config ~/.anvil-serving/voice.toml --profile dark-audio

For multi-sample STT qualification, prepare/validate the versioned corpus and run the repeated and concurrency lanes separately:

anvil-serving voice corpus prepare \
  --config ~/.anvil-serving/voice.audio-dark.toml \
  --out .anvil/evidence/stt-corpus
anvil-serving voice corpus validate \
  --manifest .anvil/evidence/stt-corpus/manifest.jsonl \
  --expected-cases 30
anvil-serving voice benchmark --scope stt \
  --config ~/.anvil-serving/voice.audio-dark.toml \
  --corpus .anvil/evidence/stt-corpus/manifest.jsonl \
  --repetitions 3 --concurrency 1 \
  --stt-candidate-overlay examples/primary-node/stt-experiments/overlays/nemotron35-asr.toml \
  --evidence-out .anvil/evidence/nemotron35-stt.json

The primary aggregate contains the 24 LibriSpeech recordings; six Kokoro-generated agent phrases are reported separately. The command writes stt-benchmark-evidence/v1 atomically and returns nonzero for incomplete runs. Use the canonical anvil-serving-stt-benchmark skill for lifecycle, restoration, decision, and publication gates.

For candidate LLM A/B, keep audio topology in --profile and compose the LLM candidate with an overlay:

anvil-serving voice benchmark \
  --config examples/voice/openclaw-anvil-voice.toml \
  --profile dark-audio \
  --candidate-overlay examples/voice/candidates/qwen3-32b-nvfp4.toml \
  --candidate qwen3-32b-nvfp4 \
  --evidence-out .anvil/evidence/voice-qwen3-32b-dark-audio.json

For a candidate that is already loaded, use the direct candidate flags instead of writing a temporary overlay. The candidate URL is relative to the host where the benchmark command runs: use http://127.0.0.1:<port>/v1 only on the model host itself, and use the Dark private address when running the benchmark from Companion Node or another gateway host:

anvil-serving voice benchmark \
  --config examples/voice/openclaw-anvil-voice.toml \
  --profile dark-audio \
  --candidate-base-url http://100.64.0.10:39000/v1 \
  --candidate-model qwen3-32b-nvfp4 \
  --candidate qwen3-32b-nvfp4-dark-direct \
  --evidence-out .anvil/evidence/voice-qwen3-32b-dark-direct.json

Do not combine --candidate-overlay with the direct candidate flags. Both paths preserve the selected audio profile and only replace [voice.llm] for the benchmark process. They do not promote the candidate, change the configured voice capability alias, or make OpenClaw use the candidate outside this explicit run. Any promotion remains a separate, human-approved managed operation.

For comparative voice-LLM qualification, pair voice benchmark with anvil-serving eval benchmark quality against the same loaded endpoint and record the final anvil-serving serves status --manifest examples/primary-node/serves.toml after restoring the declared split stack. Voice benchmark JSON is stage-latency evidence unless the STT hypothesis and WER prove semantic transcription quality for the prompt; do not treat first-audio timing alone as a model-promotion gate.

Use --profile dark-audio only after Dark-host bridge ports are listening. Use --profile mini-dark-audio-proxy only after Mini-local proxy ports 127.0.0.1:30110 and 127.0.0.1:30111 are listening on Mini and forwarding to Dark audio.

The JSON output includes first-audio latency, total turn latency, STT/LLM/TTS stage durations, STT WER, TTS RTF, output byte counts, and the observed STT/LLM text. The durable evidence envelope is voice-benchmark-evidence/v1 and records:

  • identity.profile, identity.candidate, identity.llm, identity.stt, identity.tts, and identity.route.
  • topology.profile, topology.mode, topology.endpoints, and topology.mini_model_free_assertion.
  • runs[0].latency.ttfa_ms, turn_latency_ms, total_turn_latency_ms, stt_ms, llm_ms, llm_stage_latency_ms, and tts_ms.
  • runs[0].transcript.stt_hypothesis, llm_reply, and reference_text.
  • runs[0].tool.status, successful, tool_call_count, and calls.

total_turn_latency_ms is the end-to-end STT -> LLM -> TTS duration for the sample turn. llm_stage_latency_ms is the separately timed LLM stage, so model latency can be compared without subtracting STT or TTS time. tool.status is observed when the candidate emitted a realtime tool call such as openclaw_agent_consult; a textual claim that a tool was used is not counted as a tool call. This is a smoke measurement, not a promotion gate.

For reference OpenClaw Talk evidence, topology.mini_model_free_assertion must show a reference profile such as dark-audio or mini-dark-audio-proxy, mini_hosts_models = false, and passed = true. Companion Node must remain model-free in this path: it runs OpenClaw Gateway, Anvil Voice Realtime/proxy, Claude Code, and Codex, while Primary Node owns the router, candidate LLM serves, and STT/TTS endpoints or bridge ports. Use mini-audio only for explicit optional same-host Mini-local audio validation.

Interpret stage timing before swapping models:

  • Treat a stage as dominant when its p50 elapsed time is at least half of total turn latency, or at least twice the next-largest stage across comparable successful runs.
  • Work on the LLM/model path when LLM p50 is the dominant stage, or when LLM first-output is above about 300 ms while STT and TTS first-output are below their thresholds.
  • Work on STT when STT p50 exceeds about 200 ms, WER is unacceptable, or STT errors are present.
  • Work on TTS/chunking when TTS p50 exceeds about 350 ms, TTS first-output exceeds about 250 ms, or the TTS stream errors on normal spoken chunks.
  • If no stage dominates, prefer cheaper prompt/chunk/profile tuning before loading a new model.

Historical July 2026 campaign result

The retained T005/T006 campaign evidence from July 2026 does not justify promoting a candidate LLM. The only successful timing row was gathered on the optional Mini-local audio path (ttfa_ms 611.29, turn_latency_ms 789.06, stt_ms 106.28, llm_ms 356.82, tts_ms 325.95), where LLM and TTS were co-dominant rather than a clear model-only bottleneck. Candidate rows were retained as topology negative controls because they failed before STT from a wrong-host loopback path. Gather comparable successful data with Dark-host or Mini-proxied audio before any promotion, and keep promotion behind the standard human-approved serves_promote gate. See the dated candidate matrix and final report.

For live Realtime Talk sessions, voice proxy run also emits redacted voice_stage_timing log lines for the core stt, llm, and tts stages. Use those lines to attribute latency without exposing prompts or transcripts:

voice_stage_timing stage=llm input_type=GenerateRequest turn_id=rt-turn-7 generation=12 text_chars=84 elapsed_ms=912.4 first_output_ms=488.1 output_count=2 error=false

elapsed_ms is the full stage duration for that input. first_output_ms shows when the first downstream item was available, which is the useful value for perceived first-audio latency in streaming LLM and TTS stages. Text values are logged as character counts only.

If tts first_output_ms is high for a large text_chars value, lower voice.llm.speech_chunk_max_chars in the active voice profile before changing models. That keeps the same answer path but starts TTS on smaller word-boundary chunks. For the optional Mini-local audio path, keep the checked-in mini-audio override near 56 unless fresh voice_stage_timing evidence shows a better value; values near 48 produced stream errors in the retained campaign tests. If the TTS serve closes a stream before producing any audio for a chunk, the TTS stage can retry once with a separator-safe spoken form such as up to date instead of up-to-date; failures after audio has started still surface as real stage errors.

For explicit optional Mini-local audio proof, use the hardware validation harness:

python scripts/voice/mini_validation.py --report

That report adds target-host checks, router auth proof, endpoint model identity proof, post-benchmark STT/TTS memory attribution, and a verdict. A non-Mini run is a negative control unless explicitly allowed with --allow-unsupported. This harness is not the reference OpenClaw Talk topology because Mini should remain model-free for reference validation.

MCP and controller operation

Agents and OpenClaw should prefer voice_manage for STT/TTS lifecycle:

{
  "action": "up",
  "config": "examples/voice/openclaw-anvil-voice.toml",
  "profile": "dark-audio",
  "topology": "~/.anvil-serving/operator-topology.toml"
}

The first call previews the plan and returns a dry-run command. A live mutation requires:

{
  "action": "up",
  "config": "examples/voice/openclaw-anvil-voice.toml",
  "profile": "dark-audio",
  "topology": "~/.anvil-serving/operator-topology.toml",
  "confirm": true,
  "dry_run": false
}

This mirrors serves_manage and router_manage: exact target first, then an explicit confirmed call. status and logs are immediate bounded reads and do not require confirmation. The Dark controller may set ANVIL_VOICE_TOPOLOGY instead of receiving topology on every call.

Use voice_proxy_manage for Mini's persistent Realtime process. Its actions are up, down, restart, status, and logs; mutations require the same preview/confirm sequence. Set ANVIL_VOICE_TOPOLOGY on the Mini controller or pass topology explicitly. Neither MCP tool starts the other subsystem.

Troubleshooting

  • voice audio up says lifecycle is external: change the manifest to managed or native, or keep managing that endpoint manually.
  • voice proxy run refuses to start: check the /models endpoint for the LLM, STT, and TTS base URLs named in the manifest.
  • Native voice audio down reports ready_but_unmanaged: the endpoint is answering, but no PID file or stop_command can identify what to stop.
  • Optional Mini-local STT/TTS logs are stored under /tmp/anvil-voice-mini in the checked-in Mini-local manifest.
  • Router auth errors should be fixed by setting the env var named in voice.llm.api_key_env; do not paste token values into the manifest.
  • If the assistant forgets facts from the same Talk session, verify the active profile is not setting voice.llm.history_max_turns = 0, then restart voice proxy run so the updated manifest is loaded.