Living document. Update this, don't create new versions.
agentwire's voice backends are a tiered model: default (built into the
portal, zero setup), custom (bring your own model behind a small HTTP
shim), and — for STT — cloud (the portal POSTs audio straight to any
OpenAI-compatible transcription API; see
stt-cloud.md, no shim involved). This page is the contract
a custom shim implements. The test of this document: you should be able to
write a working shim from this page alone, without reading agentwire source.
default |
cloud (STT only) |
custom |
|
|---|---|---|---|
| STT | browser SpeechRecognition while the Moonshine shim (tmux agentwire-stt, :8101) warms up, then host transcription via that shim once ready; + jargon-correction map |
audio upload → portal → hosted transcription API (key from env, server-side only) | audio upload → your shim (POST /transcribe) |
| TTS | portal-managed Kokoro-82M shim subprocess (tmux agentwire-kokoro, :8102, CPU, ~200MB auto-download on first portal start); browser speechSynthesis while the model warms up or if it can't load |
— | text → your shim (POST /tts) → WAV broadcast |
| Setup | none | API key in the portal env | run your shim, point config at it |
| Quality | good neural voice (32 presets, 8 languages), ~90% semantic STT accuracy | provider-grade STT (e.g. gpt-4o-mini-transcribe, ~$0.003/min) |
whatever your model can do — cloning, emotion control, GPU engines |
# ~/.agentwire/config.yaml
tts:
backend: custom
url: http://localhost:8100
stt:
backend: custom
url: http://localhost:8101Off switch: tts.backend: none / stt.backend: none turns server voice off
(say plays nothing, status reports disabled, a managed Kokoro/Moonshine shim
left by a previous portal is stopped on portal start). Same as --no-tts /
--no-stt, but it survives a restart.
Bind host: the shims (tts serve, stt serve, kokoro serve) bind
127.0.0.1 and have no authentication. To expose one on the LAN (e.g. a TTS
box the portal reaches from another machine) opt in explicitly:
tts.host (TTS and Kokoro) / stt.host (STT) in config, or --host. A
non-loopback bind prints a warning at start, and agentwire doctor flags it —
both from config and from a live lsof check, so a shim started with a flag or
TTS_HOST/KOKORO_HOST is caught too. For a remote TTS, doctor can only remind
you that tts.host must be set on that machine; it cannot read it from here.
agentwire defines the envelope, never the model's vocabulary. The mandatory surface is tiny — text in / audio out (TTS), audio in / text out (STT). Everything model-specific rides in two opaque pass-through fields that agentwire transports verbatim and never interprets:
instructions(string) — free text, effectively a prompt riding along with the transaction:"speak warmly, slightly amused". Set globally viatts.instructions/stt.instructionsin config.options(object) — arbitrary JSON. Set viatts.options/stt.options. The bundled TTS shim, for example, readsoptions.backend: kokoroto pick its engine andoptions.exaggerationfor chatterbox-style knobs.
Inline markup is the shim's business: if your model understands [laughter]
or <emotion:happy> tags embedded in text, consume them. If it doesn't,
strip unknown markup rather than speaking it literally — a capability-blind
caller must never produce audibly broken output. (agentwire's own default
tier strips standalone lowercase [tag] / <tag:value> tokens before OS or
browser synthesis.)
Request (application/json):
{
"text": "Build finished. Two tests fixed.",
"voice": "amy",
"instructions": "speak warmly",
"options": {"backend": "kokoro", "exaggeration": 0.5}
}Only text is guaranteed present. voice, instructions, options are
optional — handle their absence.
Response: 200 with Content-Type: audio/wav, body = WAV bytes.
agentwire chunks long text into sentences client-side and calls /tts per
chunk, so latency per call matters more than long-text handling.
200 with any JSON body when ready to serve. agentwire probes this (1.5s
timeout) for availability reporting and fail-fast in the say tool.
{"voices": [{"name": "amy", "duration": 10.2}]}Powers agentwire tts voices and voice pickers. Omit if your model has
no selectable voices.
{
"tool_prompt": "This TTS supports inline [laugh], [sigh] tags; use sparingly.",
"voices": ["amy", "default"],
"emotion_control": true,
"paralinguistic_tags": true,
"languages": ["English"]
}tool_prompt is the most important field — it closes the capability loop
at the producer. Discovery downstream is useless if the agent never learns
what to emit: at MCP-server start, agentwire appends tool_prompt verbatim to
the agent-facing say tool description, and at session creation it's appended
to the voice role prompt. You (the shim dev) write the prompt, the tooldef
teaches the agent, the agent emits your tags, the envelope passes them through
untouched, your shim renders them. agentwire stays a dumb pipe at every step.
Caveat: MCP tooldefs are read at MCP-server start (a separate process launched by Claude Code). Swapping shims requires a session restart to re-teach running agents.
POST /voices/{name} (multipart WAV upload — voice cloning),
DELETE /voices/{name}, GET /engines, POST /engines/{name}/load
(hot-swap). None are part of the core contract.
Request: multipart/form-data with field file — WAV, 16 kHz mono PCM16
(agentwire decodes browser WebM/Opus and resamples before upload). When
configured, instructions and options (JSON-encoded string) arrive as
additional form fields — language hints, vocabulary biasing, whatever your
model takes. Ignore them if not.
Response:
{"text": "refactor the auth module and run the tests"}Extra keys (language, duration, …) are fine; agentwire reads text.
200 when ready. {"status": "ok"} by convention; agentwire treats any 200
as healthy.
Same shape as TTS; tool_prompt is usually empty for STT.
Both bundled servers implement the full optional surface and are the worked examples for this contract:
| Shim | Source | Run | Models |
|---|---|---|---|
| TTS | agentwire/tts_server.py |
agentwire tts start (port 8100) |
kokoro (CPU), chatterbox (GPU cloning), zonos (emotion via instructions) |
| STT | agentwire/stt/stt_server.py |
agentwire stt start (port 8101) |
moonshine ONNX (fast CPU), faster-whisper |
Smoke-test either with curl:
curl -s http://localhost:8100/health
curl -s http://localhost:8100/capabilities
curl -s -X POST http://localhost:8100/tts \
-H 'Content-Type: application/json' \
-d '{"text": "hello world"}' -o /tmp/out.wav && afplay /tmp/out.wav
curl -s http://localhost:8101/health
curl -s -X POST http://localhost:8101/transcribe -F file=@sample.wavWrap any model or API. Example: a Deepgram STT shim.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["fastapi", "uvicorn", "httpx", "python-multipart"]
# ///
import os
import httpx
import uvicorn
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
DEEPGRAM_KEY = os.environ["DEEPGRAM_API_KEY"]
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/transcribe")
async def transcribe(file: UploadFile = File(...)):
audio = await file.read()
async with httpx.AsyncClient() as client:
r = await client.post(
"https://api.deepgram.com/v1/listen?model=nova-3",
headers={"Authorization": f"Token {DEEPGRAM_KEY}",
"Content-Type": "audio/wav"},
content=audio,
)
text = r.json()["results"]["channels"][0]["alternatives"][0]["transcript"]
return {"text": text}
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8101)Point config at it (stt: {backend: custom, url: http://localhost:8101}),
restart the portal — done.
- Run the bundled shim with an emotion-capable engine:
tts.options.backend: zonos-transformer. - The shim's
/capabilitiesreportsemotion_control: trueand atool_promptdescribing theinstructionsfield and any inline tags. - On MCP-server start, that prompt lands in the
saytooldef; on session creation, in the voice role. - The agent emits
say(text="[chuckle] that took a while")— the envelope carries it untouched, the engine renders an actual chuckle. - Flip back to
tts.backend: defaultand the same call speaks "that took a while" — tags stripped, never read aloud. Graceful degradation is part of the contract.