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 19 port interfaces organized into 4 categories. All ports live inpackages/core/src/ports/ and are exported from @comis/core.
Core Ports
The foundational interfaces that define the primary system boundaries:| Port | File | Purpose | Adapters |
|---|---|---|---|
ChannelPort | ports/channel.ts | Chat platform messaging boundary | 10 adapters (Discord, Telegram, Slack, WhatsApp, Signal, iMessage, LINE, IRC, Echo, Email) |
ChannelPluginPort | ports/channel-plugin.ts | Plugin wrapper for channel adapters | One per channel adapter |
MemoryPort | ports/memory.ts | Persistent memory with vector search | SqliteMemoryAdapter |
SkillPort | ports/skill.ts | Skill execution boundary | SkillRegistry, MCP client |
EmbeddingPort | ports/embedding.ts | Text-to-vector embedding | External LLM providers |
PluginPort | ports/plugin.ts | Plugin extension boundary | Any custom plugin |
Media Ports
Interfaces for processing media content — speech, images, video, and documents:| Port | File | Purpose | Adapters |
|---|---|---|---|
TranscriptionPort | ports/transcription-port.ts | Speech-to-text | OpenAI, Groq, Deepgram |
TTSPort | ports/tts-port.ts | Text-to-speech | OpenAI, ElevenLabs, Edge TTS |
ImageAnalysisPort | ports/image-analysis-port.ts | Vision AI image analysis | OpenAI, Anthropic, Google |
VisionProvider | ports/vision-port.ts | Multi-capability vision (image + video) | Multi-provider vision |
MediaResolverPort | ports/media-resolver-port.ts | Platform-specific media download | Per-platform resolvers, CompositeResolver |
FileExtractionPort | ports/file-extraction-port.ts | Document text extraction | PDF, text, CSV extractors |
Security Ports
Interfaces for security, secrets, and identity management:| Port | File | Purpose | Adapters |
|---|---|---|---|
OutputGuardPort | ports/output-guard.ts | LLM output safety scanning | Content scanner |
SecretStorePort | ports/secret-store.ts | Encrypted secret storage | File-based secret store |
CredentialMappingPort | ports/credential-mapping.ts | Credential-to-injection binding | Config-based mapper |
DeviceIdentityPort | ports/device-identity.ts | Cryptographic device identity | Hardware identity manager |
Infrastructure Ports
Interfaces for message delivery, image generation, and other infrastructure services:| Port | File | Purpose | Adapters |
|---|---|---|---|
ImageGenerationPort | ports/provider.ts | Image generation | FAL, OpenAI |
DeliveryQueuePort | ports/delivery-queue.ts | Crash-safe outbound delivery | SqliteDeliveryQueue |
DeliveryMirrorPort | ports/delivery-mirror.ts | Session delivery mirroring | SqliteDeliveryMirror |
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:
start, stop), messaging methods (sendMessage, editMessage, deleteMessage), media methods (sendAttachment), and an escape hatch (platformAction) for platform-specific operations not covered by the generic interface.
Composition Root
Thebootstrap() function in core/src/bootstrap.ts is the composition root — the single place where all concrete implementations are wired to port interfaces. It returns an AppContainer that holds the fully configured application.
- 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.
The composition root is the ONLY place where concrete implementations are wired to port interfaces. If you’re adding a new adapter, this is where it gets connected.
Result Pattern
Every function in Comis returnsResult<T, E> from @comis/shared — no thrown exceptions anywhere in the codebase. This is enforced by ESLint rules that block throw statements.
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
The dependency direction is always inward — every package depends oncore (and shared), but never on sibling packages. Adapters depend on ports, not on each other.
Key rules:
channelsnever imports fromgateway— they are sibling adapter packages at the same layer.skillsnever imports fromagent— skills are a dependency of agent, not the other way around.- Cross-module communication uses the
TypedEventBus— if two sibling packages need to react to each other’s actions, they do so through typed events, not direct imports. daemondepends on everything — as the composition root and process orchestrator, it wires all packages together at startup.
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
