Live-Agent-Go
Live-Agent-Go is a Go toolkit that adds a voice layer to your text-based agents, transforming them into voice agents out of the box.
Features
- Plug in your existing agent: you implement the
Brain interface; Live-Agent-Go handles the voice layer around it
- Composable pipeline: swappable speech-to-text, brain, and text-to-speech components
- WebRTC transport: built-in session management with Opus audio in and out
- Live transcripts: stream user and agent text to the client over a WebRTC data channel
- End-to-end streaming: token streaming from brain through TTS, with parallel audio and text output
- Pluggable speech-to-text: built-in integrations for Google Cloud Speech, Deepgram, and OpenAI Realtime transcription, or implement your own transcriber
- Pluggable text-to-speech: built-in integrations for Google Cloud TTS and Deepgram, or implement your own synthesizer
- Voice activity detection: optional Silero VAD for server-side speech detection, or rely on VAD from your speech-to-text provider
- Barge-in / interruption: users can cut off the agent mid-response
- Controls during response generation: adding filler speech, playing background audio, marking output as non-interruptible, etc.
- Ice-breaking greetings: agent can speak first when a session connects
- Background audio mixing: mix synthesized speech with audio tracks
Build & Installation
Requirements
- Go 1.25+
libopus and libopusfile development packages (required by gopkg.in/hraban/opus.v2, used for WebRTC audio encoding)
Install native dependencies
macOS:
brew install pkg-config opus opusfile
Ubuntu:
sudo apt-get install pkg-config libopus-dev libopusfile-dev
Docker
If you build in Docker, install the native libraries before compiling (see Using in Docker (hraban/opus)).
Example multi-stage Dockerfile:
FROM golang:1.25-bookworm AS build
RUN apt-get update && apt-get install -y pkg-config libopus-dev libopusfile-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o example ./cmd/example
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libopus0 libopusfile0 ca-certificates
WORKDIR /app
COPY --from=build /app/example .
EXPOSE 8080
CMD ["./example"]
Quick Start
This example has three parts:
- HTTP server and WebRTC signaling (
main.go): hosts WebRTC voice sessions and exposes POST /offer to exchange SDP offer/answer.
- Voice agent pipeline (
main.go): a SessionAgent wired with Deepgram speech-to-text, a stub Brain, and Deepgram text-to-speech.
- Browser client (
web/index.html): captures microphone audio, sends it over WebRTC, plays the agent's audio response, and displays live transcripts over a data channel.
main.go
package main
import (
"context"
"embed"
"io"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"github.com/google/uuid"
"github.com/josephnhtam/live-agent-go/voice"
"github.com/josephnhtam/live-agent-go/voice/synthesizer"
"github.com/josephnhtam/live-agent-go/voice/transcriber"
"github.com/josephnhtam/live-agent-go/voice/transport/webrtc"
)
//go:embed web
var webFS embed.FS
type MyBrain struct{}
var _ voice.Brain = (*MyBrain)(nil)
func (b *MyBrain) Generate(ctx context.Context, prompt string, tools voice.DialogTools, tokens chan<- voice.Token) error {
if prompt == "" {
// Ice-breaking: speak first when the session connects.
tokens <- voice.Token{Text: "Hi there! How can I help you today?", MessageID: uuid.NewString()}
return nil
}
tokens <- voice.Token{Text: "I am a mock agent echoing your speech: " + prompt, MessageID: uuid.NewString()}
return nil
}
func main() {
logger := slog.Default()
manager, err := webrtc.NewManager(
webrtc.NewDefaultAPIFactory(nil),
webrtc.NewManagerOptions().WithMessageChannel("transcript-channel"), // must match browser createDataChannel('transcript-channel')
)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /offer", func(w http.ResponseWriter, r *http.Request) {
handleOffer(w, r, manager, logger)
})
webContent, _ := fs.Sub(webFS, "web")
mux.Handle("GET /", http.FileServer(http.FS(webContent)))
log.Fatal(http.ListenAndServe(":8080", mux))
}
func handleOffer(w http.ResponseWriter, r *http.Request, manager *webrtc.Manager, logger *slog.Logger) {
offerSDP, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read SDP offer", http.StatusBadRequest)
return
}
answerSDP, session, err := manager.AcceptOffer(r.Context(), string(offerSDP))
if err != nil {
http.Error(w, "failed to establish connection", http.StatusInternalServerError)
return
}
if err := runSessionAgent(r.Context(), session, logger); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/sdp")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(answerSDP))
}
func runSessionAgent(ctx context.Context, session voice.Session, logger *slog.Logger) error {
transcriber := transcriber.NewDeepgramTranscriber(
transcriber.DeepgramTranscriberConfig{APIKey: os.Getenv("DEEPGRAM_API_KEY")},
transcriber.NewDeepgramOptions(),
)
synthesizer := synthesizer.NewDeepgramSynthesizer(
synthesizer.DeepgramSynthesizerConfig{
APIKey: os.Getenv("DEEPGRAM_API_KEY"),
Model: "aura-2-thalia-en",
},
synthesizer.NewDeepgramSynthesizerOptions(),
)
sa, err := voice.NewSessionAgent(session, voice.SessionAgentConfig{
AgentConfig: voice.AgentConfig{
Transcriber: transcriber,
Synthesizer: synthesizer,
Brain: &MyBrain{},
},
Logger: logger,
}, voice.NewSessionAgentOptions().WithIceBreaking())
if err != nil {
_ = session.Close(context.Background())
return err
}
go func() {
defer sa.Close(context.Background())
_ = sa.Run(context.Background())
}()
return nil
}
web/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>live-agent-go-quick-start</title>
</head>
<body>
<p id="status">Disconnected</p>
<button id="connect">Connect</button>
<pre id="transcript"></pre>
<script>
const status = document.getElementById("status");
const transcript = document.getElementById("transcript");
const btnConnect = document.getElementById("connect");
let pc = null;
let localStream = null;
const messageOrder = [];
const messages = {};
function renderTranscript() {
transcript.textContent = messageOrder
.map((id) => {
const m = messages[id];
return (m.role === "user" ? "You: " : "Agent: ") + m.text;
})
.join("\n");
}
function handleDataMessage(raw) {
try {
const msg = JSON.parse(raw);
if (!messages[msg.message_id]) {
messages[msg.message_id] = { role: msg.role, text: "" };
messageOrder.push(msg.message_id);
}
const entry = messages[msg.message_id];
entry.text = msg.role === "user" ? msg.text : entry.text + msg.text;
renderTranscript();
} catch (e) {
console.warn("failed to parse data channel message", e);
}
}
function cleanup() {
pc?.close();
pc = null;
localStream?.getTracks().forEach((t) => t.stop());
localStream = null;
btnConnect.disabled = false;
}
async function connect() {
btnConnect.disabled = true;
status.textContent = "Connecting...";
try {
localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
pc = new RTCPeerConnection();
localStream
.getTracks()
.forEach((track) => pc.addTrack(track, localStream));
const audio = new Audio();
audio.autoplay = true;
pc.ontrack = (e) => {
audio.srcObject = e.streams[0] || new MediaStream([e.track]);
};
const dc = pc.createDataChannel("transcript-channel"); // must match manager WithMessageChannel('transcript-channel')
dc.onmessage = (e) => handleDataMessage(e.data);
pc.oniceconnectionstatechange = () => {
const s = pc.iceConnectionState;
if (s === "connected" || s === "completed") {
status.textContent = "Connected";
} else if (
s === "disconnected" ||
s === "failed" ||
s === "closed"
) {
status.textContent = "Disconnected";
cleanup();
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send the SDP offer to the server and receive the SDP answer to complete WebRTC signaling.
const resp = await fetch("/offer", {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});
if (!resp.ok) throw new Error("signaling failed: " + resp.status);
await pc.setRemoteDescription({
type: "answer",
sdp: await resp.text(),
});
} catch (err) {
console.error(err);
status.textContent = "Failed to connect";
cleanup();
}
}
btnConnect.addEventListener("click", connect);
</script>
</body>
</html>
Requires Go 1.25+ and native Opus libraries (Install native dependencies). Get a Deepgram API key (used for both STT and TTS).
go get github.com/josephnhtam/live-agent-go
export DEEPGRAM_API_KEY=your-deepgram-api-key
go run .
Open http://localhost:8080 and click Connect.
License
This project is licensed under the terms of the MIT license.