Content Filtering
Redact unsafe or sensitive content in the agent's responses.
Recipe prompt
Paste into Cursor, Claude Code, v0, or your coding agentYou are implementing the "Content Filtering" recipe in this project.
Read the recipe markdown first:
https://raw.githubusercontent.com/AgoraIO-Conversational-AI/recipe-agent-content-filter/main/README.md
Use the source repository for cross-reference:
https://github.com/AgoraIO-Conversational-AI/recipe-agent-content-filter
Build this recipe into the user's app using the markdown as the implementation guide. Inspect related source files through the repository links when the recipe points to them. Ask before installing new dependencies.Recipe
Rendered from the configured recipe markdown.
Agora Conversational AI — Content Filter Recipe (Python)
  
The content-filter recipe in the Agora Conversational AI recipes family. The agent's LLM stage is pointed at a mock endpoint that echoes the user's words and then runs a sentence-level keyword filter before the text is handed to TTS. Flagged sentences are replaced with "Content filtered." — so you can hear the redaction by voice just by saying a banned term. STT (Deepgram nova-3) and TTS (MiniMax) stay Agora-managed.
Zero-key: no LLM API key, no external model account.
Prerequisites
- Python 3.10+
- Bun
- Agora CLI (easiest way to get App ID + Certificate)
- ngrok (or any tunnel to expose localhost) — required so
Agora cloud can reach the /llm endpoint on the backend
Run It
# 1. Install + create the server Python venv
bun run setup
# 2. Add Agora credentials (CLI), or edit server/.env.local by hand
agora login
agora project use <your-project>
agora project env write server/.env.local
# 3. Expose the backend publicly (Agora cloud calls /llm/chat/completions)
ngrok http 8000
# 4. Add the tunnel URL to server/.env.local
# CUSTOM_LLM_URL=https://<your-tunnel>.ngrok-free.dev/llm/chat/completions
# 5. Run the backend and web
bun run devOpen http://localhost:3000 → Start Conversation → speak. Say "strawberries" (or any term in FILTER_BANNED_TERMS) to hear that sentence redacted.
Working from a clone
After bun run setup and credentials, bun run dev starts two services:
| Service | Port | Notes |
|---|---|---|
| Web (Next.js) | 3000 | Browser UI |
| Agent backend | 8000 | Token generation + agent lifecycle + mock /llm endpoint |
| API docs | 8000/docs | FastAPI auto-generated docs |
Deploy
Deploy web (Next.js) and server (a single publicly reachable FastAPI backend). The mock LLM endpoint is mounted at /llm in the same process, so Agora cloud reaches it at <public-url>/llm/chat/completions. Set AGENT_BACKEND_URL in the web deployment to point at your deployed backend.
A single-process Docker image is published to ghcr.io/AgoraIO-Conversational-AI/recipe-agent-content-filter on v* tags.
Co-public caveat: the server :8000 is now the public endpoint Agora calls (/llm), so the token endpoints are co-public; the App Certificate is only used in-memory to mint tokens (never on the wire); add auth/rate-limiting before a real deployment.docker run -d -p 8000:8000 \
-e AGORA_APP_ID=<your-app-id> \
-e AGORA_APP_CERTIFICATE=<your-certificate> \
-e CUSTOM_LLM_URL=https://<your-tunnel>.ngrok-free.dev/llm/chat/completions \
-e CUSTOM_LLM_API_KEY=any-key-here \
ghcr.io/AgoraIO-Conversational-AI/recipe-agent-content-filter:latestEnvironment variables
Backend env file: `server/.env.example`.
| Variable | Required | Default | Notes |
|---|---|---|---|
AGORA_APP_ID | ✅ | — | Agora Console → Project → App ID |
AGORA_APP_CERTIFICATE | ✅ | — | Agora Console → Project → App Certificate (server only) |
CUSTOM_LLM_URL | ✅ | — | Public chat-completions URL of your /llm endpoint (<tunnel>/llm/chat/completions). Agora cloud calls it; cannot be localhost. |
CUSTOM_LLM_API_KEY | ✅ | any-key-here | Forwarded by Agora cloud as Authorization: Bearer. Required by the CustomLLM vendor. |
CUSTOM_LLM_MODEL | filter-mock | Model name passed to your endpoint | |
AGENT_GREETING | built-in | Optional opening line override | |
PORT | 8000 | Agent backend port | |
FILTER_BANNED_TERMS | strawberries | Comma-separated list of banned terms | |
AGENT_BACKEND_URL (web deploy) | ✅ | — | Required in a deployed web app when proxying to the backend |
Commands
bun run setup # install web deps + create server/ venv
bun run dev # run backend (:8000, serves /llm) + web (:3000)
bun run doctor # prerequisite check (no creds needed)
bun run doctor:local # + .env.local + credentials + CUSTOM_LLM_URL checks
bun run verify # web-only gate (no Agora creds needed)
bun run verify:local # full local gate: backend compile + smoke tests + web build
bun run clean # remove venvs and build artifactsTests run standalone (no Agora cloud needed): pytest in server/, plus bun run verify in web/. CI runs them on Linux/macOS/Windows × Python 3.10 & 3.13.
Architecture
Browser (localhost:3000)
│ fetch /api/*
▼
Next.js ──rewrite──▶ Agent backend (server/, localhost:8000)
│ starts agent session (CustomLLM vendor)
▼
Agora ConvoAI Cloud
│ POST <CUSTOM_LLM_URL> (Authorization: Bearer)
▼
Content-filter LLM endpoint (mounted at /llm in server/, localhost:8000)
▲ public via ngrok tunnelThe browser only ever calls Next /api/*, which rewrites to the agent backend. The agent backend owns Agora tokens, agent lifecycle, and serves the content-filter LLM endpoint mounted at /llm. Because Agora cloud — not the browser — calls it, the backend must be publicly reachable (ngrok http 8000). See ARCHITECTURE.md.
Repo Map
recipe-agent-content-filter/
├── web/ # Next.js frontend (:3000)
├── server/ # Agent backend (:8000) — tokens + agent lifecycle + /llm mock endpoint
│ └── src/
│ ├── server.py # FastAPI app + /llm mount
│ ├── agent.py # Agora agent session management
│ └── llm.py # OpenAI-compatible mock; filter seam; no Agora deps
├── ARCHITECTURE.md
└── AGENTS.mdWhat You Get
- Web client — zero-config Next.js voice UI; token + session lifecycle handled
behind Next rewrites.
- Agent backend (
server/) — FastAPI service that generates Agora tokens and
drives the agent session via the CustomLLM vendor.
- API contract — the
CustomLLMvendor expects a public OpenAI-compatible
POST /chat/completions endpoint; the mock fulfils that contract.
- Output redaction behind a pluggable
moderate()seam insideserver/src/llm.py
with FILTER_BANNED_TERMS — swap the body for a real moderation model with no other changes required.
- Zero-key mock — no LLM API key or external model account needed to run the
demo.
How It Works
- The browser opens the Agora RTC channel via the Next.js web client.
- The agent backend (
:8000) generates an Agora token and starts an agent session
pointing the CustomLLM vendor at CUSTOM_LLM_URL.
- Agora cloud transcribes speech (Deepgram STT) and sends the transcript to the
content-filter LLM endpoint via POST /llm/chat/completions.
echo_reply()builds a reply that includes a sentence repeating what the user
said (the sentence that may get flagged).
filter_reply()splits the reply at sentence boundaries (.,!,?) and
passes each sentence to moderate().
moderate()checks whether any term fromFILTER_BANNED_TERMS(default:
strawberries) appears in the sentence. Returning False replaces the sentence with "Content filtered.".
- The filtered text streams back to Agora cloud via OpenAI SSE and is spoken by
MiniMax TTS.
The content generation is mocked; the filter is real code. Swap moderate()'s body for a real moderator model (e.g. call an LLM) to get the "LLM-powered" variant with no other changes.
Replacing the mock
The filter + seam live in run_agent_turn() / moderate() / filter_reply() in `server/src/llm.py`.
- Replace
moderate()'s body with a real moderator model (e.g. an LLM
classification call) to get the "LLM-powered" variant.
- Replace
echo_reply()with a real LLM call to get a fully real response pipeline
that still runs the filter.
The endpoint must keep speaking the OpenAI streaming /chat/completions contract. A production endpoint should also validate the Authorization: Bearer header.
Troubleshooting
| Problem | Fix |
|---|---|
| Agent starts but never speaks | CUSTOM_LLM_URL is not public or omits /llm/chat/completions. Use your ngrok URL. |
doctor:local warns about localhost | Replace the local URL with your public tunnel URL. |
| Local calls fail / hang under a global proxy | Configure your proxy to route 127.0.0.1, localhost, and RFC-1918 ranges DIRECT. |
Missing server/venv during verify | Run bun run setup (creates the venv). |
| Want different banned terms | Set FILTER_BANNED_TERMS=term1,term2 in server/.env.local. |
More Docs
License
Released under the MIT License.