Haygen
Wiki/Architecture.md
Architecture — heygen-avatar
Package Structure
heygen-avatar/ ← npm package
src/
config/
types.ts — HeygenConfig, PersonaConfig, LiteConfig, KnowledgeSource types
validate.ts — validateConfig() — 6 runtime guards before session start
ingest/
text.ts — plain text + markdown extraction
pdf.ts — pdf-parse v2.4.5 class-based extraction
url.ts — cheerio HTML → plain text
crawler.ts — BFS web crawler with page budget
chunk.ts — assembleKnowledge() — merge + truncate to maxWords
index.ts — IngestPipeline builder (add_text/pdf/url/crawl/build)
lite/
prompt.ts — buildSystemPrompt() — 3-section HeyGen persona format
openai.ts — createOpenAIProvider() — OpenAI-compatible LLM client
loop.ts — attachLiteLoop() — USER_TALKING_MESSAGE accumulation → LLM → speak
client/
types.ts — AvatarRuntimeConfig, AvatarStatus, AvatarHandle, SpeakOptions
useHeygenAvatar.ts — headless React hook — full session lifecycle + Lite mode wiring
HeygenAvatar.tsx — <HeygenAvatar> — forwardRef, loading/error states, video
server/
types.ts — ServerConfig
handler.ts — createHeygenSessionHandler (Web Fetch API) + withExpress adapter
cli/
prompts.ts — @inquirer/prompts interactive persona wizard (10 questions)
writer.ts — writes heygen.config.ts to disk
index.ts — Commander program ("heygen-avatar init")
index.ts — public npm entry: all client exports + types + SDK enums
server.ts — server npm entry (heygen-avatar/server)
heygen_avatar/ ← Python package
__init__.py — exports create_session_token, fastapi_route, IngestPipeline
session.py — async httpx token fetch + FastAPI route factory
ingest.py — IngestPipeline (text/pdf/url/crawl), word-count aware
dist/ ← compiled output (tsup)
cli.js — 151 lines, shebang, real Commander program
index.js / .cjs / .d.ts — ESM + CJS + types for client entry
server.js / .cjs / .d.ts — ESM + CJS + types for server entry
Two Modes
| Full mode | Lite mode | |
|---|---|---|
| Who controls LLM | HeyGen (GPT-4o-mini built-in) | Developer (any OpenAI-compatible API) |
| Conversation loop | Handled by HeyGen platform | attachLiteLoop() in src/lite/loop.ts |
| Knowledge delivery | knowledgeBase string → HeyGen injects into system prompt |
Same string → injected into BYOLLM system prompt |
| Cost | 1 credit = 30s streaming | 1 credit = 1 min streaming (cheaper) |
| Config | mode: 'full' |
mode: 'lite' + liteConfig: { apiKey, model } |
Session Lifecycle (Full mode)
Mount → autoStart=true → startSession()
Phase 1 (sync): new StreamingAvatar({ token: '' })
attachHandlers(earlySdk) ← sync, for test timing
Phase 2 (async): fetch(sessionTokenUrl, POST) ← token proxy
new StreamingAvatar({ token })
attachHandlers(sdk) ← real SDK
[Lite only]: attachLiteLoop(sdk, { provider, systemPrompt })
Phase 3: sdk.createStartAvatar({ avatarName, quality, language, knowledgeBase })
→ STREAM_READY fires → video.srcObject = stream → status: 'ready'
Token Proxy Pattern
The HEYGEN_API_KEY never leaves the server. The browser calls your backend route (/api/heygen/token), the backend calls HeyGen's /v1/streaming/create_token, and returns only the short-lived session token to the browser.
Browser Your Backend HeyGen API
POST /api/heygen/token
─────────────────►
POST /v1/streaming/create_token (HEYGEN_API_KEY)
─────────────────────────────►
{ token }
◄─────────────────────────────
{ token }
◄─────────────────
StreamingAvatar({ token })
Lite Mode Conversation Loop
USER_TALKING_MESSAGE (incremental) → pendingTranscript = last message
USER_END_MESSAGE (no message field) → drain pendingTranscript
history.push({ role: 'user', content })
provider.complete([system, ...history])
→ reply
history.push({ role: 'assistant', content: reply })
avatar.speak({ text: reply, taskType: REPEAT })
Key invariant: USER_END_MESSAGE fires UserTalkingEndEvent which has NO message field (only type + task_id). Content must be accumulated from USER_TALKING_MESSAGE events.
Build Pipeline
tsup (dual config array):
Config 1: { index, server } → ESM + CJS + .d.ts, no banner, sideEffects tree-shaking safe
Config 2: { cli } → ESM only, #!/usr/bin/env node banner, direct entry at src/cli/index.ts
Critical: CLI entry points directly at src/cli/index.ts (not via a stub file). "sideEffects": false in package.json would tree-shake any bare re-export stub. See ADR-002.
Test Architecture
| File | What it tests |
|---|---|
| tests/config/validate.test.ts | 8 guard conditions in validateConfig |
| tests/ingest/chunk.test.ts | assembleKnowledge merge + truncation + word count |
| tests/ingest/url.test.ts | HTML → text extraction via cheerio |
| tests/ingest/pdf.test.ts | PDF text extraction via pdf-parse |
| tests/lite/loop.test.ts | accumulation pattern + LLM call + speak |
| tests/lite/openai.test.ts | provider.complete happy path + empty response guard |
| tests/server/handler.test.ts | token proxy, CORS, 401 on HeyGen failure |
| tests/client/useHeygenAvatar.test.ts | hook lifecycle, two-phase init, Lite mode wiring |
| tests/client/HeygenAvatar.test.tsx | component renders, loading state, error state, forwardRef |
Totals: 9 test files, 40 TypeScript tests + 7 Python tests = 47 tests, all passing.
Package Exports
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js",
"require": "./dist/server.cjs"
}
}
types condition must appear before import/require — bundler resolution order. See ADR-005.