# Channel Plugin Architecture

> Status: shipped. Manifest contract, `ChannelManifestRegistry`, the `channelPackages` config field, the setup contract, and the first external plugin (`@openhermit/channel-wechat`) have all landed. The migration plan below is kept for historical context; everything in it is done. Supersedes the "External Channel Adapter API" open question in `pending-decisions.md`. For day-to-day operational reference, see [`channel-adapter.md`](channel-adapter.md).

## Why

Today the built-in channel set (`telegram`, `slack`, `discord`) is hardcoded:

- `BUILTIN_CHANNELS` in `apps/agent/src/core/types.ts` is a static array.
- `apps/agent/src/channels.ts` ships a `starters` map that dynamically imports each `@openhermit/channel-*` package by name, but the keys are written by hand.
- `apps/cli/tsup.config.ts` bundles `@openhermit/channel-telegram` (via `noExternal`) into the CLI binary; the others resolve from `node_modules` at runtime, but only because they happen to be runtime dependencies of the CLI.
- The admin UI reads the same hardcoded list to render the "available channels" registry.

Adding a new channel — Signal (PR #81), Weixin, Debox — currently means touching all four places and shipping it inside the CLI. This couples the CLI release cadence to every new channel and bloats the binary for users who only need one.

Goal: make channels first-class npm-installable plugins. The CLI still bundles a default set for the out-of-box experience, but additional channels can be added by `npm install -g @vendor/channel-foo` without re-releasing the CLI.

## Non-goals

- Out-of-process or non-Node channels. The existing external-channel HTTP slot (DB row + bearer token + `AgentLocalClient`) remains the escape hatch for those cases. A dedicated `channels-gateway` sidecar is deferred until a real driver appears.
- Hot reload. Adding/removing a plugin requires a gateway restart in this phase.
- Cross-language plugins. Manifests are JS/TS modules.

## Overview

```text
┌─────────────────────────────────────────────────────────────────────┐
│ gateway process                                                     │
│                                                                     │
│  ┌──────────────────────────┐  ┌─────────────────────────────────┐  │
│  │ ChannelManifestRegistry  │← │ PluginLoader                    │  │
│  │ (runtime)                │  │  • read channelPackages config │  │
│  └──────────────────────────┘  │  • dynamic-import each manifest │  │
│         ▲                      │  • register {key, namespace,    │  │
│         │                      │     parseConfig, start}         │  │
│  ┌──────┴───────────┐          └─────────────────────────────────┘  │
│  │ ChannelPool      │  boots bridges by looking up                  │
│  │ (per-agent ×     │  registered start() — no hardcoded            │
│  │  per-channel)    │  switch on key                                │
│  └──────────────────┘                                               │
└─────────────────────────────────────────────────────────────────────┘
```

The runtime `ChannelManifestRegistry` replaces the compile-time `BUILTIN_CHANNELS` constant. `ChannelPool` (`apps/gateway/src/channel-pool.ts`) keeps its current per-agent-per-channel semantics — only the lookup of `start()` changes.

The name `ChannelManifestRegistry` (not `ChannelRegistry`) is deliberate: `apps/gateway/src/auth.ts` already exports a `ChannelRegistry` class that handles per-channel auth-token registration. Manifest registration is a separate concern; keeping the names distinct avoids confusion at call sites.

## Channel Manifest

Every channel package exports a default manifest:

```ts
// @openhermit/channel-signal/src/index.ts
import type { ChannelManifest } from '@openhermit/protocol';
import { z } from 'zod';
import { startSignal } from './start.js';

const SignalConfig = z.object({
  restUrl: z.string().url(),
  selfNumber: z.string(),
  // ...
});

const manifest: ChannelManifest = {
  manifestVersion: 1,
  key: 'signal',                              // ChannelsConfig key + DB channel_type
  namespace: 'signal',                        // sender.channel namespace
  displayName: 'Signal',                      // admin UI label
  parseConfig: (input) => SignalConfig.parse(input),
  start: startSignal,                         // (config, context) => Promise<ChannelHandle>
};

export default manifest;
```

The shape lives in `packages/protocol/src/index.ts`:

```ts
export const CHANNEL_MANIFEST_VERSION = 1 as const;

export interface ChannelManifest {
  manifestVersion: 1;       // pinned literal; bumped on breaking changes
  key: string;
  namespace: string;
  displayName: string;
  /** Optional config parser. Throws on invalid; returned value is what start() receives. */
  parseConfig?: (input: unknown) => unknown;
  start: (config: unknown, context: ChannelContext) => Promise<ChannelHandle | undefined>;
}
```

The `manifestVersion: 1` literal is required from day one. The registry refuses to register a manifest whose `manifestVersion` doesn't match `CHANNEL_MANIFEST_VERSION`, throwing a clear error. Plugins survive a protocol bump by either continuing to declare `1` (if the loader still accepts it) or shipping a new release pinned to the new version. Bump policy:

- **No bump** for adding *optional* fields to the manifest, the handle, or the context.
- **Bump required** for adding required fields, changing the `start()` signature, or any semantic change a v1 plugin couldn't have anticipated.

`parseConfig` is intentionally opaque — plugin authors can use Zod (`schema.parse`), a hand-rolled validator, or skip validation entirely. The contract is "throws if invalid; returns the value passed to `start()`."

Webhook ingress (Telegram secret_token, Slack HMAC, Discord ed25519) lives on the live `ChannelHandle.handleWebhook` returned from `start()`, not on the manifest, because some channels switch between polling and webhook based on per-agent config (e.g. Telegram).

Existing built-in channels (`telegram`, `slack`, `discord`) are refactored to expose the same default-exported manifest. The `start*` functions and config types already exist; only the export shape changes.

## Interactive Setup

Token-only channels (telegram, slack, discord) start with a known config: the operator pastes a bot token into the admin UI, the row is saved, the channel is enabled. Some channels — Signal (QR-link via [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api)), WhatsApp-Web, anything OAuth — have a multi-step interactive flow that produces the persistent config only *after* the user completes an external action (scanning a QR, granting consent in a browser).

The `setup` field on `ChannelManifest` describes a state machine for this flow:

```ts
export interface ChannelManifest {
  // ...existing fields
  setup?: ChannelSetup;
}

export interface ChannelSetup {
  begin: (input, ctx) => Promise<{ sessionId: string; state: ChannelSetupState }>;
  poll: (sessionId, ctx) => Promise<ChannelSetupState>;
  submit?: (sessionId, input, ctx) => Promise<ChannelSetupState>;
  cancel?: (sessionId, ctx) => Promise<void>;
}

export type ChannelSetupState =
  | { kind: 'awaiting_user_input'; instructions?: string; fields: ChannelSetupFieldSpec[] }
  | { kind: 'awaiting_external'; instructions: string; qrText?: string; redirectUrl?: string; pollIntervalMs: number }
  | { kind: 'done'; config: Record<string, unknown> }
  | { kind: 'error'; message: string };
```

The plugin owns its session state, keyed by an opaque `sessionId` it generates in `begin`. The gateway exposes four REST routes that wrap the four methods:

```
POST   /api/agents/:id/channels/:type/setup/begin              -> { sessionId, state }
GET    /api/agents/:id/channels/:type/setup/:sessionId         -> { sessionId, state }   (= poll)
POST   /api/agents/:id/channels/:type/setup/:sessionId         -> { sessionId, state }   (= submit)
DELETE /api/agents/:id/channels/:type/setup/:sessionId         -> { ok: true }            (= cancel)
```

A Signal flow looks like:

```text
UI                           gateway                     manifest.setup
 │                              │                              │
 │ POST .../setup/begin         │                              │
 │ { phone_number: '+1...' }    │                              │
 │ ──────────────────────────►  │ begin(input, ctx)            │
 │                              │ ──────────────────────────►  │
 │                              │                              │ signal-cli-rest-api:
 │                              │                              │   POST /v1/qrcodelink/+1...
 │                              │                              │   ◄── PNG bytes
 │                              │                              │ decode PNG → sgnl:// URI
 │                              │                              │
 │ { sessionId, state:          │                              │
 │   awaiting_external,         │                              │
 │   qrText: 'sgnl://...',      │  ◄── { sessionId, state }    │
 │   pollIntervalMs: 1000 }     │                              │
 │ ◄──────────────────────────  │                              │
 │                              │                              │
 │ <QRCode value={qrText}/>     │                              │
 │ user scans QR in Signal app  │                              │
 │                              │                              │
 │ GET .../setup/:sessionId     │                              │
 │ (every 1s)                   │ poll(sessionId, ctx)         │
 │ ──────────────────────────►  │ ──────────────────────────►  │
 │                              │                              │ check linked-device status
 │ { state: done,               │                              │
 │   config: {...} }            │  ◄── { kind: 'done', ... }   │
 │ ◄──────────────────────────  │                              │
 │                              │                              │
 │ POST /api/agents/:id/channels                               │
 │ { namespace: 'signal',       │                              │
 │   config: <state.config>,    │                              │
 │   enabled: true }            │                              │
 │ ──────────────────────────►  │ (existing channel CRUD;      │
 │                              │  setup contract does NOT     │
 │                              │  write to the DB itself)     │
```

Key design choices:

- **Plugin owns session state.** The gateway is stateless; sessions live in the plugin's own `Map<sessionId, State>` for the gateway lifetime. This matches how `start()` plugins already own their in-process state (sockets, pollers). Plugins are responsible for their own timeouts/GC.
- **Setup does not write the DB.** `done.config` is returned to the UI, which then calls the existing `POST /api/agents/:id/channels` (or PATCH) to persist the row. This keeps the setup contract focused on producing config and leaves the persistence/enable path unchanged.
- **The UI wizard is generic.** The admin UI switch-renders on `state.kind` — input form, `awaiting_external` (renders QR and/or "open link" button from `qrText` / `redirectUrl`, polls every `pollIntervalMs`), done, error. No channel-specific UI code. Channels with truly bespoke needs can ship their own admin-UI panel later (out of scope for this contract).
- **`awaiting_external` covers QR scans, OAuth device flow, and any "open this URL in your browser, finish, we'll poll for you" pattern.** Plugins set `qrText`, `redirectUrl`, or both. What is explicitly out of scope for v1 is the classic OAuth redirect flow where the provider posts a `code` back to a callback URL the gateway hosts — that needs per-plugin callback routing and is deferred. Channels that support device-flow OAuth (Google, GitHub, Slack via device code) work today; channels that *only* support the redirect flavor cannot use the setup contract yet and must fall back to the existing token-paste path.
- **All fields except `begin` and `poll` are optional.** Plugins with a single-step external flow (just a QR) don't implement `submit`. Plugins with no external resources (sockets, sub-processes) to clean up don't implement `cancel`.

`setup` is itself optional on `ChannelManifest`. Token-only channels (telegram/slack/discord) leave it unset and continue to use the existing "fill the form, save the row, enable" path.

## Discovery and Loading

Plugins are loaded from two sources, in order:

1. **CLI-bundled defaults.** Manifests imported directly in `apps/cli/src/cli.ts` (or a dedicated `default-channels.ts`) and registered before reading external config. These are bundled by `tsup` and always available.
2. **External packages listed in config.** Read from `gateway.config.channelPackages`:

   ```json
   {
     "channelPackages": [
       "@openhermit/channel-wechat"
     ]
   }
   ```

   At gateway boot, each name is passed to `await import(name)`. Node's module resolution finds the package in the same `node_modules` tree the CLI itself lives in.

If the same `key` is registered twice (e.g. a vendor publishes their own `@vendor/channel-telegram`), the external one wins and a `plugin.duplicate@v1` warning is logged. This lets operators override a default without forking.

### Why global `node_modules` works

When the CLI is installed via `npm install -g @openhermit/cli`, both the CLI and any `npm install -g @vendor/channel-foo` package land as siblings in the same global tree (`/usr/local/lib/node_modules/` on most systems, `~/.npm-global/lib/node_modules/` if the user prefixed npm). Node's normal upward resolution from the CLI's runtime location finds external channel packages without any custom `NODE_PATH` manipulation.

v1 supports npm-installed CLI only — pnpm, bun, homebrew, and standalone-binary distributions are out of scope (see "Scope Decisions" below). For development inside the monorepo, channel packages are workspace deps and resolution is trivial.

## CLI Bundling Policy

| Channel | Package | Published to npm? | Bundled in CLI? | Loaded via |
|---|---|---|---|---|
| Telegram | `@openhermit/channel-telegram` | no (`private: true`) | yes — `tsup noExternal` | static import + manifest registration |
| Slack | `@openhermit/channel-slack` | no (`private: true`) | yes — `tsup noExternal` | static import + manifest registration |
| Discord | `@openhermit/channel-discord` | no (`private: true`) | yes — `tsup noExternal` | static import + manifest registration |
| WeChat | `@openhermit/channel-wechat` | yes (`0.1.0`) | no | global `node_modules` + `channelPackages` config |
| Signal (planned) | `@openhermit/channel-signal` | no (in review on PR #92) | no | global `node_modules` + `channelPackages` config |

This honors "default-bundle the three established channels so `hermit` is usable out-of-box" while keeping the loading path uniform — both bundled and external go through the same manifest registration, so there is no special-case code for the built-ins. Bundled channels keep `private: true` because the CLI binary is the only consumer; only channels meant for `hermit channel install` are published.

## Migration Plan

The plan below is preserved as the historical record of how the rollout was sequenced. Everything is shipped.

1. **Manifest contract.** ✅ `ChannelManifest` type and `ChannelManifestRegistry` runtime class — landed in PR #87 (`packages/protocol`, `apps/gateway/src/channel-manifests.ts`).
2. **Refactor built-ins to export manifests.** ✅ Telegram, Slack, Discord now default-export their `ChannelManifest` and register via the registry — landed in PR #88.
3. **Add `channelPackages` config.** ✅ Gateway config carries a `channelPackages: string[]` field; each entry is dynamic-imported at boot — landed in PR #88 alongside step 2. Empty list = no-op.
4. **Delete `BUILTIN_CHANNELS` constant.** ✅ All consumers read from the runtime registry; admin UI registry derives from registered manifests — folded into PR #88.
5. **Interactive setup contract.** ✅ `ChannelSetup` / `ChannelSetupState` plus the four `/setup/*` gateway routes — landed in PR #90.
6. **First external-plugin channel.** ✅ `@openhermit/channel-wechat@0.1.0` published — landed in PR #94 (manifest) and the corresponding workspace package. Operators add it via `hermit channel install @openhermit/channel-wechat`.
7. **CLI ergonomics.** ✅ `hermit channel install/uninstall/list` — landed in PR #95.

Signal (PR #81 / PR #92) is the next external-default channel candidate, awaiting contributor fixes to packaging. It lands as the second published external plugin and requires no further gateway changes.

## Scope Decisions

### v1 supports npm only

- **No pnpm or bun global resolution.** Their global layouts use symlink trees that Node's default upward resolution from the CLI's location does not traverse. Supporting them is feasible (custom `createRequire` paths, `PNPM_HOME`-aware loader) but each adds a code path that must be maintained. Operators wanting pnpm/bun can install the CLI and channel packages into a project-local `node_modules` and point the gateway at it via env var — but this is unsupported in v1.
- **No homebrew / `curl | sh` / standalone-binary distributions.** Those install the CLI outside any `node_modules` tree, so a sibling-resolution model doesn't apply. v1 is `npm install -g @openhermit/cli` only. Other distribution channels are a future-work item.

These constraints are documented in [`channel-adapter.md`](channel-adapter.md) so users picking pnpm/bun/brew aren't surprised.

### `hermit channel install` (shipped, PR #95)

A thin wrapper around `npm install -g <pkg>` that also appends the package to `channelPackages` in the gateway config. The install does the npm step first so a failed npm install leaves no phantom entry in config; the uninstall writes the config first so a failed npm uninstall doesn't leave the gateway pointing at a package it can't load. A gateway restart is required for changes to take effect.

### Supply-chain trust is the operator's responsibility

The gateway loads arbitrary npm packages whose `start()` runs with full gateway permissions. v1 takes no position on signing, sandboxing, or capability restriction; the operator-facing docs say plainly:

> Installing a channel plugin grants the plugin's code the same authority as the gateway process. Only install plugins from sources you trust — pin versions, audit changes, and treat `npm install -g @vendor/channel-foo` with the same caution as `sudo`.

Signing / capability-scoped sandboxing is a future-work item. Out of scope for v1.

## Open Questions

None at this revision. Future revisions land here as the design hits real-world friction.
