Skip to content

Repository files navigation

Agora — Live Arena powered by the Agora Gateway

Agora — Gateway

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.


Go Gorilla WebSocket Redis Deepgram Supabase Status




Architecture · Event Flow · Engineering Decisions · WebSocket Protocol · Getting Started


Table of Contents

  1. Overview
  2. Service Responsibilities
  3. Visual Context
  4. Architecture
  5. Engineering Decisions
  6. Event Flow
  7. WebSocket Protocol
  8. Redis Contract
  9. Concurrency Model
  10. Speech Services
  11. Authentication
  12. Tech Stack
  13. Project Structure
  14. Getting Started
  15. Environment Variables
  16. Development
  17. Deployment
  18. Observability
  19. Troubleshooting
  20. Roadmap

Overview

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.

Where this service sits

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

Service Responsibilities

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.


Visual Context

The gateway has no UI of its own. Every real-time interaction in the screenshots below is mediated by this service.

Arena — Idle
Arena idle
Arena — AI Speaking
AI speaking
Arena — Live Streaming
Arena streaming
Adjudication In Progress
Adjudication

Architecture

Topology

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
Loading

Pipeline in one sentence

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.

Production Deployment & Cloud Architecture

In the live production environment, the system is distributed across multiple hosting tiers to optimize for real-time performance, low latency, and secure streaming:

Deployment Architecture

1. Frontend Layer (Vercel Cloud)

  • 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.

2. Real-Time Routing & Gateways (AWS EC2 VM)

  • 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.

3. Persistence & Auth Tier (Supabase Cloud)

  • 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).

Engineering Decisions

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.

1. Go for the network edge, Python for the brain

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.

2. Sentence-boundary buffering for natural TTS

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 = ""
}

3. Stateless edge with Redis as the source of truth

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.

4. Per-session goroutine multiplexing

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.

5. Lightweight JWT parsing instead of full verification

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.

6. Single entrypoint for the frontend

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.

7. Lazy initialization of Deepgram STT

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.


Event Flow

Life of a debate turn

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
Loading

Sub-pipelines

A. Human audio ingress

  1. Frontend MediaRecorder.ondataavailable invokes socket.send(blob).
  2. Gateway conn.ReadMessage returns websocket.BinaryMessage.
  3. Lazy-init the DeepgramService if it does not yet exist for this session.
  4. Call deepgram.SendAudio(p) to write to the Deepgram WSS.
  5. The STT receiver goroutine emits transcripts via callback.
  6. Each transcript fragment is appended to humanBuffer and published as HUMAN_TRANSCRIPT_CHUNK.

B. AI token egress

  1. The Redis subscriber goroutine receives AI_TOKEN.
  2. Forward the JSON event {event, text} to the React client via safeWrite().
  3. Append the token to aiBuffer.
  4. If token == ".", "?", or "!", flush:
audio, _ := ttsService.TextToSpeech(aiBuffer, currentVoiceID)
conn.WriteMessage(websocket.BinaryMessage, audio)
aiBuffer = ""
  1. On AI_THOUGHT_COMPLETE, flush the remaining buffer once more for any trailing partial sentence.

WebSocket Protocol

Endpoint

wss://<host>:8080/ws/live?token=<JWT>&match_id=<uuid>

Inbound (Frontend → Gateway)

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

Outbound (Gateway → Frontend)

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

Redis Contract

Keys

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

Pub/sub channel

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

state.go — IncrementTurn

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.


Concurrency Model

Per-session goroutines

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
       })

Synchronization

  • sync.Mutex named mu guards humanBuffer, humanConfidenceSum, humanChunkCount, aiBuffer.
  • safeWrite() wraps conn.WriteMessage and respects the done channel, preventing writes to a closed socket.
  • done is closed exactly once on disconnect; subscribers select on it for early exit.

Cleanup

defer conn.Close()
defer pubsub.Close()
defer close(done)
defer deepgram.Close()  // if initialized

Speech Services

Deepgram STT — internal/voice/deepgram.go

Endpoint: 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 }
    ]
  }
}

Deepgram TTS — internal/voice/deepgram_tts.go

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

Pluggable TTS adapters

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.


Authentication

Strategy

  • Provider: Supabase JWT (HS256).
  • Token sources: ?token=<JWT> query parameter (WebSocket) or Authorization: Bearer <JWT> header (REST).
  • Validation depth: lightweight ParseUnverified — extract sub (user_id) only.
  • Downstream verification: the Python engine performs full cryptographic verification.

X-User-ID injection

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.


Tech Stack

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

Project Structure

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

Getting Started

Prerequisites

  • 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

Install

git clone <repository-url>
cd agora-gateway
go mod download

Configure

cp .env.example .env
# Edit .env with real credentials

Build and run

go build -o api.exe ./cmd/api
./api.exe                # Windows
./api                    # Linux/macOS
# or
go run ./cmd/api/main.go

Smoke test

curl 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" }));

Environment Variables

.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=kokoro

Defaults baked into internal/config/config.go:

Variable Default
PORT 8080
PYTHON_BACKEND_URL http://localhost:8000
Allowed origins localhost:3000, localhost:5173

Development

Hot reload

# https://github.com/cosmtrek/air
air

Tests

go test ./... -v
go test ./... -cover

Manual integration tests:

go run testing/test_deepgram.go     # STT smoke
go run testing/test_elevenlabs.go   # TTS smoke

Code quality

go fmt ./...
go vet ./...
golangci-lint run ./...

Adding a TTS provider

  1. Create internal/voice/<provider>.go implementing:
type TTSService interface {
    TextToSpeech(text, voiceID string) ([]byte, error)
}
  1. Register the implementation in internal/websocket/handler.go.
  2. Add env vars to .env.example and internal/config/config.go.

Deployment

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.

Production Cloud Architecture (AWS EC2)

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.

1. Nginx Reverse Proxy Setup

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;
    }
}

2. Let's Encrypt & Certbot SSL

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.io

Certbot automatically updates the agora.conf server block to bind to Port 443 with the SSL certificates and maps an auto-renewal cronjob.


Continuous Deployment (CD Pipeline)

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.

GitHub Actions Workflow (.github/workflows/deploy.yml)

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 -f

To set this up:

  1. Navigate to Settings ➔ Secrets and variables ➔ Actions in the GitHub repository.
  2. Add a new repository secret named EC2_SSH_KEY containing the contents of your agora-key.pem private key.

Building & Running Manually

Building

# 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

Running

./agora-gateway

The binary respects PORT and reads all credentials from environment variables. It writes structured logs to stdout.

Operational notes

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.

Pre-flight checklist

  • REDIS_URL points to managed Redis (Upstash or equivalent) with TLS enabled (rediss://).
  • DEEPGRAM_API_KEY has both STT and TTS scopes.
  • SUPABASE_JWT_SECRET matches the project that issues frontend tokens.
  • PYTHON_BACKEND_URL is reachable from the gateway's network.
  • CORS allow-list (internal/config/config.go) is updated for the production domain.

Observability

Structured logs

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

Recommended metrics

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

Troubleshooting

WebSocket close 1006 immediately on connect

Likely a JWT issue. Verify the token contains a valid sub claim:

echo "<jwt>" | cut -d. -f2 | base64 -d
Deepgram 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_URL
Redis: connection refused
redis-cli -u "$REDIS_URL" ping

For 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.


Roadmap

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

Further Reading


Built with ⚡ in Go · The Spinal Cord of Agora

About

Real-time WebSocket gateway for Agora debate platform. Multiplexes concurrent sessions, manages STT/TTS integration, and orchestrates debate state transitions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages