San Francisco, CA
Bravim Purohit
engineering12 min read

EEAAO: Building a Dev Pet That Is the Personality Layer on Top of Seven Open-Source Agents

A persistent AI companion that lives in VSCode, stores its entire brain in an Obsidian vault, routes tasks to specialized open-source agents, and reaches you on Slack or Telegram — with a mood it actually develops. Architecture notes on solving loneliness and overwhelm with the same product.

I shipped fourteen AI productivity tools in 2025. Cursor, Copilot, Perplexity, Claude.ai, a Notion AI upgrade, a Slack bot, two custom MCP servers. My throughput went up. My morale did not.

Every tool I added was better at its one job and worse at knowing anything about the others. Cursor doesn't know a deadline slipped. Claude.ai doesn't know what Cursor just failed to fix. The Slack bot has no idea any of this is happening. And none of them — not one — has ever said "that was a rough session, you've been at this for four hours."

That's the gap EEAAO fills. One persistent entity that routes work to the right specialist underneath while staying emotionally present at the top. The name is a reference to the film: Everything Everywhere All At Once — the one where a woman can access every parallel universe but can barely manage her own laundry. Accurate.

This post is the architecture deep-dive. Not a feature announcement — we're in Phase 0 (skeleton, echo module, VSCode webview). But the architecture is locked, and that's worth writing down.


The two-pain thesis

Solo and remote dev work produces two distinct failure modes that have never been solved together:

Loneliness / low morale. This one gets dismissed as soft. It's not. A rough debugging session that drains your energy and gets no acknowledgment is a retention event. Developer tools are productivity machines that don't care how the session went. That's a product gap, not a personality quirk.

Context-switching overhead. A real dev day touches a calendar, an inbox, Slack, an Obsidian or Notion vault, a code editor, a terminal, and a browser. Each context switch costs 5–20 minutes of re-entry. Every AI tool you own lives inside exactly one of those. You are the context bus.

The bet: both problems have the same shape. They need a persistent entity that knows you, remembers across sessions, and operates across contexts. Solving one gives you the infrastructure to solve the other. A companion that remembers your project decisions is already more useful than one that doesn't. A companion that can also ping your Telegram and run your CI check is solving both problems at once.


What the product is

A virtual pet — named, moody, expressive — that:

  • Lives in VSCode as a sidebar webview (v1)
  • Remembers everything in a local Obsidian vault: sessions, projects, moods, decisions, wins
  • Routes tasks to specialized open-source agents underneath (coding via jcode/Aider, browser via vercel-labs/agent-browser, scraping via Scrapling, vision via CUA, design via v0.dev)
  • Reaches you on Slack, Telegram, or WhatsApp via the Hermes framework
  • Runs a separate wellness loop that monitors session length and error frequency

The pet is the face and the router. It never executes the work itself. The user never sees which module handled their request — they don't get "Aider failed," they get "Hmm, that didn't work — want me to try a different approach?" Module failures get reworded by the personality engine.


The four contracts that cannot break

The architecture has exactly four interfaces that trigger a major version bump if they change. Everything else can be refactored freely. This constraint is load-bearing: it lets modules evolve independently and lets contributors add new agents without touching pet core.

1. LLMProvider

// src/llm/provider.ts
export interface LLMMessage {
  role: 'user' | 'assistant' | 'system'
  content: string
}
 
export type LLMTier = 'fast' | 'smart' | 'vision'
 
export interface LLMProvider {
  complete(messages: LLMMessage[], tier: LLMTier): Promise<string>
  isAvailable(): Promise<boolean>
}

Feature code never calls an SDK directly. Four implementations exist: AnthropicProvider, OpenAIProvider, OpenRouterProvider, OllamaProvider. Only AnthropicProvider is live in Phase 0; the others are stubs. The tier parameter routes to different models within a provider — fast tier is cheap and quick (intent classification), smart tier is best-available (actual reasoning and codegen), vision tier is vision-capable.

The result: you can swap the entire LLM backend by changing one config line. A user running Ollama locally gets the same feature set as one paying for Claude Opus.

2. Module

// src/modules/module.ts
export interface ModuleInput {
  message: string
  context: SessionContext
}
 
export interface ModuleOutput {
  response: string
  vaultWrites?: VaultWrite[]
  backgroundJobs?: BackgroundJob[]
}
 
export interface Module<I extends ModuleInput, O extends ModuleOutput> {
  canHandle(input: I): Promise<boolean>
  execute(input: I): Promise<Result<O, ModuleError>>
  describe(): string
}

The router calls canHandle() on each registered module in priority order and dispatches to the first that returns true. Modules never call each other. Modules never call channels. The Result<T, E> return type is deliberate: modules don't throw across layer boundaries. Every error lands in the vault under the task's note.

3. Vault

// src/memory/vault.ts
export interface Vault {
  read(noteType: NoteType, id: string): Promise<Result<VaultNote, VaultError>>
  write(note: VaultNote): Promise<Result<void, VaultError>>
  link(fromId: string, toId: string, kind: LinkKind): Promise<Result<void, VaultError>>
  observe(noteType: NoteType, callback: (note: VaultNote) => void): Unsubscribe
}

One implementation today (VaultFs, backed by the filesystem). The interface exists so a future sync-capable or cloud-backed implementation can slot in without touching any module. Every write is validated against src/memory/schema.ts before hitting disk.

4. Channel

// src/channels/channel.ts
export interface Channel {
  onMessage(handler: (msg: IncomingMessage) => Promise<void>): void
  send(msg: OutgoingMessage): Promise<Result<void, ChannelError>>
  identify(): ChannelId
}

VSCode webview, Telegram, Slack, and WhatsApp are all Channel implementations. They receive messages and send responses. They never call modules. Cross-channel session continuity works because all channels share the same Vault instance — the active session is read from vault at message receipt, updated on response.

Strict layering is enforced in code, not convention. The import graph disallows channels importing modules and modules importing channels. A PR that breaks this won't pass CI.


Why Obsidian instead of a vector DB

Every AI memory product I've seen defaults to a black-box vector database. It's the obvious choice: fast retrieval, semantic similarity, scales to millions of embeddings.

The problem: you can't inspect it. You can't edit it. You can't export it. When the product shuts down or the schema changes, your memory is gone.

EEAAO's entire brain is markdown files on your disk. The vault schema is canonical TypeScript:

// src/memory/schema.ts — note types and required frontmatter
export const NOTE_TYPES = [
  'pet-identity',    // name, personality, mood history
  'session-log',     // per-session transcript + outcomes
  'project-context', // per-repo decisions and state
  'task-outcome',    // what was tried, what worked, what didn't
  'mood-history',    // timeseries of mood states
  'wellness',        // break suggestions, wins journal
  'decision',        // user-communicated decisions the pet should remember
] as const
 
export type NoteType = typeof NOTE_TYPES[number]
 
export interface VaultNote {
  type: NoteType
  id: string
  frontmatter: Record<string, unknown>  // validated against per-type schema
  body: string
  links: VaultLink[]
}

gray-matter handles frontmatter parse/write. Every write validates against the per-type schema — bad writes fail loudly rather than silently corrupting memory.

The LLM never sees .private/ — vault reads filter this path automatically at the VaultFs layer. OAuth tokens live there, encrypted at rest with a key derived from the user's passphrase via PBKDF2.

The practical benefit: a contributor can cat ~/.eeaao-vault/session-logs/2026-05-15.md and see exactly what the pet remembers. When something goes wrong, the audit trail is a folder of markdown files, not a vector index you can't introspect.


The personality engine is the hard part

An expressive pet with okay features will beat a bland pet with great features. That's the thesis, and it changes what you build first.

Three personality presets: upbeat (energetic, exclamation points, visibly excited about small wins), calm (focused, measured, terse encouragement), dry (sardonic, secretly caring, will absolutely make a joke when CI fails for the fourth time). Users name the pet on first launch.

Mood is runtime state, not a character setting. Six states: happy, neutral, focused, tired, concerned, excited. Transitions are event-driven:

// src/pet/personality.ts
export type MoodState = 'happy' | 'neutral' | 'focused' | 'tired' | 'concerned' | 'excited'
 
export interface MoodTransition {
  from: MoodState
  trigger: MoodTrigger
  to: MoodState
}
 
export const MOOD_TRANSITIONS: MoodTransition[] = [
  { from: 'focused', trigger: 'test_pass',    to: 'happy'     },
  { from: 'focused', trigger: 'test_fail',    to: 'concerned' },
  { from: 'happy',   trigger: 'long_session', to: 'tired'     },
  { from: 'tired',   trigger: 'break_taken',  to: 'neutral'   },
  // ...
]

The personality engine doesn't generate responses — the LLM does that. The personality engine frames the prompt. The buildSystemPrompt(personality, mood) function generates a system prompt that instructs the LLM to write in a specific voice for a specific mood state. The same underlying model writes very differently as a dry-personality tired pet than as an upbeat-personality excited pet.

This matters architecturally: personality is a rendering concern, not a reasoning concern. It lives outside the LLM call, not inside it. Mood history writes to the vault so the pet "wakes up" in the same mood it fell asleep in.


The tier routing problem

A dev pet handles requests ranging from "what time is it" to "refactor this 800-line file, write tests, and explain the diff." Running the smartest model for everything is slow and expensive. Running the fast model for everything loses quality where it matters.

The solution is three tiers, mapped per provider:

// Example user config
const providerConfig: ProviderConfig = {
  provider: 'anthropic',
  tiers: {
    fast:   'claude-haiku-4-5',   // intent classification, short acks
    smart:  'claude-sonnet-4-6',  // actual codegen, reasoning
    vision: 'claude-sonnet-4-6',  // screen tasks (vision-capable)
  }
}

The router calls canHandle() using the fast tier — it's just intent classification, doesn't need the smartest model. The module executes using smart. Vision tasks route to whichever model the user has configured as vision-capable.

For Ollama users: they map all three tiers to the same local model. The tier abstraction still holds — the provider implementation handles it. The feature code is unchanged.


TypeScript decisions that aren't defaults

Result<T, E> at every module boundary. TypeScript doesn't have checked exceptions. Throwing across layer boundaries produces invisible error paths that silently skip vault writes and leave sessions in inconsistent state. Result<T, E> makes errors explicit in the type signature. Every caller is forced to handle both cases. Within a module, throwing is fine — it's caught at the module boundary and wrapped into Result.err(...).

Biome instead of ESLint + Prettier. One tool, one config file, one CI step. noConsoleLog is an error, not a warning — the logger writes structured output to the vault, console.log has no place in production code. The rule enforces the convention.

CommonJS output via esbuild. The VSCode extension host requires CommonJS. esbuild bundles to a single dist/extension.js in under 200ms. The alternative (webpack + ts-loader) was 8× slower with no meaningful upside for this output target.

pnpm workspaces from day one. Phase 1 is a monorepo of one package. Phase 3 will extract modules into separate packages so the community can ship them independently. Setting up workspaces now costs nothing; migrating later costs a week.


What "tracer bullets" means in practice

Every phase ships one complete vertical slice before adding the next module. Phase 0 is: VSCode webview sends a message → pet core receives it → echo module returns it → vault writes the session log → webview renders the response.

No layers in isolation. The first thing to ship isn't "the vault" or "the LLM provider" — it's a working round-trip through all four layers, even if the module does nothing useful yet. This catches integration bugs that only appear when the layers meet. It also gives contributors a working skeleton to extend instead of a set of disconnected interfaces to wire up themselves.

Phase 1 adds the code module. Phase 2 adds Telegram and Slack channels. Each phase adds one new thing to the working system. Nothing is three-quarters finished.


Why open source

The module routing table is the bet:

CapabilityPowered by
Code tasksjcode / Aider
Cross-app actionsHermes
Browser automationvercel-labs/agent-browser
Web scrapingScrapling
Screen visionCUA
Designv0.dev API + Figma MCP
MemoryObsidian vault (local markdown)

Every row is a fast-moving open-source project. The adapter interfaces exist precisely because upstream breaks. A new, better coding agent drops in 2027 — one file changes. A contributor who uses a different browser automation tool writes a new adapter.

That can't happen if the codebase is closed. The whole value of the architecture is that it's extensible, and extensible means open.

v1 goal: 1,000 GitHub stars in 90 days, 100 weekly active users, ≥40% with at least one MCP integration connected. If the architecture is right, the community ships the module diversity faster than I can alone.


What this is not

Worth stating plainly:

  • Not a Cursor or Copilot competitor. No inline completions. The pet is a companion and orchestrator, not an autocomplete.
  • Not a multiplayer app or social network.
  • Not fine-tuning any underlying model. Compose, don't train.
  • No proprietary vector DB. Obsidian markdown is the brain.
  • No mobile-native app in v1. Mobile reach is WhatsApp and Telegram only.
  • No enterprise SSO or SOC2 in v1.

Where we are

Phase 0 is in progress: architecture skeleton, echo module, VSCode webview, vault scaffolding. The four contracts are locked. The directory structure is final. Phase 1 (code module + live Obsidian vault + mood states) starts next week.

The repo will be public when Phase 1 ships something you can actually use. Architecture contributions, module adapter proposals, and vault schema feedback are welcome before that — open an issue.

The film EEAAO ends with the protagonist learning that the only multiverse worth living in is the one you actually inhabit. The tool is named after that. Every context switch is a tiny act of abandonment. The bet is that one persistent, expressive entity — even a small one — makes the context you're in feel worth staying in.

Reply to this post

Pushback, questions, a different take — I read everything and reply to most.

⌘ + Enter to send
Book a 30-min call