A character engine for conversational AI that runs as a CLI application. Drop a system prompt into prompts/ and the engine handles the rest -- persistent memory across sessions, optional ElevenLabs voice output, Obsidian vault integration for grounding characters in personal knowledge, and multi-character group conversations. No characters are included; bring your own.
- Claude CLI installed and authenticated (
claudeavailable on PATH) - Node.js 22+
- ElevenLabs account (optional -- required only for voice output in single-character mode)
git clone <repo-url> && cd understudy
npm install
cp .env.example .env
# Edit .env with your values (see below)
npm run dev| Variable | Required | Description |
|---|---|---|
CHARACTER_NAME |
No | Skip the selection menu. Comma-separated for multi-character: CHARACTER_NAME=Ghost,Hanni. |
UNDERSTUDY_MODEL |
No | Claude model for conversations. Defaults to sonnet. |
UNDERSTUDY_EXTRACT_MODEL |
No | Claude model for memory extraction/summarization. Defaults to haiku. |
UNDERSTUDY_COLOR_{NAME} |
No | Terminal color for a character's name. Values: purple, blue, cyan, green, yellow, red. |
ELEVENLABS_API_KEY |
No | ElevenLabs API key. Voice output is disabled without this. |
ELEVENLABS_VOICE_ID |
No | Fallback ElevenLabs voice ID. Per-character override: ELEVENLABS_VOICE_ID_{NAME}. |
ELEVENLABS_MODEL_ID |
No | ElevenLabs TTS model. Defaults to eleven_turbo_v2_5. |
OBSIDIAN_VAULT_PATH |
No | Absolute path to an Obsidian vault directory. Enables vault RAG when set. |
UNDERSTUDY_LOG_DIR |
No | Directory for markdown chat log transcripts. Logs are written on session end when set. |
Voice requires ELEVENLABS_API_KEY and a voice ID for the active character (either ELEVENLABS_VOICE_ID_{NAME} or the fallback ELEVENLABS_VOICE_ID). Voice is only available in single-character mode.
src/
index.ts Conversation loop, session management, shutdown/memory extraction
claude.ts Claude CLI wrapper (spawns `claude` as a child process)
types.ts Shared types: Message, CharacterSession, TranscriptEntry
log.ts Markdown transcript writer (writes to UNDERSTUDY_LOG_DIR on session end)
config/
character.ts CharacterConfig factory, terminal color resolution
select.ts Scans prompts/ for available characters, single and multi-select menus
memory/
db.ts SQLite setup (better-sqlite3), migrations, WAL mode
store.ts Memory CRUD -- scoring by importance, recency, frequency
extract.ts Post-conversation memory extraction + summarization via Claude
context.ts Assembles memory context block for the system prompt
prompt/
system.ts Reads character definition, appends memory context
vault/
config.ts Vault path validation and budget constants
scanner.ts Recursive .md file walker with mtime diffing
index.ts FTS5 indexing -- chunking by headings, frontmatter/wikilink stripping
search.ts Per-message FTS5 search with stopword filtering and deduplication
voice/
tts.ts ElevenLabs streaming TTS (single-character mode only)
stt.ts Placeholder -- not implemented
prompts/
{name}-system.md Character definition and behavioral rules
data/
{name}.db SQLite database per character (created automatically, gitignored)
On startup with one character selected:
- Character selection: if
CHARACTER_NAMEis set (single value), that character is used. If only one*-system.mdexists inprompts/, it's auto-selected. If multiple exist, an interactive menu is shown. - SQLite database initializes (creates
data/{character}.dbon first run) - If
OBSIDIAN_VAULT_PATHis set, vault markdown files are incrementally indexed into FTS5 - System prompt is assembled: character definition from
prompts/{character}-system.md+ relevant memories from past conversations
Each message:
- Vault is searched for relevant snippets (FTS5 match against extracted keywords)
- Matching vault snippets are prepended to the message as
<vault-reference>blocks - Message is sent to Claude CLI with the system prompt and session continuity (
--session-id/--resume) - Response is printed (and streamed through ElevenLabs TTS if voice is enabled)
On exit (bye, quit, exit, goodbye, /quit, /exit, or Ctrl+C):
- Claude extracts new memories from the conversation transcript
- Conversation summary and mood are stored
- Memories persist to SQLite for future sessions
Select multiple characters via comma-separated CHARACTER_NAME=Ghost,Hanni or by entering comma-separated numbers (or all) at the interactive menu.
Each character gets their own database, Claude session, and memory store. Characters are identified by colored names in the terminal (configurable via UNDERSTUDY_COLOR_{NAME}).
How messages are routed:
- Unaddressed messages go to all characters sequentially. Each character sees what prior characters said before responding.
@Name messagesends only to the named character.
Reaction rounds: After the addressed character(s) respond, every other character gets a chance to react to what was said. If a character has nothing to add, they respond with [PASS] and stay silent. Reactions continue for up to 3 rounds or until everyone passes. This means characters naturally respond to each other without the user having to relay messages.
On shutdown, each character independently extracts memories and summarizes the conversation. Characters remember what other characters said in group conversations.
Limitations in multi-character mode:
- TTS is disabled
- Vault is indexed per-character DB (redundant but simple)
- Create
prompts/{name}-system.mdwith the character's system prompt and behavioral rules - Restart the bot -- the new character appears in the selection menu
- Each character gets their own
data/{name}.dbwith separate memories, conversations, and vault index
Set OBSIDIAN_VAULT_PATH to the root of an Obsidian vault. On startup, understudy walks the vault directory, indexes all .md files into an FTS5 virtual table, and on subsequent runs only re-indexes files whose mtime has changed.
What gets indexed:
- All
.mdfiles recursively (excluding.obsidian/,.trash/, dotfiles,.icloudstubs) - YAML frontmatter is stripped before indexing
- Wikilinks are resolved to plain text
- Files are chunked by headings (h1-h3), capped at ~2,000 chars per chunk
Per message, up to 4 snippets are retrieved (best-ranked per file), with a total budget of 6,400 characters. Snippets are injected as context the character "already knows" -- they draw on them naturally without referencing them as notes or files.
iCloud note: If your vault syncs via iCloud, files may appear as .icloud stubs when not downloaded locally. These are skipped during scanning. Make sure the files you want indexed are downloaded to disk.
Set UNDERSTUDY_LOG_DIR to persist conversation transcripts as markdown files. On session end, a file is written with YAML frontmatter and speaker-labeled messages:
---
date: 2026-02-20
characters: [Ghost, Hanni]
---
**You:** @Ghost what did you think about chapter 12?
**Ghost:** She doesn't break...
**Hanni:** Ghost just said that...Filename format: {YYYY-MM-DD}-{HH-MM}-{character names}.md. The directory is created automatically if it doesn't exist. If UNDERSTUDY_LOG_DIR is unset, no logs are written.
Docker: When UNDERSTUDY_LOG_DIR is set in .env, it's mounted into the container at /app/logs. The host path receives the transcript files directly.
| Command | Description |
|---|---|
npm run dev |
Run with tsx (development) |
npm run build |
Compile TypeScript to dist/ |
npm start |
Run compiled output |