The open-source voice agent stack. One agent contract, any runtime, honest costs.
Tring is the sound of an arriving call. Define a voice agent once, in plain YAML, and run it on any of three architectures without rewriting anything:
- Cascade: STT → LLM → TTS, maximum control
- Speech-to-speech: one end-to-end audio model, minimum latency
- Hybrid: S2S conversation with cascade-grade tooling
Same tools, same analytics, same cost attribution on every runtime. Switching a deployment from one architecture to another is a config change, not a migration.
pip install tringLive inference, real costs — watch the full demo with narration
Built from lessons learned running voice agents across more than a million production telephony calls, in multiple languages, where callers code-switch mid-sentence and two seconds of silence means a hangup.
- Why Tring
- Sixty-second quickstart
- Architecture
- The four latency primitives
- Choosing a runtime
- Providers
- The cost engine
- Tring Studio
- Build on top of Tring
- Project layout
- Roadmap and contributing
Latency is a correctness property. In a text agent, latency is an annoyance. In a voice agent, it is a conversational error: the caller assumes the line dropped, talks over the bot, and the turn is lost. Tring ships four primitives that make dead air structurally impossible instead of patching it after the fact.
Local-first, not local-as-demo. The default pipeline runs entirely on open models: faster-whisper for STT, Ollama for the LLM, Kokoro for TTS. Zero cloud dependencies, zero per-minute cost. Cloud providers are opt-in upgrades, wired through the same interfaces.
Honest cost accounting. Every metered unit carries an estimated flag. Exact vendor-reported usage is preferred; estimates are never silently substituted. Reports roll costs up per call, per connected minute, per conversation, and per outcome, because a 4x spread between the cheapest and most expensive provider stack is dwarfed by conversation quality. Optimize the denominator, not the rate.
Per-language provider routing. Real callers code-switch. Most ASR engines silently drop the part they cannot handle and return a clean, plausible, wrong transcript, which is invisible on every dashboard. Tring routes STT, TTS, and LLM per agent and per detected language.
# agent.yaml
name: front-desk
persona: |
You are a friendly front-desk assistant for a dental clinic.
greeting: "Hi! How can I help you today?"
language:
primary: en
runtime:
mode: cascade
routing:
default: { stt: text_input, llm: ollama, tts: kokoro }
tools:
- name: check_appointment
description: Look up a patient's next appointment by phone number.
parameters:
type: object
properties:
phone: { type: string }
required: [phone]import asyncio
from tring import AgentSpec, CallSession
from tring.runtimes.cascade import CascadeRuntime
from tring.cost.meter import CostMeter
from tring.cost.rates import DEFAULT_RATES
from tring.transports.console import ConsoleTransport
async def check_appointment(args: dict) -> dict:
return {"next": "Tuesday 3pm", "phone": args["phone"]}
async def main() -> None:
agent = AgentSpec.from_yaml("agent.yaml")
session = CallSession(agent)
runtime = CascadeRuntime(
session,
handlers={"check_appointment": check_appointment},
meter=CostMeter(session, DEFAULT_RATES),
)
await ConsoleTransport(runtime).run()
asyncio.run(main())Run ollama serve and ollama pull mistral, then run the script and start typing. That is a full agent loop: streaming generation, choreographed tool calls, cost lines on every turn. Swap text_input for faster_whisper and add the websocket transport for real audio (pip install "tring[local,transports]"). See examples/ for runnable versions.
Everything integrates through one event stream. Runtimes emit unified SessionEvents; consumers (cost metering, analytics, transports, your code) subscribe to the session and never import a concrete runtime.
flowchart LR
subgraph spec["AgentSpec (YAML)"]
P[persona]
T[tools]
L[language policy]
R[runtime + routing]
end
spec --> RA{RuntimeAdapter}
RA --> C["CascadeRuntime<br/>STT → LLM → TTS"]
RA --> S["S2SRuntime<br/>speech-to-speech"]
RA --> H["HybridRuntime<br/>S2S + cascade tools"]
C --> REG[Provider registry]
S --> REG
H --> REG
REG --> LOC["local: faster-whisper,<br/>faster-whisper-streaming, ollama, kokoro"]
REG --> CLD["cloud: deepgram,<br/>elevenlabs, openai-compatible"]
REG --> VAD["VAD: silero<br/>(turn-taking)"]
C -.emits.-> EV[(SessionEvent stream)]
S -.emits.-> EV
H -.emits.-> EV
EV --> CM[CostMeter]
EV --> AN[your analytics]
EV --> TR["transports:<br/>console, websocket, twilio"]
The sequence below is where most of Tring's value lives. The LLM answers in a strict envelope, {"speak": ..., "tool_call": ...}, with speak first. A character-level parser streams speak text to TTS while the tool call is still being generated, and the schema forces the model to plan the silence around every tool call.
sequenceDiagram
participant Caller
participant STT
participant LLM
participant Parser as SpeakToolParser
participant TTS
participant Tool
Caller->>STT: audio
STT->>LLM: final transcript (+ language lock, byte-stable)
activate LLM
LLM-->>Parser: {"speak": "Let me pull
Parser-->>TTS: "Let me pull" (streaming, ~0ms to first audio)
TTS-->>Caller: audio starts while LLM is still generating
LLM-->>Parser: that up...", "tool_call": {...}}
deactivate LLM
Parser->>Tool: choreographed call (waiting_message, spoken_mode, post_tool_response)
Note over Tool,Caller: caller hears the waiting message,<br/>never silence
Tool-->>LLM: result
LLM-->>TTS: follow-up (only if post_tool_response says it will not repeat)
Caller->>STT: barge-in mid-sentence?
Note over Parser,TTS: playback ledger splits heard vs unheard text,<br/>annotates context so the model never references<br/>words the caller did not hear
Each is an independent, framework-free module under tring/primitives/, fully unit-tested, usable outside Tring's runtimes.
| Primitive | Problem it kills | How |
|---|---|---|
speak_parser |
Waiting for complete JSON before speaking adds a full generation of latency | Character-level state machine streams the speak field to TTS mid-generation, handles escapes split across chunks, falls back to plain speech so a call never goes silent |
choreography |
Dead air during tool execution | waiting_message, spoken_mode, and post_tool_response are required fields of every tool call's schema. The model cannot call a tool without planning what the caller hears. Failures force a spoken explanation |
interruption |
TTS generates faster than audio plays, so after a barge-in the model confidently references words the caller never heard | A playback ledger tracks generated vs actually-played text, splits heard/unheard at a word boundary, and annotates context only on genuine interrupts, not normal turn-taking |
language_lock |
Models drift to English mid-conversation; the naive per-turn fix silently destroys prompt caches | The directive is folded into exactly one trailing message, keeping the prior message array byte-identical across turns (proven by a prefix-stability test) |
| Cascade | Speech-to-speech | Hybrid | |
|---|---|---|---|
| Live transcripts | ✅ mid-turn | ❌ post-call only | ❌ post-call only |
| Mid-call tool calls | ✅ | ✅ | ✅ |
| Barge-in | ✅ | ✅ | ✅ |
| Exact usage reporting | ✅ | ❌ | partial |
| Runs 100% locally | ✅ | ❌ | ❌ |
| Voice ownership / cloning control | high | provider-bound | mixed |
Each runtime declares these as RuntimeCapabilities; consumers query them instead of assuming. When an architecture has no mid-call transcripts, Tring says so in the event stream (availability: post_call) rather than pretending.
Streaming STT: Use faster_whisper_streaming for true incremental transcription (character by character) instead of buffered phrases. Starts the LLM turn before the caller finishes, saving hundreds of milliseconds of perceived latency.
VAD turn-taking: Pair cascade with Silero VAD (local, vendor-free) for natural turn-taking. The runtime stops listening and starts speaking when the caller falls silent, with configurable sensitivity.
Twilio ingress: The twilio transport bridges Twilio media streams with exact playout marks for perfect interruption reconciliation. Use runtime.ledger.mark_played() to upgrade heard/unheard splits from estimated to exact.
The triangle is real: latency, control, voice ownership. Pick two. Cascade maximizes control, S2S minimizes latency, hybrid buys voice ownership at a premium. Tring's job is making the choice reversible.
All providers are lazy-loaded. The core install pulls only pydantic and pyyaml; vendor SDKs are never imported unless selected. API keys are referenced by environment variable name, never stored in specs.
| Slot | Local | Cloud |
|---|---|---|
| STT | faster_whisper, text_input |
deepgram, assemblyai, openai_stt, sarvam |
| LLM | ollama |
openai_compatible, anthropic, gemini |
| TTS | kokoro, piper |
elevenlabs, openai_tts, sarvam_tts, cartesia |
| S2S | openai_realtime, ultravox |
Full provider reference: docs/PROVIDERS.md
Routing is per-language, because the best engine for one language is often the wrong one for another:
runtime:
mode: cascade
routing:
default: { stt: deepgram, llm: openai_compatible, tts: elevenlabs }
mr: { stt: faster_whisper, llm: ollama, tts: kokoro } # Marathi routes differentlyEvery provider reports Usage; the CostMeter prices it against a versioned rate card and emits CostRecorded events. Anything inferred rather than vendor-reported is flagged estimated: true, and cached prompt tokens are tracked separately, so a silent cache regression cannot masquerade as normal operation.
component provider units amount estimated
───────── ───────────────── ───────────── ──────── ─────────
stt deepgram 182.4 audio_s $0.0079 no
llm openai_compatible 14,200 tok_in $0.0021 no (9,800 cached)
llm openai_compatible 610 tok_out $0.0004 no
tts elevenlabs 1,240 chars $0.0372 no
───────── total $0.0476
Then the part nobody else does, the denominator ladder:
from tring.cost.report import denominator_ladder
denominator_ladder(
total_cost_all_calls=8_750.0,
dials=100_000, connected=24_000, conversations=21_000, outcomes=340,
)
# cost_per_dial=$0.0875 cost_per_connected=$0.36
# cost_per_conversation=$0.42 cost_per_outcome=$25.74Rate optimization moves the top line by percents. Conversation quality moves the bottom line by multiples. The ladder makes that visible.
The workbench: design, test, and debug agents in the browser, no audio hardware or model downloads required. The same event stream and cost metering runs live, so you see the exact choreography and price your production calls will pay.
pip install "tring[transports]"
tring studioOpens at http://localhost:8900 (watch the demo):
- Build: the agent designer (form or raw YAML), a live test console with typed turns or push-to-talk from your microphone, tool-call cards showing the choreographed waiting messages, and live cost lines with the estimated-fraction honesty chip.
- Flow: draw conversation flows on a canvas (say / ask / branch / tool / handoff / end) and compile them into numbered, transition-explicit agent instructions. The graph round-trips losslessly in the spec's metadata, and nothing saves without your explicit action.
- Sessions: every call persists as its event stream; scrub any past conversation through the same panels you use live, tool calls and costs included.
Declared tools get studio mock handlers automatically, so you can test conversation logic with zero backend code. Deployment options, containers included, are in docs/DEPLOYMENT.md.
Tring is a stack, but every layer is a public extension point. The five ways people build on it:
Any STT/LLM/TTS/S2S service or model becomes a Tring provider by implementing one streaming interface and registering a factory. Lazy-import your SDK inside methods so the core stays light:
# my_pkg/providers.py
from collections.abc import AsyncIterator
from tring.providers.base import LLMChunk, LLMProvider, Usage
from tring.providers.registry import register
@register("llm", "my_llm")
def _make(**options):
return MyLLM(model=options.get("model", "default"))
class MyLLM(LLMProvider):
name = "my_llm"
def __init__(self, model: str) -> None:
self.model = model
async def generate(self, messages, tools=None) -> AsyncIterator[LLMChunk]:
async for delta in my_sdk.stream(self.model, messages, tools):
yield LLMChunk(text=delta.text)
yield LLMChunk(text="", finish=True, usage=[
Usage(units=exact_in, unit_name="tokens_in", estimated=False),
Usage(units=exact_out, unit_name="tokens_out", estimated=False),
])Import your module once, and every agent YAML in existence can now say llm: my_llm. Ship a rate-card entry with it and the cost engine prices it automatically.
Tools are plain async functions plus a YAML declaration. Choreography is applied automatically, so your tool inherits the no-dead-air guarantee for free:
async def create_lead(args: dict) -> dict:
return await crm.upsert(phone=args["phone"], name=args.get("name"))
runtime = CascadeRuntime(session, handlers={"create_lead": create_lead})This is the layer where a CRM integration, a booking system, or an entire vertical product (admissions, collections, support) lives: your business logic, Tring's conversation machinery.
Every runtime emits the same typed events. Subscribe and build whatever you want on top: live dashboards, QA tooling, transcript stores, alerting:
async for event in session.subscribe():
match event.type:
case "user_transcript":
db.save_turn(event)
case "interruption":
metrics.incr("barge_in")
case "cost_recorded":
ledger.add(event.amount, event.estimated)
case "tool_call_completed" if not event.ok:
alerts.page(event.tool_name)An observability product for voice agents is one subscribe() loop away.
A transport is anything that moves 16kHz mono PCM in and out. The websocket server shows the pattern in about a hundred lines; a SIP/FreeSWITCH bridge, a Twilio media-streams adapter, a WebRTC gateway, or a native app all plug in the same way. Tring ships a Twilio transport for MediaStreams with playout-aware interruption tracking:
from tring.transports.websocket import serve
def factory(session):
return CascadeRuntime(session, handlers=my_handlers,
meter=CostMeter(session, my_rates))
await serve(factory, port=8765) # binary in: caller PCM, binary out: bot PCMTransports with a real playout clock can drive runtime.ledger.mark_played() directly for exact interruption reconciliation.
New architecture (a new S2S vendor, an on-device duplex model, a research pipeline)? Subclass RuntimeAdapter, emit the standard events, declare honest RuntimeCapabilities, and every existing tool, transport, cost meter, and analytics consumer works with it unchanged. The cascade runtime is written to be read; it is the reference.
Or skip the stack entirely and use the primitives à la carte: SpeakToolParser, the choreography schema, PlaybackLedger, and LanguageLock have no dependency on Tring's runtimes and drop into Pipecat, LiveKit Agents, or hand-rolled pipelines.
Full guide with contracts and testing patterns: docs/EXTENDING.md.
src/tring/
agent.py AgentSpec: the single agent contract (YAML-loadable)
events.py unified SessionEvent model, all runtimes emit these
session.py CallSession: event bus + lifecycle + injectable clock
runtimes/ base contract, cascade (reference), s2s, hybrid
providers/ streaming ABCs, registry, local/ and cloud/ implementations
primitives/ speak_parser, choreography, interruption, language_lock
cost/ rate cards, meter, reports, denominator ladder
transports/ console dev loop, websocket audio server
eval/ scripted YAML conversation tests against a real runtime
outbound/ campaign dialing, answering-machine detection, funnel reports
observability/ OpenTelemetry export, per-turn latency waterfalls, cache-regression alarms
telephony/ FreeSWITCH dialplan generation, live-call transfer to a human
knowledge/ retrieval-as-a-tool: keyword, Chroma, and Qdrant providers
tests/ 131 tests, all offline: fakes, no keys, no GPU
examples/ console chat, local audio quickstart
Quality bar: ruff clean, mypy --strict clean, every test runs without a network.
v0.2 targets true streaming STT, VAD-driven turn-taking, S2S provider maturity, and SIP ingress. See docs/ROADMAP.md and CONTRIBUTING.md. Provider contributions must include a rate-card entry and honest estimated flags; that is the house rule.
MIT © Anzal Hussain Abidi
