Skip to content

Channel Module integration

This guide is for developers embedding TypeScript external channel modules into a host — Desktop, a CLI tool, or any supervisor process — through the @dotcraft/channel module contract. Channel authoring packages are repository-local. Build sdk/typescript first, then install the required local package directories. See the TypeScript SDK setup for the published client package.

The eight lifecycle statuses a host observes: starting leads to ready and then to stopped, while configMissing, configInvalid, authRequired, authExpired, and degraded branch off that path

Overview

The module contract gives hosts a stable boundary:

  • Load metadata from manifest
  • Create a runnable instance through createModule(context)
  • Observe machine-readable lifecycle and errors
  • Render grouped config UX from configGroups and configDescriptors
  • Substitute module variants by moduleId while keeping runtime channel identity by channelName

Import only from the package root. Don't import package-internal files or infer behavior from the source layout.

Loading a module

A host reads the manifest and factory from the package root:

typescript
import { configDescriptors, configGroups, createModule, manifest } from "@dotcraft/channel-feishu";
import type { ModuleFactory, ModuleManifest } from "@dotcraft/channel";

const moduleManifest: ModuleManifest = manifest;
const moduleFactory: ModuleFactory = createModule;

console.log(moduleManifest.moduleId);
console.log(configGroups.length);
console.log(configDescriptors.length);

Discovering modules

A host can maintain a registry from an allowlist of package roots or moduleId mappings.

Recommended model:

  1. Load known package roots.
  2. Read each manifest.
  3. Index by moduleId.
  4. Maintain optional channel grouping by channelName.

The selection key is moduleId. Runtime channel identity remains channelName.

Creating and starting a module instance

Create WorkspaceContext explicitly and pass it to the module factory.

typescript
import { createModule, manifest } from "@dotcraft/channel-feishu";
import type { ModuleInstance, WorkspaceContext } from "@dotcraft/channel";

const context: WorkspaceContext = {
  workspaceRoot: "F:/workspace/demo",
  craftPath: "F:/workspace/demo/.craft",
  channelName: manifest.channelName,
  moduleId: manifest.moduleId,
};

const instance: ModuleInstance = createModule(context);
await instance.start();

The host controls startup inputs. Pass the workspace context explicitly — a module does not rely on the current working directory to locate the workspace.

Observing lifecycle

Register status handlers before calling start() so no early transition is missed.

typescript
import type { LifecycleStatus, ModuleError, ModuleInstance } from "@dotcraft/channel";

function mapStatusToHostAction(status: LifecycleStatus, error?: ModuleError): string {
  switch (status) {
    case "configMissing":
      return "Prompt user to create module config";
    case "configInvalid":
      return `Show config error: ${error?.message ?? "Invalid config"}`;
    case "starting":
      return "Show connecting state";
    case "ready":
      return "Mark module active";
    case "authRequired":
      return "Start interactive setup flow";
    case "authExpired":
      return "Prompt re-authentication";
    case "degraded":
      return "Show degraded warning";
    case "stopped":
      return "Mark module stopped";
  }
}

function observeLifecycle(instance: ModuleInstance): void {
  instance.onStatusChange((status, error) => {
    const action = mapStatusToHostAction(status, error);
    console.log(`[module-status] ${status} -> ${action}`);
  });
}

The host can query immediate state through instance.getStatus() and the last structured error through instance.getError().

Rendering config UI

If exported, configGroups and configDescriptors drive host config forms without package-internal schema parsing. Render non-empty groups in exported order and keep them expanded.

typescript
import { configDescriptors, configGroups } from "@dotcraft/channel-feishu";
import type { ConfigDescriptor, ConfigGroupDescriptor } from "@dotcraft/channel";

type FormGroup = {
  group: ConfigGroupDescriptor;
  fields: ConfigDescriptor[];
};

const groups: FormGroup[] = configGroups
  .map((group) => ({
    group,
    fields: configDescriptors.filter((descriptor) => descriptor.group === group.id),
  }))
  .filter(({ fields }) => fields.length > 0);

Have the host UI respect:

  • unique, non-empty group ids and valid ConfigDescriptor.group references
  • required for validation
  • masked and dataKind: "secret" for protected input display
  • displayLabel and description as user-facing guidance
  • structured options for localized enum labels and previews; prefer them over enumValues
  • allowCustomValue for a preset-plus-custom enum control
  • defaultValue as the effective display value when the stored field is absent

Showing defaultValue must not initialize or save the field. Persist it only after the user edits the control.

Fields without group appear in an implicit Configuration group. A field with advanced: true and no group appears in an implicit Advanced group. New modules should declare every group and field assignment explicitly.

Interactive setup

Interactive setup is signaled by lifecycle status, not host-specific UI assumptions.

typescript
import type { ModuleInstance } from "@dotcraft/channel";

function attachInteractiveSetupHandlers(instance: ModuleInstance): void {
  instance.onStatusChange((status, error) => {
    if (status === "authRequired") {
      console.log("Display QR path or setup prompt to user");
      return;
    }
    if (status === "authExpired") {
      console.log("Notify session expired and start re-auth flow");
      return;
    }
    if (status === "configMissing" || status === "configInvalid") {
      console.log(`Config action needed: ${error?.message ?? status}`);
    }
  });
}

The host decides the UI (Desktop panel, CLI prompt, dashboard notification). The contract only requires structured state signaling.

Stopping a module

Stop with await instance.stop() and treat stopped as terminal for that runtime instance.

Recommended host behavior:

  1. Disable send and tool actions for this module instance.
  2. Mark connection as offline.
  3. Keep the last structured error for diagnostics.

Variant substitution

Variant substitution lets hosts swap module implementations while preserving logical channel identity.

Selection model:

  • choose implementation by moduleId
  • keep runtime identity by channelName
  • keep default config naming by channel conventions unless manifest explicitly differs

Example:

  • Standard: moduleId = "feishu-standard", channelName = "feishu"
  • Enterprise: moduleId = "feishu-enterprise", channelName = "feishu"

A host can switch variants by changing the selected moduleId without changing the host-facing integration model.

Adding new modules

A third-party package is loadable by the same model when it exports from package root:

  • manifest
  • createModule
  • optional configGroups
  • optional configDescriptors

Checklist for new module packages:

  1. Implement the @dotcraft/channel module contract types.
  2. Keep host integration on package-root exports only.
  3. Provide machine-readable lifecycle and error transitions.
  4. Validate config in module boundary code.
  5. Include package tests and conformance tests.

This keeps first-party, enterprise, and partner modules interchangeable at the host boundary.