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 inpackages/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 isChannelPort — the boundary between the platform-agnostic core and platform-specific chat adapters. Here is a simplified view of its key methods:
Composition Root
Thebootstrap() 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.
- SecretManager — Loads encrypted secrets from environment variables. This must be created first because config loading may need to resolve secret references.
- Config — Loads layered configuration: defaults, then YAML files, then environment overrides. Secret references in config values are resolved via the SecretManager.
- TypedEventBus — Creates the typed event bus with compile-time safety for all events across the
EventMapinterface. - PluginRegistry — Creates the plugin registry that discovers, registers, and manages plugins. Connected to the event bus for audit events.
- HookRunner — Creates the hook execution engine that runs lifecycle hooks at the appropriate points. Connected to the plugin registry to discover registered hooks.
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 useResult<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:
ok(value)— Create a success result wrapping the given valueerr(error)— Create a failure result wrapping the given errortryCatch(fn)— Execute a synchronous function and capture exceptions aserr()fromPromise(promise)— Await a promise and capture rejections aserr()
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.
Related
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
