- Core Principles
- Layered Architecture
- Module Organization
- Naming Conventions
- Import Patterns
- Adding New Code
- Anti-Patterns to Avoid
Related architecture notes:
docs/system-prompt-architecture.mdcovers how TENEX builds the base system prompt.docs/CONTEXT-MANAGEMENT-AND-REMINDERS.mdcovers request-time context management, reminders, and prompt-history overlays.docs/SUPERVISION.mdcovers supervision heuristics, post-completion gating, retry semantics, and structured resolution paths.docs/plans/2026-04-28-architecture-map.mdcovers Rust workspace crates, including the JSON-backed installed-agent registry.
Dependencies flow downward only, never upward:
commands/daemon/event-handler → services/agents/conversations/tools
↓
llm/nostr/prompts/events
↓
utils
↓
lib
Rule: Lower layers never import from higher layers.
The lib/ layer contains zero TENEX-specific code. These are pure, reusable utilities that could work in any Node.js project:
- Filesystem operations
- String manipulation
- Time formatting
- Validation helpers
- Error formatting
Rule: lib/ has NO imports from utils/, services/, or any TENEX modules.
The utils/ layer contains helpers specific to TENEX's domain:
- Nostr entity parsing
- Phase management
- Git operations
- Conversation utilities
Rule: utils/ can import from lib/ but not from services/ or higher layers.
The services/ layer contains stateful business logic and domain services:
- Configuration management
- RAG operations
- Scheduling
- Delegation/RAL state tracking
- MCP integration
Rule: Services can import from utils/, lib/, nostr/, llm/, but not from commands/, daemon/, or event-handler/.
Purpose: Framework-agnostic utilities
Contains:
lib/fs/- Filesystem operationslib/string.ts- String utilitieslib/error-formatter.ts- Error formattinglib/time.ts- Time utilities
Dependencies: Node.js built-ins, npm packages only
Example:
// ✅ Good - pure utility
export function toKebabCase(str: string): string {
return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
// ❌ Bad - depends on TENEX code
import { config } from "@/services/ConfigService";Purpose: Domain-specific helpers
Contains:
utils/nostr-entity-parser.ts- Nostr parsing utilitiesutils/git/- Git operations (including worktree management)utils/phase-utils.ts- Phase managementutils/conversation-id.ts- Conversation identifier helpersutils/logger.ts- Logging (can depend on services/config)
Dependencies: lib/, Node.js built-ins, npm packages
Example:
// ✅ Good - TENEX-specific helper using lib utilities
import { formatAnyError } from "@/lib/error-formatter";
export function parseNostrUser(input: string): { pubkey: string } | null {
// ... implementation
}Modules: events/, nostr/, llm/, prompts/
Purpose: Protocol implementations and provider abstractions
Dependencies: utils/, lib/
Modules: services/, agents/, conversations/, tools/
Purpose: Business logic, state management, capabilities
Notes: Tools stay stateless even when they are runtime-gated. Context-injected capabilities such as send_message belong in src/tools/registry.ts and must delegate transport work to services like src/services/telegram/TelegramDeliveryService. Within the conversation domain, canonical transcripts remain JSON in ConversationStore, while metadata-style reads belong on the per-project SQLite catalog in ConversationCatalogService. Prompt and tool code should query that catalog or ConversationRegistry compatibility APIs instead of reparsing transcript files.
Teams are a local-only service concern. src/services/teams/ loads JSON definitions from disk, normalizes membership, resolves team names to lead agents for delegation, and feeds prompt-facing team summaries into the prompt layer. Team scope itself is carried across Nostr via ["team", "..."] tags, not via team objects in relay state.
Dependencies: Everything below (layers 0-2)
Modules: commands/, daemon/, event-handler/
Purpose: CLI, runtime orchestration, event routing
Dependencies: Everything below (layers 0-3)
Services should be organized by domain, with related code grouped together:
services/
├── analysis/ # LLM telemetry schema, migrations, and query services
├── migrations/ # Explicit TENEX state migrations keyed by config.json version
├── dispatch/ # Chat routing + delegation dispatch
│ ├── AgentDispatchService.ts
│ ├── AgentRouter.ts
│ └── DelegationCompletionHandler.ts
├── ral/ # Delegation/RAL state
│ ├── RALRegistry.ts
│ ├── DelegationRegistry.ts
│ ├── KillSwitchRegistry.ts
│ ├── HeuristicViolationManager.ts
│ ├── MessageInjectionQueue.ts
│ ├── ExecutionTimingTracker.ts
│ ├── types.ts
│ └── index.ts
├── teams/ # Local team definitions, prompt context, and delegate resolution
├── rag/ # RAG domain
│ ├── RAGService.ts
│ ├── RAGDatabaseService.ts
│ ├── RagSubscriptionService.ts
│ ├── ...
│ └── index.ts
├── projects/
├── scheduling/
├── reports/
├── mcp/
└── ...
When to create a subdirectory:
- 3+ related files
- Distinct domain boundary
- Internal implementation details to hide
Small services (1-2 files): Keep at top level until they grow.
TENEX conversation storage has two intentional layers:
ConversationStoreis the canonical ledger. It owns full transcript JSON, context-management compactions, and save/load semantics.ConversationCatalogServiceis the per-project derived read model backed byconversation-catalog.db. It exists for prompt/tool metadata queries such as recent conversations, conversation previews, participant/delegation lookups, and durable embedding indexing state.
ConversationStore also carries per-agent prompt-view state that is intentionally separate from the canonical transcript:
- Canonical
ConversationRecords remain the source of truth for transcript history. - Per-agent frozen prompt histories record the exact pre-context-management prompt view previously sent to that agent. Runtime-only system reminder overlays remain ephemeral until a request has actually established prompt caching; after that anchor exists they can be persisted as replayable prompt-history entries.
- Runtime overlays must never be replayed by mutating historical transcript messages. Append them as prompt-history entries instead.
When adding a new conversation-facing feature, decide explicitly which layer it belongs to:
- If it needs the full transcript or mutates canonical conversation state, use
ConversationStore. - If it only needs queryable metadata or list/filter behavior, use the catalog. Do not add new transcript-scanning helpers for those paths.
Teams are local JSON-defined memberships that never become standalone Nostr entities. The runtime resolves them from disk through src/services/teams/TeamService.ts, which normalizes the team lead into membership, caches by file state, and resolves team names to lead pubkeys for delegation.
- Team definitions stay local on disk and are not published as Nostr events.
- Delegation events carry team scope through the
["team", "..."]tag. - Prompt rendering uses the teams-aware project context plus the lightweight
src/prompts/fragments/teams-contextsummary fragment. - Team names are resolved case-insensitively, with agent slugs still taking priority when a recipient string matches both.
Standalone developer tooling or auxiliary CLIs should live under tools/ at the repo root. Treat each tool as isolated (own package.json/tsconfig.json) and document additions in MODULE_INVENTORY.md.
Preferred suffix: Service
// ✅ Preferred
ConfigService
RAGService
SchedulerService
ProjectStatusService
// ✅ Most business-logic classes use the "Service" suffix.Goal: Consistent "Service" suffix for all business logic classes.
Exceptions: Registries (e.g., RALRegistry) keep their names when they are not business-logic services.
- Services:
SomethingService.ts(PascalCase) - Utilities:
kebab-case.tsorcamelCase.ts(be consistent within a directory) - Types:
types.tsorSomething.types.ts - Tests:
Something.test.ts(co-located in__tests__/)
Do NOT use services/index.ts barrel export. Import directly from service directories:
// ✅ Good - direct import
import { RALRegistry } from "@/services/ral";
import { RAGService } from "@/services/rag";
// ❌ Bad - barrel import
import { RALRegistry, RAGService } from "@/services";Why:
- Explicit dependencies
- Better tree-shaking
- Faster TypeScript compilation
- No barrel maintenance
Each service subdirectory has an index.ts that controls what's public:
// services/ral/index.ts
export { RALRegistry } from "./RALRegistry";
export type { PendingDelegation, CompletedDelegation } from "./types";Always use the @/ path alias for absolute imports:
// ✅ Good
import { config } from "@/services/ConfigService";
import { formatAnyError } from "@/lib/error-formatter";
// ❌ Bad - relative imports for cross-module
import { config } from "../../../services/ConfigService";1. Determine if it's pure or TENEX-specific:
- Pure (no TENEX deps) →
lib/ - TENEX-specific →
utils/
2. Create the file:
// lib/array-utils.ts (pure utility)
export function chunk<T>(array: T[], size: number): T[][] {
// ... implementation
}3. Prefer direct imports. Add an index.ts only when a subdirectory needs an explicit public surface.
1. Decide if it needs a subdirectory:
- Yes if: 3+ related files, distinct domain
- No if: 1-2 files, simple service
2. Create the service:
// services/notifications/NotificationService.ts
import { config } from "@/services/ConfigService";
import { logger } from "@/utils/logger";
export class NotificationService {
constructor(
private readonly config: typeof config,
private readonly logger: typeof logger
) {}
// Methods...
}
// Export default instance for convenience
export const notificationService = new NotificationService(config, logger);3. Create index.ts:
// services/notifications/index.ts
export { NotificationService, notificationService } from "./NotificationService";
export type { NotificationOptions } from "./types";4. Import where needed:
import { notificationService } from "@/services/notifications";Adding to lib/:
- Must be pure, no TENEX dependencies
- No imports from
utils/,services/, etc. - Use console.error instead of TENEX logger
Adding to utils/:
- Can import from
lib/ - Should be domain helpers, not business logic
- If it needs state, it should be a service instead
Adding to services/:
- Can import from
utils/,lib/,nostr/,llm/,prompts/,events/ - Cannot import from
commands/,daemon/,event-handler/
// lib/something.ts
import { logger } from "@/utils/logger"; // ❌ lib → utils (wrong direction)
// utils/helper.ts
import { someService } from "@/services/SomeService"; // ❌ utils → servicesSolution: Move code to correct layer or use dependency injection.
// ❌ Importing from barrel when subdirectory exists
import { RAGService } from "@/services";
// ✅ Import directly from subdirectory
import { RAGService } from "@/services/rag";// ❌ Bad - stateful service masquerading as utility
// utils/user-manager.ts
export class UserManager {
private users: Map<string, User> = new Map();
// ... state management
}Solution: Move to services/ if it has state or business logic.
Avoid mixing naming styles for the same layer. Use Service for business logic, and reserve Registry/Manager for registries or infrastructure helpers.
Recommended pattern for services:
// services/something/SomethingService.ts
export class SomethingService {
// Declare dependencies in constructor
constructor(
private readonly config: typeof config,
private readonly logger: typeof logger,
private readonly someOtherService: SomeOtherService
) {}
doSomething(): void {
// Use injected dependencies
const value = this.config.getConfigPath();
this.logger.info("Doing something", { value });
}
}
// Export default instance for convenience
import { config } from "@/services/ConfigService";
import { logger } from "@/utils/logger";
import { someOtherService } from "@/services/some-other";
export const somethingService = new SomethingService(
config,
logger,
someOtherService
);Benefits:
- Testable (inject mocks)
- Clear dependencies
- No hidden singletons
- Convenient default instance
- Pure Utilities in
lib/: All pure, framework-agnostic utilities are now isolated in thelib/directory with zero TENEX dependencies. - No Circular Dependencies: All circular dependencies between layers have been resolved.
- Consistent Service Naming: All services have been refactored to use the
Servicesuffix, removing legacy names likeReportManagerandPubkeyNameRepository. - Git Utilities Consolidated: All Git-related helpers, including worktree management, are now centralized in
utils/git/. - Configuration Architecture: A centralized
ConfigServicenow manages all configuration, ensuring consistent and predictable settings management.
We are incrementally moving toward:
- ⏳ Subdirectory Grouping for Services: Gradually group related services into subdirectories for better organization.
- ⏳ Dependency Injection Pattern: Continue to adopt dependency injection for all services to improve testability and clarity.
- ⏳ Removal of Barrel Exports: Phase out all remaining barrel exports in favor of direct imports.
Philosophy: Make incremental improvements. Leave code better than you found it (Boy Scout Rule).
If unsure where code belongs:
- Is it pure/framework-agnostic? →
lib/ - Is it a TENEX helper with no state? →
utils/ - Does it manage state or business logic? →
services/ - Is it protocol-specific? →
nostr/,llm/, etc.
When in doubt, ask in PR review or check this document.
- CONTRIBUTING.md - Development workflow
- MODULE_INVENTORY.md - Current module list
- TESTING_STATUS.md - Testing guidelines