Skip to main content
Comis follows hexagonal architecture (ports and adapters). The core domain defines port interfaces that describe what the system needs. Adapter packages implement those interfaces with platform-specific code. This separation means you can swap implementations (e.g., replace SQLite memory with PostgreSQL) without touching the core, and the core compiles without knowing which adapters exist. All 16 packages follow this boundary model. Dependency direction is inward: runtime and adapter packages depend on core contracts, while core does not depend on their implementations.

Hexagonal Pattern

The architecture has three conceptual layers: Core (@comis/core) — Domain types, port interfaces, event bus, security primitives, config schemas. This is the center of the hexagon. It defines WHAT the system does, never HOW. Ports (interfaces in core/src/ports/) — Contracts that the outside world must satisfy. A port says “I need something that can send messages” without specifying whether it’s Discord, Telegram, or a test mock. Adapters (implementations in other packages) — Concrete implementations of port interfaces. The Telegram adapter implements ChannelPort using the Telegram Bot API. The SQLite adapter implements MemoryPort using better-sqlite3. The Echo adapter implements ChannelPort with an in-memory message store for testing. This means any adapter can be replaced or added without modifying core. To add a new chat platform, you implement the ChannelPort interface. To add a new storage backend, you implement MemoryPort. The rest of the system doesn’t know or care which concrete adapter is running.

Port Interfaces

Comis defines its port interfaces in packages/core/src/ports/ and exports them from @comis/core. The tables below show the main user-facing families; store, runtime, scheduling, and orchestration ports follow the same pattern.

Core Ports

The foundational interfaces that define the primary system boundaries:

Media Ports

Interfaces for processing media content — speech, images, video, and documents:

Security Ports

Interfaces for security, secrets, and identity management:

Infrastructure Ports

Interfaces for message delivery, image generation, and other infrastructure services:

Example: ChannelPort

The most important port interface is ChannelPort — the boundary between the platform-agnostic core and platform-specific chat adapters. Here is a simplified view of its key methods:
Effectful asynchronous operations return Result<T, Error> instead of using exceptions for normal control flow. See the Result Pattern section below.
The required surface covers lifecycle, inbound messages, outbound text, and a platform-specific action dispatcher. Edit, reaction, history, attachment, status, and reconciliation methods are optional capabilities implemented only where the platform supports them.

Composition Root

The bootstrap() function in core/src/bootstrap.ts builds the core AppContainer: resolved configuration, the event bus, secret manager, plugin registry, hook runner, and shutdown contract. The daemon’s wiring modules then compose runtime adapters such as channels, persistence, tools, and the gateway.
The bootstrap flow creates these services in a specific order:
  1. SecretManager — Loads encrypted secrets from environment variables. This must be created first because config loading may need to resolve secret references.
  2. Config — Loads layered configuration: defaults, then YAML files, then environment overrides. Secret references in config values are resolved via the SecretManager.
  3. TypedEventBus — Creates the typed event bus with compile-time safety for all events across the EventMap interface.
  4. PluginRegistry — Creates the plugin registry that discovers, registers, and manages plugins. Connected to the event bus for audit events.
  5. HookRunner — Creates the hook execution engine that runs lifecycle hooks at the appropriate points. Connected to the plugin registry to discover registered hooks.
The bootstrap() function returns Result<AppContainer, ConfigError> — it never throws. If config loading fails or secrets are missing, you get an explicit error you can handle.
When adding an adapter, define or reuse its core port, export the adapter from its package, and connect it in the relevant daemon wiring path.

Result Pattern

Domain and service operations use Result<T, E> from @comis/shared so callers handle failures explicitly. Narrow boundary wrappers, CLI or web entry flows, and tests may throw where the exception is immediately caught or asserted; architecture and lint checks constrain those exceptions. The Result type is a discriminated union:
Core utilities for working with Results:
  • ok(value) — Create a success result wrapping the given value
  • err(error) — Create a failure result wrapping the given error
  • tryCatch(fn) — Execute a synchronous function and capture exceptions as err()
  • fromPromise(promise) — Await a promise and capture rejections as err()
Here is a typical usage pattern:
Use ok() and err() for domain control flow. A boundary throw needs a local rationale and an immediate translation to the repository’s error contract.

Dependency Direction

Contracts point inward: shared provides dependency-free utilities, core owns domain and port contracts, and runtime packages implement or compose those contracts. Some higher-level packages have explicit sibling dependencies, so the package manifests and TypeScript project references are the authoritative graph. Cross-package imports use public exports; lifecycle signals use the typed event bus where an event is the appropriate boundary. For the full package breakdown including roles, exports, and boundary rules, see the Packages page.

Packages

Detailed package roles and exports

Event Bus

Cross-module communication via typed events

Custom Adapters

Build your own channel adapter

Plugins

Hook into the lifecycle