Skip to main content

Architecture Overview

The core abstraction in Ethos is AgentLoop — a 13-step AsyncGenerator<AgentEvent> that takes a user message and streams typed events back to the caller.

Every component (LLMProvider, SessionStore, MemoryProvider, PersonalityRegistry, ToolRegistry, HookRegistry) is an interface defined in @ethosagent/types and injected at construction time. Core never imports concrete implementations.

The turn cycle

~/.ethos/config.yaml


wiring.ts assembles all components
├── LLMProvider AnthropicProvider | OpenAICompatProvider
├── SessionStore SQLiteSessionStore (WAL + FTS5)
├── MemoryProvider MarkdownFileMemoryProvider
└── PersonalityRegistry FilePersonalityRegistry (mtime hot-reload)


AgentLoop.run(text) AsyncGenerator<AgentEvent>
├── session_start hooks
├── MemoryProvider.prefetch() → system context
├── ContextInjector[] → system prompt assembly
├── before_prompt_build hooks
├── LLMProvider.complete() → stream chunks
│ ├── text_delta events
│ ├── tool_use_start/delta/end
│ └── usage event
├── ToolRegistry.executeParallel()
│ ├── before_tool_call hooks (arg override / rejection)
│ ├── parallel execution with budget splitting
│ └── after_tool_call hooks
├── MemoryProvider.sync()
└── agent_done hooks

AgentEvent types

Every event emitted by AgentLoop.run() is one of these variants:

type AgentEvent =
| { type: 'text_delta'; text: string }
| { type: 'thinking_delta'; thinking: string }
| { type: 'tool_start'; toolCallId: string; toolName: string; args: unknown }
| { type: 'tool_progress'; toolName: string; message: string; percent?: number }
| { type: 'tool_end'; toolCallId: string; toolName: string; ok: boolean; durationMs: number }
| { type: 'usage'; inputTokens: number; outputTokens: number; estimatedCostUsd: number }
| { type: 'error'; error: string; code: string }
| { type: 'done'; text: string; turnCount: number }

Consuming the generator:

for await (const event of agentLoop.run('explain this codebase')) {
if (event.type === 'text_delta') process.stdout.write(event.text)
if (event.type === 'tool_start') console.log(`\n[${event.toolName}]`)
if (event.type === 'done') console.log(`\nTurns: ${event.turnCount}`)
}

Injection at construction

AgentLoop receives every component via AgentLoopConfig. Nothing is global. The wiring.ts in the CLI reads ~/.ethos/config.yaml and wires up concrete implementations:

apps/ethos/src/wiring.ts
const loop = new AgentLoop({
llm: new AnthropicProvider({ apiKey, model }),
session: new SQLiteSessionStore({ path: '~/.ethos/sessions.db' }),
memory: new MarkdownFileMemoryProvider({ dir: '~/.ethos' }),
personalities: new FilePersonalityRegistry({ dir: '~/.ethos/personalities' }),
tools: new DefaultToolRegistry(),
hooks: new DefaultHookRegistry(),
})

To use a different LLM, session store, or memory backend — implement the interface and inject it. No other code changes.

Extension points

InterfaceDefault implementationSwap to
LLMProviderAnthropicProviderAny LLMProvider implementation
SessionStoreSQLiteSessionStoreRedis, Postgres, in-memory
MemoryProviderMarkdownFileMemoryProviderAny file format or database
PersonalityRegistryFilePersonalityRegistryRemote registry, database
ToolRegistryDefaultToolRegistryCustom tool filtering
HookRegistryDefaultHookRegistryCustom hook execution
PlatformAdapterCLI (readline)Telegram, Discord, Slack

All interfaces are defined in @ethosagent/types with zero dependencies — any package can implement them.