Skip to main content

Frontend Development Guide

This guide covers setting up and developing the Aragora Live frontend application.

Quick Reference

AspectDetails
Locationaragora/live/
FrameworkNext.js 14 (App Router)
LanguageTypeScript
StylingTailwind CSS
StateReact Context + Custom Hooks
TestingJest + RTL + Playwright
Real-timeWebSocket

Canonical Frontend

The active, production frontend lives in aragora/live/ (Next.js App Router). This is the canonical UI for new feature work and is what powers aragora.ai.

Other frontend-related directories in the repo:

  • sdk/typescript/: TypeScript SDK for API consumers (not a UI).
  • frontend/: legacy prototype with a single hook file; not wired to builds or deployments.

SDK docs: see sdk/typescript/README.md. All frontend feature work should happen in aragora/live/. Route map: see FRONTEND_ROUTES for the full UI surface.


Project Structure

aragora/live/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Homepage
│ │ ├── debate/[[...id]]/ # Debate viewer (dynamic route)
│ │ ├── debates/ # Debate list, graph, matrix views
│ │ ├── gauntlet/ # Gauntlet validation UI
│ │ ├── laboratory/ # Agent testing laboratory
│ │ ├── insights/ # Analytics dashboard
│ │ ├── replays/ # Debate replay browser
│ │ ├── auth/ # Login/register
│ │ └── billing/ # Subscription management
│ ├── components/ # React components
│ │ ├── debate-viewer/ # Live/archived debate UI
│ │ ├── deep-audit/ # Gauntlet audit views
│ │ ├── landing/ # Landing page sections
│ │ ├── shared/ # Reusable UI primitives
│ │ ├── auth/ # Auth-related components
│ │ └── billing/ # Billing components
│ ├── hooks/ # Custom React hooks
│ │ ├── useDebateWebSocket.ts # Live debate streaming
│ │ ├── useGauntletWebSocket.ts # Gauntlet streaming
│ │ ├── useApi.ts # API client
│ │ └── useFetch.ts # Data fetching
│ ├── context/ # React contexts
│ │ ├── AuthContext.tsx # Authentication state
│ │ └── FeaturesContext.tsx # Feature flags
│ ├── types/ # TypeScript types
│ │ └── events.ts # WebSocket event types
│ ├── utils/ # Utility functions
│ │ ├── supabase.ts # Supabase client
│ │ ├── sanitize.ts # HTML sanitization
│ │ └── logger.ts # Client-side logging
│ └── config.ts # Configuration
├── __tests__/ # Component tests
├── e2e/ # Playwright e2e tests
└── public/ # Static assets

Getting Started

Prerequisites

  • Node.js 18+
  • npm or pnpm
  • Running Aragora backend (aragora serve)

Setup

cd aragora/live

# Install dependencies
npm install

# Start development server
npm run dev

The frontend runs on http://localhost:3000 by default.

Environment Variables

Create .env.local for local development:

# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8765/ws

# Optional: Supabase (for debate history)
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

# Feature flags
NEXT_PUBLIC_ENABLE_STREAMING=true
NEXT_PUBLIC_ENABLE_AUDIENCE=true

# Defaults
NEXT_PUBLIC_DEFAULT_AGENTS=anthropic-api,openai-api,gemini
NEXT_PUBLIC_DEFAULT_ROUNDS=3

Development Workflow

Running Tests

# Unit/component tests
npm test

# Watch mode
npm run test:watch

# E2E tests (requires running app)
npm run test:e2e

# E2E with UI
npm run test:e2e:ui

Linting

npm run lint

Building

# Production build (uses production URLs)
npm run build

# Local build (uses localhost URLs)
npm run build:local

Key Patterns

WebSocket Hooks

The frontend uses custom hooks for real-time WebSocket communication:

import { useDebateWebSocket } from '@/hooks/useDebateWebSocket';

function DebateViewer({ debateId }: { debateId: string }) {
const {
status, // 'connecting' | 'streaming' | 'complete' | 'error'
messages, // TranscriptMessage[]
streamingMessages, // Map<string, StreamingMessage>
streamEvents, // StreamEvent[]
sendVote, // (choice: string, intensity?: number) => void
sendSuggestion, // (suggestion: string) => void
reconnect, // Manual reconnect trigger
} = useDebateWebSocket({
debateId,
wsUrl: 'ws://localhost:8765/ws',
enabled: true,
});

// Render debate UI...
}

Features:

  • Automatic reconnection with exponential backoff
  • Message deduplication
  • Token streaming with sequence ordering
  • Orphaned stream cleanup (60s timeout)

API Client

Use the useApi hook for REST API calls:

import { useApi } from '@/hooks/useApi';

function LeaderboardPanel() {
const api = useApi();
const [agents, setAgents] = useState([]);

useEffect(() => {
api.get('/api/leaderboard')
.then(setAgents)
.catch(console.error);
}, []);
}

Authentication Context

import { useAuth } from '@/context/AuthContext';

function UserMenu() {
const { user, signIn, signOut, isLoading } = useAuth();

if (isLoading) return <LoadingSpinner />;
if (!user) return <button onClick=\{signIn\}>Sign In</button>;

return <button onClick=\{signOut\}>Sign Out</button>;
}

Feature Flags

import { useFeatures } from '@/hooks/useFeatures';
import { FeatureGuard } from '@/components/FeatureGuard';

// Check programmatically
const { isEnabled } = useFeatures();
if (isEnabled('streaming')) {
// Show streaming UI
}

// Or use guard component
<FeatureGuard feature="audience">
<UserParticipation />
</FeatureGuard>

Component Conventions

File Structure

Each component should follow this pattern:

// ComponentName.tsx

'use client'; // If using client-side features

import { useState } from 'react';
import { SomeType } from '@/types/events';

interface ComponentNameProps {
prop1: string;
prop2?: number;
}

export function ComponentName({ prop1, prop2 = 0 }: ComponentNameProps) {
// Component logic
return (
<div className="...">
{/* JSX */}
</div>
);
}

Styling

Use Tailwind CSS for styling:

<div className="bg-black/80 border border-green-500/30 rounded-lg p-4">
<h2 className="text-green-400 font-mono text-lg">
Panel Title
</h2>
<p className="text-green-300/80 text-sm">
Content
</p>
</div>

The frontend uses a CRT/terminal aesthetic with:

  • Green color palette (green-400, green-500, green-300/80)
  • Black backgrounds with opacity
  • Monospace fonts (font-mono)
  • Borders with low opacity (border-green-500/30)

Shared Components

Use components from src/components/shared/:

import { PanelContainer, PanelHeader, StatusBadge } from '@/components/shared';

function MyPanel() {
return (
<PanelContainer>
<PanelHeader title="My Panel" />
<StatusBadge status="active" />
</PanelContainer>
);
}

Testing Patterns

Component Tests

// __tests__/MyComponent.test.tsx

import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { MyComponent } from '../src/components/MyComponent';

// Mock dependencies
jest.mock('../src/hooks/useApi', () => ({
useApi: () => ({
get: jest.fn().mockResolvedValue({ data: [] }),
}),
}));

describe('MyComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders correctly', () => {
render(<MyComponent />);
expect(screen.getByText('Expected Text')).toBeInTheDocument();
});

it('handles user interaction', async () => {
render(<MyComponent />);

await act(async () => {
fireEvent.click(screen.getByRole('button'));
});

await waitFor(() => {
expect(screen.getByText('Updated Text')).toBeInTheDocument();
});
});
});

WebSocket Testing

// Mock WebSocket for tests
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 3;

url: string;
readyState = MockWebSocket.CONNECTING;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;

constructor(url: string) {
this.url = url;
}

send = jest.fn();
close = jest.fn();

simulateOpen() {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}

simulateMessage(data: object) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}

global.WebSocket = MockWebSocket as any;

E2E Tests (Playwright)

// e2e/debates.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Debates', () => {
test('can view debate list', async ({ page }) => {
await page.goto('/debates');

await expect(page.getByRole('heading', { name: /debates/i }))
.toBeVisible();
});

test('can start a new debate', async ({ page }) => {
await page.goto('/');

await page.fill('[data-testid="question-input"]', 'Test question');
await page.click('[data-testid="start-debate"]');

await expect(page).toHaveURL(/\/debate\//);
});
});

Configuration

src/config.ts

Centralized configuration with environment variable overrides:

// API endpoints
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
export const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8765/ws';

// Defaults
export const DEFAULT_AGENTS = process.env.NEXT_PUBLIC_DEFAULT_AGENTS || 'anthropic-api,openai-api';
export const DEFAULT_ROUNDS = parseInt(process.env.NEXT_PUBLIC_DEFAULT_ROUNDS || '3', 10);

// Timeouts
export const API_TIMEOUT_MS = 30000;
export const WS_RECONNECT_DELAY_MS = 3000;

// Cache TTLs
export const CACHE_TTL_LEADERBOARD = 5 * 60 * 1000; // 5 minutes
export const CACHE_TTL_DEBATES = 2 * 60 * 1000; // 2 minutes

// Feature flags
export const ENABLE_STREAMING = process.env.NEXT_PUBLIC_ENABLE_STREAMING !== 'false';
export const ENABLE_AUDIENCE = process.env.NEXT_PUBLIC_ENABLE_AUDIENCE !== 'false';

Adding New Features

1. Add a New Page

# Create page directory
mkdir -p src/app/my-feature

# Create page component
cat > src/app/my-feature/page.tsx << 'EOF'
import { MyFeaturePanel } from '@/components/MyFeaturePanel';

export default function MyFeaturePage() {
return (
<main className="min-h-screen bg-black p-8">
<MyFeaturePanel />
</main>
);
}
EOF

2. Add a New Component

# Create component
cat > src/components/MyFeaturePanel.tsx << 'EOF'
'use client';

import { useState, useEffect } from 'react';
import { useApi } from '@/hooks/useApi';
import { PanelContainer, PanelHeader } from '@/components/shared';

export function MyFeaturePanel() {
const api = useApi();
const [data, setData] = useState(null);

useEffect(() => {
api.get('/api/my-feature').then(setData);
}, []);

return (
<PanelContainer>
<PanelHeader title="My Feature" />
{/* Content */}
</PanelContainer>
);
}
EOF

3. Add Tests

# Component test
cat > __tests__/MyFeaturePanel.test.tsx << 'EOF'
import { render, screen } from '@testing-library/react';
import { MyFeaturePanel } from '../src/components/MyFeaturePanel';

jest.mock('../src/hooks/useApi', () => ({
useApi: () => ({ get: jest.fn().mockResolvedValue({}) }),
}));

describe('MyFeaturePanel', () => {
it('renders panel header', () => {
render(<MyFeaturePanel />);
expect(screen.getByText('My Feature')).toBeInTheDocument();
});
});
EOF

WebSocket Event Types

The frontend handles these WebSocket event types:

Event TypeDescription
debate_startDebate initialized with task and agents
debate_endDebate completed
agent_messageFull agent response
token_startToken streaming started
token_deltaStreaming token received
token_endToken streaming completed
critiqueAgent critique of another agent
consensusConsensus status update
voteAgent vote
grounded_verdictCitation/evidence verdict
uncertainty_analysisDisagreement detection
flip_detectedPosition flip detected
audience_summaryUser participation metrics

See src/types/events.ts for full type definitions. Control messages like connection_info, loop_list, and sync are handled during connection setup (see useNomicStream).


Debugging

Enable Debug Logging

The frontend uses a custom logger that respects log levels:

import { logger } from '@/utils/logger';

logger.debug('Debug message'); // Only in development
logger.info('Info message');
logger.warn('Warning');
logger.error('Error', error);

WebSocket Debugging

Open browser DevTools and filter Network tab by "WS" to see WebSocket frames.

React DevTools

Install the React DevTools browser extension for component inspection.


Deployment

Production Build

# Build for production
npm run build

# Start production server
npm start

Environment for Production

Set these environment variables in production:

NEXT_PUBLIC_API_URL=https://api.aragora.ai
NEXT_PUBLIC_WS_URL=wss://api.aragora.ai/ws
NEXT_PUBLIC_SUPABASE_URL=your-production-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-production-key

See Also