A stateless Go service that brokers WebSockets between the browser and the Agora platform. It multiplexes real-time audio to and from Deepgram, mediates Redis pub/sub between client and AI engine, and reverse-proxies REST traffic to the Python backend.
Architecture · Event Flow · Engineering Decisions · WebSocket Protocol · Getting Started
- Overview
- Service Responsibilities
- Visual Context
- Architecture
- Engineering Decisions
- Event Flow
- WebSocket Protocol
- Redis Contract
- Concurrency Model
- Speech Services
- Authentication
- Tech Stack
- Project Structure
- Getting Started
- Environment Variables
- Development
- Deployment
- Observability
- Troubleshooting
- Roadmap
The Agora Gateway is the edge service of the Agora competitive debate platform. It is the single network destination the browser talks to, and the only service in the platform that handles real-time binary audio.
The gateway is stateless by design. All durable debate state lives in Redis and Postgres. Restarting a gateway pod does not lose any session data — the browser simply re-establishes the WebSocket and the platform continues from the Redis snapshot.
Agora is composed of three services. This repository is the network edge.
| Service | Responsibility | Stack |
|---|---|---|
| agora-frontend | Browser UI, WebSocket lifecycle, microphone capture, audio playback queue | Next.js, React, TypeScript, Zustand |
| agora-gateway (this repo) | WebSocket broker, STT/TTS multiplexer, reverse proxy, Redis state mutator | Go, Gorilla, Redis |
| agora-ai-engine | Four-phase debater, five-phase adjudicator, RAG, persistence | Python, FastAPI, LangChain, pgvector |
WebSocket termination. Holds the long-lived wss:// connection to each browser, multiplexes binary audio frames in both directions, and routes JSON command events to and from Redis.
Streaming speech-to-text. Maintains a persistent WebSocket to Deepgram per active human turn, forwards binary audio chunks upstream, and republishes transcript fragments with confidence metrics to Redis.
Streaming text-to-speech. Buffers AI-generated tokens until a sentence boundary, then makes a synchronous HTTPS call to Deepgram TTS and forwards the resulting PCM audio frame to the browser.
Redis state mutation. Atomically increments the current_turn_index field of the match_state:{id} JSON blob on END_TURN and persists the accumulated human transcript before publishing TURN_CHANGED.
Reverse proxy. Forwards /api/* HTTP traffic to the Python AI engine, injecting an X-User-ID header derived from the Supabase JWT so the backend can authorize tenant operations without re-decoding the token.
Authentication. Parses Supabase JWTs from either the Authorization header or the WebSocket query string. Extracts the sub claim and rejects unauthenticated traffic at connection upgrade.
The gateway has no UI of its own. Every real-time interaction in the screenshots below is mediated by this service.
Arena — Idle![]() |
Arena — AI Speaking![]() |
Arena — Live Streaming![]() |
Adjudication In Progress![]() |
graph TD
subgraph Browser["Browser"]
UI[Next.js Frontend<br/>Web Audio · MediaRecorder]
end
subgraph Gateway["Go Gateway · :8080 (this repository)"]
ROUTE[handler/routes.go<br/>HTTP Mux]
AUTH[middleware/auth.go<br/>JWT validation]
PROXY[proxy/router.go<br/>Reverse proxy]
WS[websocket/handler.go<br/>ServeLiveArena]
STORE[store/state.go<br/>Redis mutations]
STT[voice/deepgram.go<br/>STT stream]
TTS[voice/deepgram_tts.go<br/>TTS adapter]
ROUTE --> AUTH
AUTH --> PROXY
AUTH --> WS
WS --> STORE
WS --> STT
WS --> TTS
end
subgraph DataPlane["Data plane"]
REDIS[(Redis<br/>pub/sub + state)]
end
subgraph External["External services"]
SB[(Supabase JWT)]
DG[Deepgram<br/>STT WSS · TTS HTTPS]
EL[ElevenLabs<br/>fallback TTS]
PY[Python AI Engine<br/>:8000]
end
Browser <-->|WSS · binary + JSON| WS
Browser -->|HTTPS · /api/*| PROXY
PROXY -->|with X-User-ID| PY
AUTH -.->|verify| SB
STT <-->|WSS| DG
TTS -->|HTTPS| DG
TTS -.->|alt| EL
STORE <-->|GET / SET / PUBLISH| REDIS
WS <-->|SUBSCRIBE| REDIS
PY <-->|PUBLISH / SUBSCRIBE| REDIS
The gateway is a stateless concurrency multiplexer: per-session goroutines pipe binary audio to Deepgram, fan out Redis events as WebSocket messages, and accumulate streamed AI tokens into sentence-sized TTS requests.
In the live production environment, the system is distributed across multiple hosting tiers to optimize for real-time performance, low latency, and secure streaming:
- Host: Deployed serverlessly at
https://agora-frontend-alpha.vercel.app. - Role: Delivers the responsive, responsive web interface, handles Client state (Zustand), captures human microphone audio via browser
MediaRecorder, schedules TTS playback buffers sequentially using the Web Audio API, and interacts directly with Supabase Cloud for user sign-in/sign-up sessions.
- Host: AWS EC2 instance running Amazon Linux 2023 (
16.171.42.39.nip.io). - Nginx Reverse Proxy: Serves as the SSL/TLS termination gate (ports 80/443). Cryptographically decrypts incoming secure HTTPS/WSS traffic using a Let's Encrypt authority certificate, proxying connection queries locally to the Go gateway on
http://localhost:8080. - Go Gateway (Port 8080): A highly concurrent reverse proxy terminating long-lived WebSocket connections, validating Supabase JWT tokens, multiplexing binary audio slices to Deepgram, and coordinating Redis message routing.
- Python AI Engine (Port 8000): FastAPI server orchestrating the 4-phase debate agent, LangChain/Groq LLaMA models, pgvector searches, and WUDC adjudication.
- Redis Event Broker: Active in a Docker container acting as a Pub/Sub queue to stream token arrays instantaneously between the AI Engine and Go Gateway.
- Host: PostgreSQL + pgvector databases deployed in AWS region
ap-southeast-2(Sydney). - Role: Handles secure Supabase OAuth and stores tables containing debates, match configurations, speaker grades, and case-prep embeddings. Connects to backend containers via the dedicated, highly stable production pooler host (
aws-1-ap-southeast-2.pooler.supabase.com).
The following are the non-trivial technical decisions made while building the gateway. Each is framed as a problem and the constraint that drove the solution.
Problem. The platform's intelligence is in Python. But Python under the GIL is the wrong tool for thousands of concurrent long-lived WebSockets, low-jitter binary audio piping, and microsecond-precision token routing. Wrapping FastAPI in async still leaves us blocked on syscalls.
Solution. Push all real-time I/O to a Go service in front. The Python engine never sees a WebSocket, never holds a TCP socket, and never blocks on audio. It receives events through Redis and emits events through Redis. The gateway absorbs all connection cost.
Effect. A single Go pod handles thousands of concurrent debate sessions on modest hardware. Python's role compresses to LLM orchestration, where it excels.
Problem. LLMs emit one token at a time. TTS engines need full sentences for natural intonation. Calling TTS on every token produces robotic, choppy audio. Buffering the entire LLM response loses the typewriter UI effect that makes streaming feel responsive.
Solution. Maintain a per-session aiBuffer string. Every incoming AI_TOKEN is forwarded to the browser as text immediately. The buffer accumulates locally. When a sentence terminator (., ?, !) arrives, the buffer is flushed to Deepgram TTS as a single sentence and the resulting binary audio frame is written to the WebSocket.
Result. Text appears word-by-word (typewriter UI). Audio plays sentence-by-sentence (natural prosody). Both stream concurrently with no perceptible coupling.
aiBuffer += token
if token == "." || token == "?" || token == "!" {
audio, _ := tts.TextToSpeech(aiBuffer, currentVoiceID)
conn.WriteMessage(websocket.BinaryMessage, audio)
aiBuffer = ""
}Problem. Holding session state inside a Go pod means a redeploy or crash drops every active debate. WebSocket reconnect logic on the client alone cannot rebuild what was lost.
Solution. The gateway holds zero durable state. Every byte of debate state lives in Redis (match_state:{id}, debate:{id}:human_transcript:{idx}) or Postgres (turns, results). The browser reconnects, the gateway re-subscribes to the same Redis channel, and the platform continues where it left off.
Operational benefit. Horizontal scaling, rolling deploys, and unexpected restarts are transparent to users. Any orchestrator that can run a static binary can run several gateway instances side by side once the appropriate session-affinity and health-check configuration is in place.
Problem. A single debate session needs to do three concurrent things: read from the browser WebSocket, listen for Redis pub/sub events, and receive Deepgram transcripts. A single-threaded handler would serialize them and introduce head-of-line blocking.
Solution. Spawn three goroutines on connection:
Goroutine A — main read loop: conn.ReadMessage()
Goroutine B — Redis subscriber: pubsub.Channel()
Goroutine C — Deepgram STT (lazy): deepgram.ReceiveTranscript(callback)
Coordination is via a sync.Mutex for shared buffers and a done channel for cooperative cancellation. On disconnect, close(done) broadcasts to all listeners atomically.
Problem. Verifying a JWT cryptographically on every WebSocket connection requires either JWKS fetching (network call) or a synchronously-loaded HMAC secret. Both are expensive at the rate of thousands of connections per second.
Solution. The gateway uses ParseUnverified to extract the sub claim only. The Python backend, which the proxy forwards everything to, performs full cryptographic validation against the Supabase secret. The gateway forwards the extracted user ID as a downstream-trusted X-User-ID header.
Trade-off. This places a trust boundary at the gateway-to-Python hop. Anyone with raw network access to Python could forge X-User-ID. We mitigate by binding both services to the same internal network in production.
Problem. Two backends (Go gateway and Python engine) means two CORS configurations, two domains for the frontend to manage, and two attack surfaces.
Solution. Reverse-proxy everything. The frontend talks only to :8080. Requests to /api/* are transparently forwarded to Python at :8000 via httputil.NewSingleHostReverseProxy. The frontend never needs to know Python exists.
Problem. Opening a Deepgram WebSocket on every session connection wastes a slot per spectator or rejoin. The user might not speak at all on this session.
Solution. The Deepgram STT WebSocket is opened only on the first binary audio message of a session. If the user never speaks, no STT connection is consumed. On STOP_MIC or END_TURN, the STT connection is explicitly closed.
sequenceDiagram
autonumber
participant Human as Human
participant FE as Frontend
participant GW as Gateway (this repo)
participant DG as Deepgram
participant R as Redis
participant PY as Python AI
%% Connection
FE->>GW: WSS /ws/live?token=JWT&match_id=...
GW->>GW: ValidateJWTToken → user_id
GW->>R: SUBSCRIBE debate:{id}:turns
GW-->>FE: 101 Switching Protocols
%% Human speech
Human->>FE: speaks
loop every 250 ms
FE->>GW: BinaryMessage(audio/webm)
GW->>DG: forward chunk
DG-->>GW: { transcript, confidence }
GW->>R: PUBLISH HUMAN_TRANSCRIPT_CHUNK
R-->>FE: forwarded
R-->>PY: logged
end
%% End turn
Human->>FE: tap End Turn
FE->>GW: { action: END_TURN, human_speech_*_utc }
GW->>R: SET debate:{id}:human_transcript:{idx}
GW->>R: GET / SET match_state:{id} (turn_index + 1)
GW->>R: PUBLISH TURN_CHANGED { timing, stt_confidence }
R-->>PY: wakes consumer
%% AI generation
PY->>PY: 4-phase RAG · streaming LLM
loop each token
PY->>R: PUBLISH AI_TOKEN word
R-->>GW: subscriber notify
GW-->>FE: forward { event: AI_TOKEN, text }
Note over GW: aiBuffer += token
end
%% TTS at sentence boundaries
GW->>GW: detect . ? !
GW->>DG: TextToSpeech(sentence, voiceID)
DG-->>GW: PCM 24 kHz binary
GW-->>FE: BinaryMessage(audio)
PY->>R: PUBLISH AI_THOUGHT_COMPLETE
R-->>GW: subscriber notify
GW-->>FE: forwarded
FE->>GW: { action: END_TURN, ai_speech_*_utc }
GW->>R: PUBLISH TURN_CHANGED { ai timing }
R-->>PY: persist AI turn
A. Human audio ingress
- Frontend
MediaRecorder.ondataavailableinvokessocket.send(blob). - Gateway
conn.ReadMessagereturnswebsocket.BinaryMessage. - Lazy-init the
DeepgramServiceif it does not yet exist for this session. - Call
deepgram.SendAudio(p)to write to the Deepgram WSS. - The STT receiver goroutine emits transcripts via callback.
- Each transcript fragment is appended to
humanBufferand published asHUMAN_TRANSCRIPT_CHUNK.
B. AI token egress
- The Redis subscriber goroutine receives
AI_TOKEN. - Forward the JSON event
{event, text}to the React client viasafeWrite(). - Append the token to
aiBuffer. - If
token == ".","?", or"!", flush:
audio, _ := ttsService.TextToSpeech(aiBuffer, currentVoiceID)
conn.WriteMessage(websocket.BinaryMessage, audio)
aiBuffer = ""- On
AI_THOUGHT_COMPLETE, flush the remaining buffer once more for any trailing partial sentence.
wss://<host>:8080/ws/live?token=<JWT>&match_id=<uuid>
| Event | Type | Schema | Effect |
|---|---|---|---|
| Binary audio | binary | raw audio/webm bytes |
piped to Deepgram STT |
STOP_MIC |
text | { "action": "STOP_MIC" } |
closes the Deepgram STT socket |
END_TURN |
text | { "action": "END_TURN", "human_speech_*_utc": "...", "ai_speech_*_utc": "...", "*_duration_ms": ... } |
stores transcript and calls IncrementTurn |
POI_OFFERED |
text | { "action": "POI_OFFERED", "text": "..." } |
forwarded to Redis |
| Event | Source | Schema |
|---|---|---|
TURN_STARTED |
Python via Redis | { event, speaker, role, side, turn_index } |
AI_TOKEN |
Python via Redis | { event, text } |
AI_THOUGHT_COMPLETE |
Python via Redis | { event } |
HUMAN_TRANSCRIPT_CHUNK |
Gateway via Deepgram | { event, text, confidence } |
TURN_CHANGED |
Gateway | { action, ai_speech_*_utc, human_speech_*_utc, *_duration_ms, stt_confidence } |
MATCH_COMPLETE |
Python via Redis | { event, match_id, message } |
ADJUDICATION_COMPLETE |
Python via Redis | { event, verdict, gov_total_score, opp_total_score, ... } |
| Binary audio | Gateway via Deepgram TTS | PCM linear16 24 kHz |
| Key | Type | Owner | Purpose |
|---|---|---|---|
match_state:{matchId} |
JSON string | Python writes, Gateway patches | Schedule, current_turn_index, format |
debate:{matchId}:human_transcript:{turnIndex} |
string | Gateway writes | Final accumulated human speech for one turn |
| Channel | Pattern | Event types |
|---|---|---|
debate:{matchId}:turns |
per-match | START_MATCH, TURN_STARTED, TURN_CHANGED, AI_TOKEN, AI_THOUGHT_COMPLETE, HUMAN_TRANSCRIPT_CHUNK, MATCH_COMPLETE, ADJUDICATION_COMPLETE |
val, _ := RDB.Get(Ctx, "match_state:"+matchID).Result()
json.Unmarshal([]byte(val), &state)
state["current_turn_index"] = state["current_turn_index"].(float64) + 1
updated, _ := json.Marshal(state)
RDB.Set(Ctx, "match_state:"+matchID, updated, 0)
RDB.Publish(Ctx, "debate:"+matchID+":turns", turnChangedJSON)This is a non-atomic read/modify/write. It is safe today because exactly one frontend and one Python consumer touch any given match. To scale horizontally, replace with WATCH/MULTI/EXEC or a Lua script.
ServeLiveArena()
│
├── Goroutine A · Main loop
│ for { conn.ReadMessage() }
│ ├─ BinaryMessage → deepgram.SendAudio()
│ └─ TextMessage → switch action: STOP_MIC | END_TURN | POI_OFFERED
│
├── Goroutine B · Redis subscriber
│ for msg := range pubsub.Channel() {
│ switch event:
│ AI_TOKEN → safeWrite(text) + aiBuffer += token + maybe TTS
│ AI_THOUGHT_COMPLETE → flush aiBuffer
│ TURN_STARTED → set currentVoiceID
│ * → forward JSON to client
│ }
│
└── Goroutine C · Deepgram STT receiver (lazy)
deepgram.ReceiveTranscript(func(text, conf) {
humanBuffer += text
PUBLISH HUMAN_TRANSCRIPT_CHUNK
})
sync.MutexnamedmuguardshumanBuffer,humanConfidenceSum,humanChunkCount,aiBuffer.safeWrite()wrapsconn.WriteMessageand respects thedonechannel, preventing writes to a closed socket.doneis closed exactly once on disconnect; subscribersselecton it for early exit.
defer conn.Close()
defer pubsub.Close()
defer close(done)
defer deepgram.Close() // if initializedEndpoint: wss://api.deepgram.com/v1/listen
Query: model=nova-2 · smart_format=true · keepalive=true · endpointing=300
Auth: Authorization: Token <DEEPGRAM_API_KEY>
Response shape:
{
"channel": {
"alternatives": [
{ "transcript": "I believe the opposition", "confidence": 0.95 }
]
}
}Endpoint: POST https://api.deepgram.com/v1/speak
Query: model={voiceID} · encoding=linear16 · sample_rate=24000
Auth: Authorization: Token <DEEPGRAM_API_KEY>
Body: { "text": "..." }
Voice profile assigned per WUDC role (see internal/websocket/handler.go):
| Role | Voice ID | Gender |
|---|---|---|
| Prime Minister | aura-asteria-en |
Female |
| Leader of Opposition | aura-orion-en |
Male |
| Deputy Prime Minister | aura-luna-en |
Female |
| Deputy Leader of Opposition | aura-angus-en |
Male (Irish) |
| Member of Government | aura-hera-en |
Female |
| Member of Opposition | aura-perseus-en |
Male |
| Government Whip | aura-stella-en |
Female |
| Opposition Whip | aura-zeus-en |
Male |
| Default / unknown role | aura-asteria-en |
Female |
The voice layer is structured around a simple interface so the TTS provider is swappable:
type TTSService interface {
TextToSpeech(text string, voiceID string) ([]byte, error)
}Implementations available in internal/voice/: deepgram_tts.go, elevenlabs.go, google_tts.go, openai.go.
The handler currently wires only NewDeepgramTTSService() into the live session. The other adapters are committed and importable but not selected by configuration; swapping providers today requires a one-line change in internal/websocket/handler.go. A configurable factory keyed off an env variable is on the roadmap.
- Provider: Supabase JWT (HS256).
- Token sources:
?token=<JWT>query parameter (WebSocket) orAuthorization: Bearer <JWT>header (REST). - Validation depth: lightweight
ParseUnverified— extractsub(user_id) only. - Downstream verification: the Python engine performs full cryptographic verification.
Every /api/* request that flows through the reverse proxy receives:
X-User-ID: <sub from JWT>
The header is stamped onto the request before forwarding. Python uses it for tenant isolation without re-decoding the JWT.
| Layer | Choice | Rationale |
|---|---|---|
| Language | Go 1.26 | Goroutines, small binary, fast cold start |
| HTTP | net/http stdlib |
Zero-dep, battle-tested |
| WebSocket | gorilla/websocket 1.5.3 |
Industry standard, mature |
| Redis | redis/go-redis/v9 9.18 |
Native pub/sub, connection pooling |
| JWT | golang-jwt/jwt/v5 5.3 |
RFC 7519 claims |
| Env | joho/godotenv 1.5 |
.env loader |
| STT | Deepgram WSS | streaming transcription, nova-2 |
| TTS | Deepgram HTTPS | linear16 24 kHz, Aura voice family |
agora-gateway/
├── assets/ README screenshots
├── cmd/
│ └── api/
│ └── main.go Entry point, Redis init, HTTP server
│
├── internal/
│ ├── config/
│ │ └── config.go Env loader (PORT, REDIS_URL, etc.)
│ │
│ ├── handler/
│ │ └── routes.go /health, /api/*, /ws/live
│ │
│ ├── middleware/
│ │ ├── auth.go JWT parse, CORS, ErrorHandler
│ │ └── ...
│ │
│ ├── proxy/
│ │ └── router.go httputil.ReverseProxy to Python :8000
│ │
│ ├── store/
│ │ ├── redis.go go-redis client init
│ │ └── state.go IncrementTurn, StoreHumanTranscript
│ │
│ ├── voice/
│ │ ├── deepgram.go STT, WSS, ReceiveTranscript callback
│ │ ├── deepgram_tts.go TTS, HTTPS, linear16 24 kHz
│ │ ├── elevenlabs.go alternate TTS (MP3)
│ │ ├── openai.go alternate STT/LLM
│ │ └── google_tts.go alternate TTS
│ │
│ └── websocket/
│ ├── handler.go ServeLiveArena, three goroutines per session
│ ├── client.go Client struct (Hub scaffolding)
│ └── hub.go Hub (Hub scaffolding)
│
├── testing/
│ ├── test_deepgram.go manual STT integration test
│ └── test_elevenlabs.go manual TTS integration test
│
├── api.exe built binary (Windows)
├── go.mod gorilla/websocket, go-redis, godotenv, jwt/v5
├── go.sum
├── .env secrets (gitignored)
├── .env.example secret schema
├── agora-gateway-architecture.md detailed design analysis
└── readme.md this file
- Go 1.26 or later
- Redis 7 or later (local or Upstash)
- Deepgram API key
- Supabase project (for the JWT secret)
- Python AI engine running on
localhost:8000
git clone <repository-url>
cd agora-gateway
go mod downloadcp .env.example .env
# Edit .env with real credentialsgo build -o api.exe ./cmd/api
./api.exe # Windows
./api # Linux/macOS
# or
go run ./cmd/api/main.gocurl http://localhost:8080/health
# {"status":"ok"}// Browser console
const ws = new WebSocket("ws://localhost:8080/ws/live?token=YOUR_JWT&match_id=test-123");
ws.onmessage = (e) => console.log(e.data);
ws.send(JSON.stringify({ action: "START_MATCH" }));.env.example:
# Redis (Upstash recommended for production)
REDIS_URL=rediss://default:<pass>@<host>.upstash.io:6379
# Deepgram (STT and TTS)
DEEPGRAM_API_KEY=<deepgram_token>
# Supabase JWT
SUPABASE_JWT_SECRET=<jwt_signing_secret>
SUPABASE_KEY=<anon_key>
# Reserved for the planned self-hosted TTS fallback.
# These keys are declared in .env.example but the Voicebox adapter
# is not yet wired into the live handler — leave them unset for now.
VOICEBOX_URL=http://localhost:17493
VOICEBOX_PROFILE_ID=<profile_uuid>
VOICEBOX_ENGINE=kokoroDefaults baked into internal/config/config.go:
| Variable | Default |
|---|---|
PORT |
8080 |
PYTHON_BACKEND_URL |
http://localhost:8000 |
| Allowed origins | localhost:3000, localhost:5173 |
# https://github.com/cosmtrek/air
airgo test ./... -v
go test ./... -coverManual integration tests:
go run testing/test_deepgram.go # STT smoke
go run testing/test_elevenlabs.go # TTS smokego fmt ./...
go vet ./...
golangci-lint run ./...- Create
internal/voice/<provider>.goimplementing:
type TTSService interface {
TextToSpeech(text, voiceID string) ([]byte, error)
}- Register the implementation in
internal/websocket/handler.go. - Add env vars to
.env.exampleandinternal/config/config.go.
The gateway compiles to a single static binary. It runs anywhere a Linux or Windows executable runs — bare metal, a VM, or any managed runtime that accepts a binary. In production, we run the gateway containerized within a Docker Compose environment on an AWS EC2 instance running Amazon Linux 2023.
The live production instance runs at IP address 16.171.42.39. To handle SSL/TLS constraints and allow secure https:// / wss:// connections from the Vercel frontend, we route all incoming traffic through a reverse proxy.
We place Nginx as the "front door" of the EC2 instance, listening on ports 80 (HTTP) and 443 (HTTPS). It terminates SSL certificates and proxies the unencrypted traffic locally to our Go Gateway container at port 8080.
Below is our production Nginx site configuration (agora.conf):
server {
listen 80;
server_name 16.171.42.39.nip.io;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}We use a dynamic wildcard DNS mapping provided by nip.io to route 16.171.42.39.nip.io directly to the EC2 server IP.
To generate a secure Let's Encrypt certificate, we run:
sudo amazon-linux-extras install epel -y
sudo dnf install certbot python3-certbot-nginx -y
sudo certbot --nginx -d 16.171.42.39.nip.ioCertbot automatically updates the agora.conf server block to bind to Port 443 with the SSL certificates and maps an auto-renewal cronjob.
This repository features fully automated Continuous Deployment built with GitHub Actions. Whenever code is pushed or merged into the main branch, a workflow automatically builds and restarts the gateway service on the EC2 instance.
name: Deploy to AWS EC2
on:
push:
branches:
- main # Triggers when you push to main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to EC2 via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: 16.171.42.39
username: ec2-user
key: ${{ secrets.EC2_SSH_KEY }}
port: 22
script: |
# 1. Pull the latest code
cd ~/agora-gateway
git pull origin main
# 2. Rebuild the specific gateway container
cd ~
docker compose up -d --build gateway
# 3. Clean up dangling images to save disk space
docker image prune -fTo set this up:
- Navigate to Settings ➔ Secrets and variables ➔ Actions in the GitHub repository.
- Add a new repository secret named
EC2_SSH_KEYcontaining the contents of youragora-key.pemprivate key.
# Linux amd64
GOOS=linux GOARCH=amd64 go build -o agora-gateway ./cmd/api
# Windows amd64
GOOS=windows GOARCH=amd64 go build -o agora-gateway.exe ./cmd/api./agora-gatewayThe binary respects PORT and reads all credentials from environment variables. It writes structured logs to stdout.
The gateway is stateless. Every byte of debate state lives in Redis; every byte of durable state lives in Postgres. Restarting the gateway does not lose any active session — the frontend reconnects and resubscribes to the same Redis channel automatically. This makes the gateway a candidate for aggressive horizontal scaling, but two constraints apply:
- WebSockets are long-lived; a load balancer in front must forward the upgrade headers (
Connection: upgrade,Upgrade: websocket). - If you scale to more than one instance, configure the load balancer for client IP affinity so a single user stays pinned to one gateway pod for the duration of their session. Otherwise their Deepgram STT stream will be sharded across pods.
REDIS_URLpoints to managed Redis (Upstash or equivalent) with TLS enabled (rediss://).DEEPGRAM_API_KEYhas both STT and TTS scopes.SUPABASE_JWT_SECRETmatches the project that issues frontend tokens.PYTHON_BACKEND_URLis reachable from the gateway's network.- CORS allow-list (
internal/config/config.go) is updated for the production domain.
Logs are prefixed by component for grep-ability:
[MAIN] starting on :8080
[WS] upgraded user=abc match=match-123
[STT] chunk text="..." confidence=0.94
[TTS] sent 23 KB to React (model=aura-asteria-en)
[STORE] turn 3 → 4 for match-123
[PROXY] POST /api/v1/matches → 201 (12ms)
[AUTH] token validated user=abc
| Metric | Purpose |
|---|---|
| Active WebSocket count | Capacity planning |
| Goroutine count | Leak detection |
| TTS round-trip latency | UX-critical |
| STT confidence histogram | Model quality |
| Redis pub/sub lag | Event loop saturation |
WebSocket close 1006 immediately on connect
Likely a JWT issue. Verify the token contains a valid sub claim:
echo "<jwt>" | cut -d. -f2 | base64 -dDeepgram handshake failed: 401 Unauthorized
DEEPGRAM_API_KEY is missing, expired, or out of quota. The gateway continues to run — STT goes offline gracefully and the user is notified.
TTS audio missing but tokens stream fine
TTS is HTTPS-blocking inside the Redis subscriber goroutine. If Deepgram TTS stalls, tokens still flow but audio lags. Check Deepgram quota and [TTS] log lines.
502 Bad Gateway from /api/*
The Python backend is unreachable.
curl http://localhost:8000/health
echo $PYTHON_BACKEND_URLRedis: connection refused
redis-cli -u "$REDIS_URL" pingFor Upstash, ensure the URL uses the rediss:// (TLS) prefix.
turn_index incremented twice
Two consumers are running on the same Redis pub/sub channel. Run exactly one Python consumer, or migrate to Redis Streams with consumer groups for exactly-once delivery.
| Area | Today | Next |
|---|---|---|
| TTS in subscriber loop | Blocks token forwarding if Deepgram stalls | Move TTS to a worker pool with bounded queue |
| Redis pub/sub | At-least-once, duplicate risk on multi-pod | Migrate to Redis Streams with consumer groups |
| STT timeout | None | Add 30 s idle timeout on Deepgram WSS |
| Hub broadcasting | Per-session only | Implement hub.go for room/spectator mode |
| Rate limiting | None | Per-user token bucket on /api/* |
| Metrics | Logs only | Prometheus /metrics endpoint |
agora-gateway-architecture.md— detailed design analysisagora-frontend— sibling Next.js arena UIagora-ai-engine— sibling Python AI engine- Deepgram API docs
- Gorilla WebSocket guide
- Redis pub/sub patterns
Built with ⚡ in Go · The Spinal Cord of Agora



