Akool Demo Architecture — Synthesis & Learnings
For building a generic avatar platform that can swap faces and personas.
1. Executive Summary
Akool provides two public React demos that together reveal their full platform architecture:
| Streaming Avatar | Talking Avatar | |
|---|---|---|
| Repo | akool-streaming-avatar-react-demo |
akool-talking-avatar-demo |
| Interaction | Real-time, bidirectional | Batch, one-shot |
| Audio in | Browser mic (getUserMedia) | Script text → TTS API |
| Audio out | WebRTC stream from Akool | REST-generated video |
| Protocol | WebRTC (Agora/LiveKit/TRTC) | REST (axios) + Webhook |
| API version | v4 | v3 |
| Backend | None (client-only) | Flask webhook (127 lines) |
| State | Zustand + Context + EventBus | 25+ useState hooks in one file |
| Source files | 100+ (well-structured) | ~6 (monolith App.tsx) |
The Streaming Avatar demo is the architectural blueprint. The Talking Avatar is a thin reference client. Neither supports dynamic face/persona swapping, but the Streaming demo's provider abstraction layer is directly adaptable to that goal.
2. API Integration Patterns
2.1 Two-Phase Connection Model
Akool uses a universal pattern across both demos:
Phase 1: REST (configuration)
→ create session/get token/list avatars/list voices
→ Bearer or x-api-key auth
→ Returns RTC credentials
Phase 2: WebRTC (real-time)
→ Join RTC channel with credentials from Phase 1
→ All media flows peer-to-peer via the RTC provider
→ Text messages flow over the data channel
2.2 Streaming Avatar API (v4) — src/apiService.ts
A typed ApiService class (182 lines) wrapping fetch:
POST /api/open/v4/liveAvatar/session/create → Session { _id, credentials }
POST /api/open/v4/liveAvatar/session/close → void
GET /api/open/v3/language/list → Language[]
GET /api/open/v4/knowledge/list → Knowledge[]
GET /api/open/v4/voice/list?type=1|2 → Voice[]
GET /api/open/v4/liveAvatar/avatar/list → Avatar[]
Auth: Dual-mode — Bearer {token} or x-api-key: {key}, selected at construction time. All responses follow { code: 1000, msg: "ok", data: ... }. Non-1000 codes throw.
Session create is the critical call. It takes avatar_id, stream_type (agora/livekit/trtc), voice_id, knowledge_id, mode_type, language, duration, and returns provider-specific RTC credentials (agora_app_id, agora_token, agora_channel, agora_uid — or LiveKit/TRTC equivalents).
2.3 Talking Avatar API (v3) — Raw axios
POST /api/open/v3/getToken → { token }
GET /api/open/v3/avatar/list → Avatar[]
GET /api/open/v3/voice/list → Voice[]
POST /api/open/v3/tts/audio/create → audio data (TTS)
POST /api/open/v3/talkingavatar/create → async video generation
POST /api/open/v3/face-swap/create → face swap result
POST /api/open/v3/liveAvatar/avatar/create → create custom avatar
GET /api/open/v3/liveAvatar/avatar/detail → avatar status/details
Critical insight: The talking avatar demo has face-swap (/v3/face-swap/create) and avatar-creation (/v3/liveAvatar/avatar/create) endpoints that the streaming demo does NOT expose. These are the endpoints needed for dynamic persona creation.
2.4 Webhook Pattern (Talking Avatar)
The Flask webhook (talking_avatar_webhook/app.py, 127 lines) handles async callbacks:
- Akool POSTs encrypted payload to
/api/webhook - Flask decrypts with AES-CBC (
client_secretas key,client_idas IV) - Parses JSON:
{ type: "avatar", status: 3, url: "..." } - Forwards via Socket.IO to the browser
- Status 3 = complete, status 1-2 = processing
For our platform: This pattern is essential for any async operation (face generation, avatar training). We'd replace the Flask/Socket.IO relay with a more robust solution (server-side WebSocket or SSE), but the AES-CBC decryption logic is reusable as-is.
2.5 What We'd Change
| Akool pattern | Our adaptation |
|---|---|
| Direct browser → Akool REST | Proxy through our backend for credential safety |
| Webhook → Flask → Socket.IO | Webhook → our backend → WebSocket/SSE to client |
| Fixed vendor endpoints | Abstract into provider interface (same pattern they use for RTC!) |
| Token in localStorage/Zustand | Server-side session, never expose to client |
3. Voice Input Flow
3.1 Streaming Avatar — Full Pipeline
[Browser mic]
│
▼
getUserMedia({ audio: true })
│
▼
AgoraRTC.createMicrophoneAudioTrack()
│
▼
AI Denoiser (Agora SDK built-in)
│
▼
client.publish(audioTrack)
│
▼
═══ WebRTC to Agora channel ═══
│
▼
[Akool server receives audio track]
│
├── STT (speech-to-text)
├── LLM (knowledge-contextualized response)
└── TTS (voice-cloned speech)
│
▼
═══ WebRTC back to browser ═══
│
▼
[Browser receives remote audio + video tracks]
│
├── Audio → <audio> element
└── Video → <video> element (avatar animation)
Text messages take a parallel path: typed text → JSON v2 protocol ({ type, content, from, ... }) → WebRTC data channel → Akool processes → returns TTS audio + avatar video via WebRTC media tracks.
Key files:
- src/hooks/useProviderAudioControls.ts — mic toggle, noise reduction
- src/hooks/useNoiseReduction.ts — Agora AI denoiser integration
- src/providers/agora/controllers/AgoraAudioController.ts — audio publish/unpublish
- src/providers/common/CommonMessageController.ts — data channel messaging
- src/providers/common/types/message.types.ts — StreamMessage v2 schema
3.2 Talking Avatar — No Mic
No real-time voice input. Text script → TTS REST API (/v3/tts/audio/create) → returns audio file → played in browser. Video is generated server-side via /v3/talkingavatar/create and returned as a URL via webhook.
3.3 What We'd Change
| Akool approach | Our approach |
|---|---|
| Agora AI denoiser (vendor-locked) | Browser-native noise suppression (RNNoise) or pluggable denoiser |
| Audio sent raw to Akool's STT | We control STT — Whisper, Deepgram, or browser Speech API |
| Text messages on WebRTC data channel | Keep this pattern — works well for real-time text chat |
| No wake-word / VAD in demo | Add VAD for hands-free operation (Scott's preference) |
| Single voice per session | Allow voice switching mid-session (persona swapping) |
4. Avatar State Management
4.1 Five-Layer Architecture (Streaming Avatar)
┌─────────────────────────────────────┐
│ LAYER 1: Zustand Store │ Persistent config (API keys, selected avatar/voice)
│ configurationStore.ts (235 lines) │ localStorage-backed via zustand/middleware persist
├─────────────────────────────────────┤
│ LAYER 2: React Context │ Streaming lifecycle state
│ StreamingContext.tsx (374 lines) │ isJoined, isConnecting, isSpeaking, error, messages
├─────────────────────────────────────┤
│ LAYER 3: Custom Hooks │ Orchestration logic
│ useStreamingSession.ts (381 lines) │ Session creation → connect → setAvatarParams
│ useProviderAudioControls.ts │ Mic/camera lifecycle
│ useProviderVideoCamera.ts │ Camera capture
├─────────────────────────────────────┤
│ LAYER 4: ProviderManager │ Singleton — switch/connect/disconnect providers
│ ProviderManager.ts (112 lines) │ Bridges L3 ↔ L5 via EventBus
│ StreamingProviderFactory.ts │ Creates typed providers from credentials
├─────────────────────────────────────┤
│ LAYER 5: Concrete Providers │ Agora / LiveKit / TRTC
│ BaseStreamingProvider.ts (154 lines)│ Abstract class — state, EventBus, resource cleanup
│ AgoraStreamingProvider.ts │ Agora SDK integration (6 controllers)
│ LiveKitStreamingProvider.ts │ LiveKit SDK integration
│ TRTCStreamingProvider.ts │ TRTC SDK integration
└─────────────────────────────────────┘
StreamingState (the core state object):
{
isJoined: boolean, // Connected to RTC channel?
isConnecting: boolean, // In progress?
isSpeaking: boolean, // Avatar currently speaking?
participants: Participant[], // Who's in the channel?
localParticipant: Participant | null,
networkQuality: ConnectionQuality | null,
error: Error | null,
}
4.2 EventBus (Pub/Sub)
src/core/EventBus.ts (117 lines) — a typed pub/sub system:
// Typed event map (30+ events)
StreamingEventMap = {
'connection:connected' | 'connection:disconnected' | 'connection:failed'
'participant:joined' | 'participant:left' | 'participant:speaking-changed'
'media:video-published' | 'media:audio-published'
'message:received' | 'message:sent'
'avatar:response-interrupted'
'system:error' | 'system:warning' | 'system:info'
}
Each provider publishes events; the ProviderManager and StreamingContext subscribe. Events bubble up: Provider → EventBus → ProviderManager → StreamingContext → React state → UI re-render.
4.3 Callback Multiplexer Pattern
The StreamingContext uses useRef<Set<>> for callbacks instead of arrays — prevents re-render storms during registration:
// Instead of: const [callbacks, setCallbacks] = useState([])
const messageCallbacks = useRef<Set<(msg: ChatMessage) => void>>(new Set());
// Adding a listener does NOT trigger re-render
4.4 Talking Avatar — Flat useState
25+ useState calls in a single 889-line App.tsx. No stores, no context, no event bus. Appropriate for a demo but not scalable.
4.5 What We'd Add
Neither demo has a formal state machine for avatar visual states. We should add:
IDLE → LISTENING → PROCESSING → SPEAKING → IDLE
│ │ │ │
└────────┴───────────┴───────────┴── ERROR → IDLE
Each state maps to visual behavior:
- IDLE: Avatar breathing animation, slight movement
- LISTENING: Avatar leaning forward, "listening" indicator
- PROCESSING: Avatar thinking animation
- SPEAKING: Lip-sync animation driven by audio
- ERROR: Fallback expression + retry UI
For persona swapping, add: currentPersona to StreamingState, with persona-specific voice, appearance, knowledge base, and personality prompt.
5. Provider Abstraction — The Crown Jewel
5.1 Architecture
This is the most valuable pattern in the entire codebase:
┌──────────────────┐
│ ProviderManager │ Singleton — lifecycle orchestration
│ (singleton) │ switchProvider(type, creds, handlers)
└────────┬─────────┘
│
┌────────▼─────────┐
│ ProviderFactory │ Registry pattern
│ (singleton) │ createProviderWithCredentials(type, creds)
└────────┬─────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Agora │ │ LiveKit │ │ TRTC │
│Provider │ │ Provider │ │ Provider │
└────┬────┘ └────┬─────┘ └────┬─────┘
│ │ │
┌────▼────┐ ┌────▼─────┐ ┌────▼─────┐
│6 Ctrls │ │6 Ctrls │ │6 Ctrls │
│3 Strat. │ │3 Strat. │ │3 Strat. │
└─────────┘ └──────────┘ └──────────┘
5.2 Pattern Decomposition
Each provider is an instance of BaseStreamingProvider (abstract class) with:
Controllers (per-provider implementations):
- ConnectionController — join/leave channel, token management
- AudioController — mic publish/unpublish, noise reduction
- VideoController — camera publish/unpublish, remote video rendering
- EventController — SDK event → EventBus translation
- ParticipantController — join/leave/speaking detection
- StatsController — network quality, bitrate, latency
Strategies (pluggable behaviors):
- AudioStrategy — how audio tracks are created and configured
- VideoStrategy — how video tracks are created and configured
- RemoteVideoStrategy — how remote video is rendered (React component per provider)
Adapters:
- MessageAdapter — translating provider-specific data channel formats to the common StreamMessage v2 protocol
5.3 How It Maps to Our Needs
Akool's provider abstraction swaps RTC backends (Agora ↔ LiveKit ↔ TRTC). We can extend this exact pattern to swap:
Akool's pattern: Our extension:
─────────────── ──────────────
StreamingProvider AvatarProvider (interface)
├── AgoraProvider ├── AkoolProvider
├── LiveKitProvider ├── HeyGenProvider
└── TRTCProvider ├── D-IDProvider
└── CustomThreeJSProvider (our own renderer)
The same Factory + Manager + Controller architecture works. The StreamingState interface is already generic enough:
- isJoined → isConnected
- isSpeaking → isAnimating
- participants → activePersonas
5.4 ResourceManager
src/core/ResourceManager.ts — registers providers for cleanup on page unload. Simple register(obj, { cleanup }) pattern. Essential for any long-lived WebRTC or WebSocket connection.
6. Key Components (Streaming Avatar)
6.1 Component Tree
App.tsx (181 lines — thin orchestrator)
├── ConfigurationPanel API keys, avatar/voice selection, provider picker
├── VideoDisplay
│ ├── MainVideoArea
│ │ ├── AvatarContent Renders remote avatar video stream
│ │ └── RemoteVideo Provider-specific rendering (strategy pattern)
│ └── VideoOverlay Speaking indicator, connection status
├── ChatInterface
│ ├── MessageList Chat history
│ ├── ChatControls Text input, send, interrupt
│ └── useMessageHandlers Message routing hook
├── NetworkQuality Connection quality indicator
├── ModalContainer JSON editor modals, voice previews
└── NotificationContainer Error/warning toasts
6.2 Shared UI Components
Well-factored reusable components:
- DraggableOverlay — drag + resize overlay container
- ResizableContainer — resizable panel wrapper
- SpeakingIndicator — animated speaking dot
- IconButton — accessible icon button
- Tooltip — hover tooltip
- PlaceholderDisplay — empty state display
All potentially reusable with minimal modification.
7. What's Missing for Face/Persona Swapping
Neither demo supports what we want, but they expose the building blocks:
| Feature | Status | Where to look |
|---|---|---|
| Dynamic face swap | ❌ Not in demos | /v3/face-swap/create endpoint exists (Talking demo) |
| Custom avatar creation | ❌ Not in demos | /v3/liveAvatar/avatar/create endpoint exists |
| Multi-avatar session | ❌ One avatar per session | Session create takes single avatar_id |
| Persona switching mid-session | ❌ No support | Would need re-create session or server-side support |
| Voice switching mid-session | ❌ Config only at start | setAvatarParameters() picks voice at connect time |
| Knowledge base per persona | ⚠️ One KB per session | knowledge_id in session options |
| VAD (voice activity detection) | ❌ No | Agora has activeSpeaker detection but not used |
| Wake word | ❌ No | Not in scope for these demos |
| Lip-sync | ✅ Server-side | Akool handles this; we'd need our own |
| Background replacement | ✅ Configurable | background_url in session options |
| 4K/quality selection | ❌ No | videoQuality in config but not wired to SDK |
7.1 The Face Swap Endpoint (Hidden Gem)
The Talking Avatar demo includes but barely uses:
POST /api/open/v3/face-swap/create
{ target_image, source_image, token }
→ async result with swapped face image
This combined with /v3/liveAvatar/avatar/create could enable:
1. Upload a face photo → generate custom avatar
2. Create session with that avatar
3. Stream with the uploaded face
This is the pipeline for dynamic persona creation.
8. Architectural Recommendations for Our Platform
8.1 Adopt: Provider Abstraction Pattern
The Streaming Avatar's 5-layer architecture (Zustand → Context → Hooks → ProviderManager → Providers) is the single most valuable pattern. Extend it from "RTC provider" to "Avatar provider":
interface AvatarProvider {
// Lifecycle
connect(config: AvatarConfig): Promise<void>;
disconnect(): Promise<void>;
// Persona management
setPersona(persona: Persona): Promise<void>;
getPersonas(): Persona[];
// Media
sendAudio(track: AudioTrack): Promise<void>;
sendText(message: string): Promise<void>;
// State
state: AvatarState; // { isConnected, isSpeaking, currentPersona, ... }
subscribe(callback: (state: AvatarState) => void): () => void;
}
Implementations: AkoolAvatarProvider, HeyGenAvatarProvider, LocalThreeJSProvider, etc.
8.2 Adopt: EventBus with Typed Events
Copy EventBus.ts directly. The typed event map pattern prevents runtime errors from misspelled event names and gives full IDE autocomplete. The once() + auto-cleanup pattern is production-grade.
8.3 Adopt: Zustand + Persist for Configuration
configurationStore.ts with zustand/middleware/persist is the right pattern for user preferences (API keys, selected provider, voice/avatar preferences). Add partialize to exclude sensitive data from localStorage.
8.4 Adopt: Callback Multiplexer
useRef<Set<>> for callback registration — avoids re-render storms. Use everywhere you have multiple subscribers to a single event source.
8.5 Replace: Direct API Calls from Browser
Akool's demos put API credentials directly in the browser (Zustand store, env vars). For production, route all API calls through a backend proxy that:
- Holds credentials server-side
- Adds rate limiting / abuse prevention
- Transforms between vendor-specific and our unified API format
- Handles webhook callbacks securely
8.6 Replace: Flask Webhook Relay
The AES-CBC decryption logic is fine, but Flask + Socket.IO is fragile. Use a proper backend (Node/Python with persistent WebSocket or SSE).
8.7 Add: Formal State Machine
Neither demo has one. For persona swapping, we need explicit states with defined transitions. Use XState or a simple reducer:
IDLE → LISTENING → THINKING → SPEAKING
↑ │
└────────────┘ (auto-return after speech)
Any state → PERSONA_SWITCHING → IDLE (with new persona)
Any state → ERROR → IDLE (after timeout or user action)
8.8 Add: Persona Data Model
The demos have flat avatar_id + voice_id. We need:
interface Persona {
id: string;
name: string;
avatarConfig: {
provider: string; // 'akool' | 'heygen' | 'local'
avatarId: string; // Provider-specific ID
faceImage?: string; // For dynamic face swap
};
voiceConfig: {
provider: string;
voiceId: string;
speed?: number;
pitch?: number;
};
knowledgeBase: {
id: string;
systemPrompt: string; // Personality definition
context?: string; // Background knowledge
};
visualConfig: {
background?: string;
animationStyle?: 'talking' | 'gesturing' | 'idle-only';
};
}
9. Concrete Reuse Assessment
| Artifact | Reuse? | Effort | Notes |
|---|---|---|---|
EventBus.ts (117 lines) |
Copy directly | None | Zero dependencies, typed, production-quality |
BaseStreamingProvider.ts (154 lines) |
Adapt | Low | Change "streaming" → "avatar", keep state/subscribe/cleanup |
ProviderManager.ts (112 lines) |
Adapt | Low | Change to manage AvatarProviders instead of StreamingProviders |
StreamingProviderFactory.ts |
Adapt | Low | Registry pattern — swap provider types |
configurationStore.ts (235 lines) |
Adapt | Medium | Add persona config, remove streaming-specific fields |
StreamingContext.tsx (374 lines) |
Adapt | Medium | Replace with AvatarContext, similar shape |
useStreamingSession.ts (381 lines) |
Adapt | Medium | Replace with useAvatarSession, similar lifecycle |
ResourceManager.ts |
Copy directly | None | Generic resource cleanup utility |
Logger.ts |
Copy directly | None | Structured logging wrapper |
StreamingState type |
Adapt | Low | Add currentPersona, visual states |
| UI components (shared/) | Reuse | Low | DraggableOverlay, Tooltip, IconButton, SpeakingIndicator |
| Flask webhook (127 lines) | Reference only | — | AES-CBC decrypt logic useful; architecture replaced |
| Talking Avatar App.tsx | Don't reuse | — | Monolithic useState — use only as API endpoint reference |
10. Source File Index
Streaming Avatar (key files)
src/apiService.ts — Typed REST client (Bearer/x-api-key)
src/App.tsx — Thin orchestrator (181 lines)
src/core/EventBus.ts — Typed pub/sub (copy directly)
src/core/ResourceManager.ts — Cleanup registry (copy directly)
src/core/Logger.ts — Structured logging (copy directly)
src/stores/configurationStore.ts — Zustand + persist config
src/contexts/StreamingContext.tsx — React Context for streaming lifecycle
src/hooks/useStreamingSession.ts — Session orchestration hook
src/hooks/useProviderAudioControls.ts— Mic lifecycle
src/providers/BaseStreamingProvider.ts— Abstract provider (adapt)
src/providers/ProviderManager.ts — Singleton provider lifecycle
src/providers/StreamingProviderFactory.ts— Provider registry
src/providers/agora/ — Agora provider (6 controllers, 3 strategies)
src/providers/livekit/ — LiveKit provider
src/providers/trtc/ — TRTC provider
src/providers/common/ — Shared controllers/strategies/types
src/types/event.types.ts — Typed event map
src/types/streaming.types.ts — Core state types
src/types/provider.interfaces.ts — Provider interface contract
Talking Avatar (key files)
talking-avatar-frontend/src/App.tsx — 889-line monolith (API reference only)
talking-avatar-frontend/src/components/ — LanguageSelector only
talking_avatar_webhook/app.py — Flask webhook (127 lines, AES decrypt)
11. Next Steps
- Clone the provider abstraction — copy EventBus, ResourceManager, Logger, then adapt BaseStreamingProvider → AvatarProviderBase
- Design the Persona data model — schema above, with provider-agnostic interface
- Build the backend proxy — wraps Akool/HeyGen/D-ID APIs behind a unified REST + WebSocket interface
- Add the state machine — formalize avatar visual states with transitions
- Implement VAD + wake word — needed for Scott's hands-free voice-first mode
- Prototype dynamic persona swapping — Akool's face-swap + avatar-create endpoints as first target
Generated from analysis of:
- akool-streaming-avatar-react-demo (100+ source files)
- akool-talking-avatar-demo (6 source files + Flask webhook)
- 4 child research tasks (API, architecture, voice flow, state management)