Voice Agents · A Complete Voice App
A real voice app: live transcription, live translation, and a tool-calling voice agent — traced with Langfuse
The big idea
A React frontend in app/ handles the microphone and the UI. A small FastAPI backend holds the OpenAI key and runs the web-search agent. Everything the agent does is recorded to Langfuse so you can watch it think. We build all of it here, from scratch.
The whole system in one picture
Notice the one dotted arrow: the backend exports OpenTelemetry spans to Langfuse. Pull it out (no keys) and everything else still works.
The playing field
| Mode | Does | Transport & model | Key trait |
|---|---|---|---|
| Transcribe | Speech → text, no reply | Browser WebRTC · gpt-realtime-whisper |
Text only, no voice back |
| Translate | Speak one lang, hear another | Backend WS proxy · gpt-realtime-translate |
Needs a backend relay |
| Assist | Talk to a tool-using agent | Browser WebRTC · gpt-realtime-2.1 |
Calls tools mid-conversation |
One live session at a time. Switching modes remounts the panel, so the old mic always stops.
Concept · one mode at a time
type Mode = "transcribe" | "translate" | "assist";
const [mode, setMode] = useState<Mode>("assist");
{mode === "transcribe" && <TranscribePanel key="transcribe" />}
{mode === "translate" && <TranslatePanel key="translate" />}
{mode === "assist" && <AssistPanel key="assist" />}
key makes React unmount the old panel and mount the new one on switch. Unmount runs cleanup, which stops the mic and closes the session. You can never leave two mics fighting.Concept · keep the key secret
load_dotenv(find_dotenv()) # shared .env
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
@app.post("/token") # for Transcribe + Assist
async def mint_token():
headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
payload = {"session": {"type": "realtime",
"model": "gpt-realtime-2.1",
"audio": {"output": {"voice": "marin"}}}}
async with httpx.AsyncClient() as c:
r = await c.post(CLIENT_SECRETS_URL,
headers=headers, json=payload)
return {"value": r.json()["value"]} # the ek_ token
/token route plus a WS /translate route. The key is loaded from a .env file via python-dotenv.Mode 1 · listen and write
dc.send(JSON.stringify({
type: "session.update",
session: {
type: "transcription", // no voice back
audio: { input: {
format: { type: "audio/pcm", rate: 24000 },
turn_detection: { type: "server_vad" },
transcription: { model: "gpt-realtime-whisper" },
} },
},
}));
...input_audio_transcription.completedresponse.output_audio_transcript.* — but in transcription mode there is no assistant, so those never fire. Audio format nests under session.audio.input at GA.Mode 2 · an honest constraint
ek_ token is scoped to a realtime session, not translation.[browser] --WS /translate--> [FastAPI]
[FastAPI] --auth WS + real key--> [OpenAI]
Mode 2 · the wire
conversation.item.input_audio_transcription.delta
# source sidecar (you)
session.output_transcript.delta # target translation
session.output_audio.delta # translated audio
gpt-realtime-whisper sidecar. Source language is auto-detected.event["delta"], not event["audio"].Mode 3 · the headline
export const getTimeTool = tool({
name: "get_time",
description: "Get the current time, optionally " +
"for an IANA zone. Use when asked the time.",
parameters: z.object({
timeZone: z.string().nullable() // strict!
.describe("e.g. 'Asia/Tokyo'. Null = local."),
}),
execute: async ({ timeZone }) => {
const zone = timeZone ||
Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new Date().toLocaleTimeString("en-US",
{ timeZone: zone, hour: "2-digit", minute: "2-digit" });
return `The time in ${zone} is ${now}.`; // observation
},
});
POST /web-search; the backend runs an Agents SDK agent (a few slides on) without exposing the key..nullable(), not .optional(). Realtime tool params must be strict or the call is rejected.Mode 3 · wire it up
const agent = new RealtimeAgent({
name: "Capstone Assistant",
instructions: "Concise voice assistant. When asked " +
"the time, call get_time and say the result. " +
"Use web_search for current or changing facts.",
tools: [getTimeTool, webSearchTool],
});
const session = new RealtimeSession(agent, {
model: "gpt-realtime-2.1",
config: {
outputModalities: ["audio"],
reasoning: { effort: "low" }, // snappy voice
},
});
await session.connect({ apiKey: ephemeralKey });
effort:"low" keeps latency down — see the course's API reference.The one new concept
The model cannot know the time on its own. The tool is its senses.
Make it visible
session.on("history_updated", (history) => {
const { lines, tools } = flattenHistory(history);
setTranscript(lines); // the conversation
setToolEvents(tools); // ACT / OBSERVE log
});
// ONE function_call item holds both:
// name + arguments -> "ACT"
// output (once set) -> "OBSERVE"
AssistPanel renders an ACT / OBSERVE log beside the transcript.history_updated fires on every partial word. Keep the handler cheap: transform and setState, nothing heavy.The web-search engine
from agents import (Agent, Runner,
WebSearchTool, set_default_openai_key)
# hand the SDK the real key once, at startup
set_default_openai_key(OPENAI_API_KEY,
use_for_tracing=False) # traces go to Langfuse
agent = Agent(
name="Web Search Delegate",
instructions="Always use web_search; answer in short "
"plain text a voice assistant can read aloud.",
model="gpt-5.6",
tools=[WebSearchTool()], # HOSTED web search
)
result = await Runner.run(agent, query) # ReAct loop
answer = result.final_output # -> {"answer": ...}
{"answer": "..."}, so the browser tool stays simple: send a query, read the answer.Observability
# startup, AFTER load_dotenv():
OpenAIAgentsInstrumentor().instrument() # auto spans
langfuse = get_client() # OTel exporter
# per request: ONE trace, explicit in/out, session, tags
with telemetry.trace("assist-web-search",
input=query, session_id=sid,
tags=["assist","web-search"], as_type="agent") as span:
result = await Runner.run(agent, query)
span.set_output(result.final_output)
telemetry.flush() # short request
session_id, tags, environment, flush.set_tracing_disabled(True) — it flattens traces (drops model/tool spans). We audited a real trace: with the flag = 1 span; without = 6.Before you deploy
/token, /web-search, and /translate stops a runaway loop from draining your quota./translate WebSocket only accepts localhost or an allow-list Origin. A browser cannot forge Origin, so this blocks cross-site abuse.CAPSTONE_API_TOKEN and every paid route needs it: Authorization: Bearer for HTTP, a {"token":...} field in the /translate first message.curl script hitting your key-spending routes.NEXT_PUBLIC_CAPSTONE_API_TOKEN is set.GET /health reports has_api_key, telemetry, and auth (true when a shared token is required).Ship it
# terminal 1: the backend (Python)
cd .../09_capstone_openai/backend
uv sync # openai-agents + langfuse
uv run uvicorn src.main:app --port 8000
# terminal 2: the UI (Node)
cd ../app && cp .env.local.example .env.local
npm install && npm run dev # localhost:3000
/health shows has_api_key:true, telemetry:true, and auth.OPENAI_API_KEY (required) + optional LANGFUSE_* in the shared .env, backend-only.telemetry:false).What you learned
final_output.load_dotenv(). Do not set_tracing_disabled(True) (flat traces). Keep names low-cardinality. Set explicit input/output, not a dump of args. Flush in short requests. Run → fetch the trace → audit → fix.The end of the course
score to each trace; filter by quality in Langfuse.You built a real voice app across three modes and made it observable — a core production skill.
Run the app, search the web, and watch the agent think in Langfuse. 🎙️ · 09_capstone_openai/app