A real-time voice agent built on the TEN Framework that grounds every answer in a Moss session. On each final ASR transcript, the control extension queries Moss for session-scoped context (single-digit milliseconds, in-process) and injects it into the LLM prompt before the model responds, so answers reflect your knowledge base with no perceptible added latency.
The integration is powered by the ten-moss package (MossSessionManager) and lives entirely in the main_python control extension.
| Role | Component |
|---|---|
| Transport | Agora RTC |
| Speech-to-text | Deepgram |
| LLM | OpenAI |
| Text-to-speech | ElevenLabs |
| Grounding | Moss (in-process session) |
flowchart LR
mic(["Mic"]) --> rtc_in["agora_rtc"]
rtc_in --> adapter["streamid_adapter"]
adapter --> stt["stt<br/>Deepgram"]
stt -- "asr_result (final)" --> ctl["main_control<br/>main_python"]
ctl <-. "query_context<br/>in-process, single-digit ms" .-> idx[("Moss<br/>index")]
ctl -- "grounding + user question" --> llm["llm<br/>OpenAI"]
llm --> tts["tts<br/>ElevenLabs"]
tts --> rtc_out["agora_rtc"]
rtc_out --> spk(["Speaker"])
classDef moss fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
class ctl,idx moss
Everything on the retrieval path runs inside the agent process. There is no network hop between the transcript arriving and the grounded prompt reaching the LLM.
This example ships the TEN app plus a small index builder; the run harness (playground, server, Taskfile, Dockerfile) comes from the TEN Framework, so tenapp/ drops into any TEN checkout.
tenapp/: the TEN app, i.e. the graph (property.json) and themain_pythoncontrol extension that carries the Moss delta.create_index.py+data/knowledge.jsonl: build the demo Moss index..env.example: every credential the agent needs.
- A TEN Framework checkout. This example runs with TEN's tooling and references shared TEN extensions by relative path (
../../../ten_packages/extension/...), so it must live inside a TEN Framework repo. - A Moss project (
MOSS_PROJECT_ID/MOSS_PROJECT_KEY) from moss.dev. - Provider keys: Agora (transport), Deepgram (STT), OpenAI (LLM), ElevenLabs (TTS).
-
Build the demo knowledge index (from this directory; needs only the Moss SDK):
pip install moss python-dotenv # the Moss SDK (+ python-dotenv to load .env) cp .env.example .env # fill in MOSS_PROJECT_ID / MOSS_PROJECT_KEY / MOSS_INDEX_NAME python create_index.py # reads data/knowledge.jsonl, creates MOSS_INDEX_NAME
-
Drop the app into a TEN checkout:
./setup.sh /path/to/ten-framework
The script reuses the sibling
voice-assistantexample's harness (Taskfile,scripts/, Dockerfile), swaps in thistenapp/, and seedsai_agents/.envfrom this directory's.envif you created one in step 1. To do it by hand instead:cd ten-framework/ai_agents/agents/examples cp -r voice-assistant voice-assistant-with-moss # reuse its Taskfile, scripts/, Dockerfile rm -rf voice-assistant-with-moss/tenapp cp -r /path/to/moss/apps/ten-moss/tenapp voice-assistant-with-moss/tenapp
main_pythondepends onten-moss(listed inmain_python/requirements.txt), sotask installpulls it from PyPI automatically.task installalso pre-downloads themoss-minilmembedding model (when theMOSS_*env vars are set) so the first agent session does not have to. -
Run with TEN's tooling from that example directory (
task install && task run, per the TEN docs), with theMOSS_*vars from step 1 plus the provider keys from Prerequisites (Agora, Deepgram, OpenAI, ElevenLabs). Open the TEN playground at http://localhost:3000, select thevoice_assistantgraph (apredefined_graphintenapp/property.json, or open?graph=voice_assistant), and ask something covered bydata/knowledge.jsonl, for example "how long do refunds take?", to hear grounded answers.Apple Silicon note: TEN's
ten_agent_builddev image is amd64-only. On colima, start the VM with Rosetta (colima start --vz-rosetta); under plain qemu emulation the Go toolchain segfaults duringtask install. Docker Desktop and OrbStack enable Rosetta by default.
The difference from the stock TEN voice assistant is small and lives in three places in main_python:
| Location | Change |
|---|---|
config.py |
MainControlConfig inherits MossSessionConfig (the moss_* properties). |
extension.py (on_init) |
Opens the Moss session via MossSessionManager.from_config(...).open(), best-effort. |
extension.py (_on_asr_result) |
Calls query_context(text) and prepends the grounding to the user's turn. |
Anatomy of a turn:
sequenceDiagram
autonumber
participant User
participant STT as Deepgram STT
participant Ctl as main_control
participant Moss as Moss session (in-process)
participant LLM as OpenAI LLM
participant TTS as ElevenLabs TTS
User->>STT: speech (via agora_rtc + streamid_adapter)
STT->>Ctl: asr_result (final)
Ctl->>Moss: query_context(text)
Moss-->>Ctl: grounding (single-digit ms)
Ctl->>LLM: context + [Current User Question] + text
LLM-->>TTS: streamed response
TTS-->>User: audio (via agora_rtc)
Every turn, the control extension logs the retrieval cost using the SDK's own SearchResult.time_taken_ms (surfaced by ten-moss as last_time_taken_ms), with the wall clock alongside for reference:
[retrieval-latency] backend=moss(in-process) time_taken_ms=2 (wall_clock=64ms)
In the playground transcript you see, per turn, what Moss retrieved plus the SDK time_taken_ms, followed by the LLM's answer:
🔎 Moss · retrieved in 2 ms (SDK time_taken_ms)
Relevant knowledge from Moss: [1] Refunds are processed within 3-5 business days…
<the assistant's spoken answer>
The extension also emits a per-turn latency breakdown, both as a grep-able log line and as a note in the transcript, so you can see where each turn's time goes:
[latency-breakdown] turn=3 moss_retrieval_ms=2 llm_ttft_ms=480 llm_total_ms=1150 turn_total_ms=1160
| Field | Meaning |
|---|---|
moss_retrieval_ms |
The SDK's SearchResult.time_taken_ms (in-process retrieval engine time). |
llm_ttft_ms |
Time to the LLM's first token after dispatch. |
llm_total_ms |
Full LLM generation for the turn. |
turn_total_ms |
ASR-final to LLM-final (the whole control-side turn). |
ASR timing appears in the Deepgram STT extension logs and TTS audio-out in the ElevenLabs TTS logs (both per turn in the worker log), so between those and the lines above you get the full component-by-component breakdown.
TEN's shipped memory/RAG backends (memU, OceanBase PowerRAG, EverMemOS) are remote services that pay a network round trip every turn, whereas Moss retrieves in-process, so the same grounding is a local call of single-digit milliseconds.
Moss is configured on the main_control node in tenapp/property.json (env-substituted):
| Property | Default | Description |
|---|---|---|
moss_project_id |
${env:MOSS_PROJECT_ID} |
Moss project ID. |
moss_project_key |
${env:MOSS_PROJECT_KEY} |
Moss project key (kept masked in logs). |
moss_index_name |
${env:MOSS_INDEX_NAME} |
Index to query. |
moss_model_id |
moss-minilm |
Embedding model; empty string adopts the stored index's model. |
moss_top_k |
3 |
Results retrieved per query. |
moss_alpha |
0.8 |
Hybrid search weighting (0.0 to 1.0). |
moss_context_header |
Relevant knowledge from Moss: |
Header prepended to the injected grounding. |
moss_max_context_chars |
2000 |
Cap on the injected grounding block; 0 means unlimited. |
enable_moss |
true |
Set to false to run the plain voice assistant with no grounding. |
The tenapp/ baseline (graph, main_python control extension, agent runtime, scripts) is vendored from the TEN Framework voice-assistant example at commit c385d27, licensed under Apache-2.0 (headers preserved). Only the Moss delta described above is Moss-authored.
Three small patches were applied on top of the vendored baseline: agent/decorators.py fixes the agent_event_handler annotation to type[AgentEvent]; extension.py parses session_id defensively so a non-numeric value cannot crash the ASR handler; and scripts/install_python_deps.sh fails fast (set -euo pipefail) and pre-warms the moss-minilm model cache, because two workers racing the first download corrupt the cache and grounding then silently degrades to an ungrounded assistant.
The ten-moss package is covered by offline unit tests (packages/ten-moss/tests/). This end-to-end app is not run in CI; it requires the TEN toolchain plus paid Agora, Deepgram, OpenAI, and ElevenLabs credentials, so it is validated manually via the steps above.