Skip to content

Refactor messaging integrations into a manifest-first planning architecture #3896

@sandl99

Description

@sandl99

Summary

Redesign NemoClaw messaging integrations around a manifest-first planning architecture.

Today, channel behavior is spread across onboarding, sandbox actions, policy logic, Dockerfile patching, config rendering, rebuild handling, and channel-specific scripts. This makes messaging channels hard to reason about, difficult to test in isolation, and expensive to extend.

The proposed architecture makes each channel a typed, mostly serializable ChannelManifest. Shared engines compile selected manifests into a serializable SandboxMessagingPlan, and core CLI workflows apply that plan through a dedicated side-effect boundary.

Target flow:

ChannelManifest
  -> MessagingWorkflowPlanner
  -> ManifestCompiler
  -> SandboxMessagingPlan
  -> MessagingSetupApplier

Class Diagram

classDiagram
  direction LR

  class ChannelManifest {
    +id
    +displayName
    +supportedAgents
    +auth
    +inputs
    +credentials
    +policy
    +render
    +state
    +hooks
  }

  class ChannelManifestRegistry {
    +register(manifest)
    +listAvailable(ctx)
    +get(channelId)
  }

  class MessagingWorkflowPlanner {
    +buildPlan(ctx)
    +buildChannelAddPlanFromSandboxEntry(ctx)
    +buildChannelStopPlanFromSandboxEntry(ctx)
    +buildChannelStartPlanFromSandboxEntry(ctx)
    +buildChannelRemovePlanFromSandboxEntry(ctx)
    +buildRebuildPlanFromSandboxEntry(ctx)
  }

  class ManifestCompiler {
    +compile(manifest, ctx)
  }

  class EnrollmentInputResolver {
    +resolveInputs(manifest, ctx)
  }

  class CredentialBindingEngine {
    +createBindings(credentials, ctx)
  }

  class PolicyResolver {
    +resolve(policySpec, ctx)
  }

  class AgentRenderEngine {
    +render(renderSpec, ctx)
  }

  class HookRunner {
    +runEnrollmentHooks(hooks, ctx)
    +planHooks(hooks, ctx)
  }

  class SandboxMessagingPlan {
    +channels
    +disabledChannels
    +credentialBindings
    +networkPolicy
    +agentRender
    +buildSteps
    +stateUpdates
    +healthChecks
  }

  class SandboxEntry {
    +messagingChannels
    +disabledChannels
    +messaging.plan
  }

  class MessagingSetupApplier {
    +apply(plan)
    +writePlanToEnv(plan)
  }

  class MessagingHostStateApplier {
    +applyPlanToRegistry(sandboxName, plan)
    +readPlanStateFromEnv()
  }

  class TelegramManifest
  class DiscordManifest
  class SlackManifest
  class WeChatManifest

  ChannelManifestRegistry o-- ChannelManifest : contains
  MessagingWorkflowPlanner --> ChannelManifestRegistry : discovers
  MessagingWorkflowPlanner --> ManifestCompiler : compiles add/onboard
  MessagingWorkflowPlanner --> SandboxEntry : loads stored plan
  MessagingWorkflowPlanner --> SandboxMessagingPlan : produces or mutates
  ManifestCompiler --> EnrollmentInputResolver : resolves inputs
  ManifestCompiler --> CredentialBindingEngine : creates bindings
  ManifestCompiler --> PolicyResolver : resolves policy
  ManifestCompiler --> AgentRenderEngine : renders config
  ManifestCompiler --> HookRunner : runs/schedules hooks
  MessagingSetupApplier --> SandboxMessagingPlan : applies
  MessagingHostStateApplier --> SandboxEntry : stores plan
  MessagingHostStateApplier --> SandboxMessagingPlan : persists

  TelegramManifest ..|> ChannelManifest
  DiscordManifest ..|> ChannelManifest
  SlackManifest ..|> ChannelManifest
  WeChatManifest ..|> ChannelManifest
Loading

Proposed Architecture

ChannelManifest should become the declarative source of truth for one messaging channel.

A manifest defines:

  • channel identity and supported agents
  • auth model
  • required and optional inputs
  • canonical and legacy env vars
  • OpenShell provider bindings
  • provider env keys and placeholder shapes
  • policy requirements
  • OpenClaw and Hermes render targets
  • persisted state fields
  • rebuild hydration rules
  • optional imperative hooks

Simple channels such as Telegram, Discord, and Slack should mostly be manifest data.

WeChat should also be manifest-driven, but its exceptional behavior should move behind hooks, such as:

  • host QR enrollment
  • account metadata capture
  • OpenClaw WeChat account seed file generation
  • WeChat account health checks

The manifest should remain a serializable declaration. It should not contain imported implementations or inline functions. It may reference hook handlers by stable IDs.

MessagingWorkflowPlanner coordinates workflow-level planning for:

  • onboard
  • add channel
  • remove channel
  • start channel
  • stop channel
  • rebuild

The planner should not contain channel-specific behavior. It should discover applicable manifests, compile selected channels, aggregate channel plans into one SandboxMessagingPlan, and expose that plan for tests, dry-run output, shadow-mode comparison, and application.

Generic compile flow:

resolve inputs
  -> run enrollment hooks if declared
  -> compile auth
  -> create credential bindings
  -> resolve policy requirements
  -> render agent config/build fragments
  -> compile state updates
  -> schedule apply/build/health hooks
  -> return channel plan

ManifestCompiler should not contain branches like if channel === "wechat" or if channel === "slack". Channel variation should live in manifest data and hook specs.

SandboxMessagingPlan should be serializable and inspectable before any mutation happens.

MessagingSetupApplier should be the only layer that performs real side effects:

  • create, update, attach, detach, or delete OpenShell providers
  • apply or restore sandbox policy
  • apply build-time agent config and build-context files while preparing the sandbox image
  • pass build inputs to sandbox create/rebuild
  • persist registry and session state
  • run apply-time hooks
  • run health checks

The applier should execute the plan from the relevant workflow phase, including image-build preparation and post-create sandbox updates. It should not reinterpret manifests or duplicate channel logic.

Migration Plan

  1. Build the manifest foundation beside the current implementation.
    Add the messaging module with manifest types, channel registry, shared hook registry, and initial channel manifests. Keep this layer isolated until individual responsibilities are ready to replace legacy code.

  2. Migrate enrollment first.
    Move channel selection, input resolution, token prompts, host-QR enrollment, credential binding intent, and persisted channel config into the manifest-driven path. Concrete channel behavior should come from the channel manifest, with exceptional behavior such as WeChat QR
    login handled through registered hooks.

  3. Migrate policy planning and application.
    Move channel policy requirements into manifests and have the planner produce policy actions for add/remove/start/stop/rebuild/onboard. Replace channel-name policy branches with PolicyResolver and MessagingSetupApplier execution.

  4. Migrate credential/provider binding.
    Move provider names, env keys, placeholders, attach/detach/delete intent, and credential rotation metadata into manifest-driven credential plans. Preserve current externally visible provider names and placeholder behavior.

  5. Migrate agent rendering and build inputs.
    Move OpenClaw JSON fragments, Hermes env lines, Docker build args, rebuild hydration, and WeChat seed-file scheduling into the manifest render/state/hook model. Invoke the setup applier during image-build preparation so build-time config and generated files are available before the sandbox starts.

  6. Adopt full workflow planning.
    Once enrollment, policy, credentials, and rendering are manifest-driven, switch workflows to use MessagingWorkflowPlanner -> SandboxMessagingPlan -> MessagingSetupApplier directly for channel lifecycle, rebuild, and onboarding.

  7. Remove legacy scattered channel logic.
    Delete duplicated messaging behavior from onboarding, policy-channel actions, Dockerfile patching, config generation, Hermes config generation, and WeChat seed scripts after the corresponding responsibility has moved to the new architecture.

Proposed module layout

Each concrete channel should live in its own folder. The shared hooks/ module defines hook interfaces, hook registration, and generic hook execution. Channel-specific hook implementations live beside the channel that owns them.

src/lib/messaging/
  manifest/
    types.ts
    registry.ts

  compiler/
    manifest-compiler.ts
    workflow-planner.ts

  engines/
    input-resolver.ts
    credential-binding-engine.ts
    policy-resolver.ts
    agent-render-engine.ts

  hooks/
    types.ts
    registry.ts
    hook-runner.ts

  channels/
    telegram/
      manifest.ts
      index.ts
      fixtures/
      manifest.test.ts

    discord/
      manifest.ts
      index.ts
      fixtures/
      manifest.test.ts

    slack/
      manifest.ts
      index.ts
      fixtures/
      manifest.test.ts

    wechat/
      manifest.ts
      index.ts
      hooks/
        ilink-login.ts
        seed-openclaw-account.ts
        health.ts
      fixtures/
      manifest.test.ts
      hooks.test.ts

Manifest examples

Telegram

export const telegramManifest: ChannelManifest = {
  id: "telegram",
  displayName: "Telegram",
  supportedAgents: ["openclaw", "hermes"],

  auth: {
    kind: "static-token",
  },

  inputs: [
    {
      id: "botToken",
      kind: "secret",
      required: true,
      inputEnv: {
        canonical: "NEMOCLAW_TELEGRAM_BOT_TOKEN",
        legacy: ["TELEGRAM_BOT_TOKEN"],
      },
      providerEnvKey: "TELEGRAM_BOT_TOKEN",
      prompt: {
        label: "Telegram Bot Token",
        help: "Create a bot via @BotFather on Telegram, then copy the token.",
      },
    },
    {
      id: "allowedIds",
      kind: "config",
      required: false,
      inputEnv: {
        canonical: "NEMOCLAW_TELEGRAM_ALLOWED_IDS",
        legacy: ["TELEGRAM_ALLOWED_IDS"],
      },
      prompt: {
        label: "Telegram User ID (for DM access)",
        help: "Send /start to @userinfobot on Telegram to get your numeric user ID.",
      },
      statePath: "allowedIds.telegram",
    },
    {
      id: "requireMention",
      kind: "config",
      required: false,
      inputEnv: {
        canonical: "NEMOCLAW_TELEGRAM_REQUIRE_MENTION",
        legacy: ["TELEGRAM_REQUIRE_MENTION"],
      },
      validValues: ["0", "1"],
      statePath: "telegramConfig.requireMention",
    },
  ],

  credentials: [
    {
      id: "telegramBotToken",
      sourceInput: "botToken",
      providerName: "{sandboxName}-telegram-bridge",
      providerEnvKey: "TELEGRAM_BOT_TOKEN",
      placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN",
    },
  ],

  policy: {
    presets: ["telegram"],
    requiredForActivation: true,
  },

  render: [
    {
      agent: "openclaw",
      target: "openclaw.json",
      fragment: {
        path: "channels.telegram.accounts.default",
        value: {
          botToken: "{{credential.telegramBotToken.placeholder}}",
          enabled: true,
          healthMonitor: { enabled: false },
          proxy: "{{proxyUrl}}",
          groupPolicy: "{{telegramConfig.groupPolicy}}",
          dmPolicy: "{{allowedIds.telegram.dmPolicy}}",
          allowFrom: "{{allowedIds.telegram.values}}",
        },
      },
    },
    {
      agent: "hermes",
      target: "~/.hermes/.env",
      lines: [
        "TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}",
        "TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}",
      ],
    },
  ],

  state: {
    persist: {
      telegramConfig: ["requireMention"],
      allowedIds: ["allowedIds"],
    },
    rebuildHydration: [
      {
        statePath: "telegramConfig.requireMention",
        env: "TELEGRAM_REQUIRE_MENTION",
      },
      {
        statePath: "allowedIds.telegram",
        env: "TELEGRAM_ALLOWED_IDS",
      },
    ],
  },

  hooks: [],
};

WeChat

export const wechatManifest: ChannelManifest = {
  id: "wechat",
  displayName: "WeChat",
  supportedAgents: ["openclaw", "hermes"],

  auth: {
    kind: "static-token",
    acquisition: "host-qr",
  },

  inputs: [
    {
      id: "botToken",
      kind: "secret",
      required: true,
      inputEnv: {
        canonical: "NEMOCLAW_WECHAT_BOT_TOKEN",
        legacy: ["WECHAT_BOT_TOKEN"],
      },
      providerEnvKey: "WECHAT_BOT_TOKEN",
    },
    {
      id: "accountId",
      kind: "config",
      required: true,
      inputEnv: {
        canonical: "NEMOCLAW_WECHAT_ACCOUNT_ID",
        legacy: ["WECHAT_ACCOUNT_ID"],
      },
      statePath: "wechatConfig.accountId",
    },
    {
      id: "baseUrl",
      kind: "config",
      required: false,
      inputEnv: {
        canonical: "NEMOCLAW_WECHAT_BASE_URL",
        legacy: ["WECHAT_BASE_URL"],
      },
      statePath: "wechatConfig.baseUrl",
    },
    {
      id: "userId",
      kind: "config",
      required: false,
      inputEnv: {
        canonical: "NEMOCLAW_WECHAT_USER_ID",
        legacy: ["WECHAT_USER_ID"],
      },
      statePath: "wechatConfig.userId",
    },
    {
      id: "allowedIds",
      kind: "config",
      required: false,
      inputEnv: {
        canonical: "NEMOCLAW_WECHAT_ALLOWED_IDS",
        legacy: ["WECHAT_ALLOWED_IDS"],
      },
      statePath: "allowedIds.wechat",
    },
  ],

  credentials: [
    {
      id: "wechatBotToken",
      sourceInput: "botToken",
      providerName: "{sandboxName}-wechat-bridge",
      providerEnvKey: "WECHAT_BOT_TOKEN",
      placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN",
    },
  ],

  policy: {
    presets: ["wechat"],
    requiredForActivation: true,
  },

  render: [
    {
      agent: "hermes",
      target: "~/.hermes/.env",
      lines: [
        "WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}",
        "WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}",
        "WEIXIN_BASE_URL={{wechatConfig.baseUrl}}",
        "WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}",
      ],
    },
  ],

  state: {
    persist: {
      wechatConfig: ["accountId", "baseUrl", "userId"],
      allowedIds: ["allowedIds"],
    },
    rebuildHydration: [
      {
        statePath: "wechatConfig.accountId",
        env: "WECHAT_ACCOUNT_ID",
      },
      {
        statePath: "wechatConfig.baseUrl",
        env: "WECHAT_BASE_URL",
      },
      {
        statePath: "wechatConfig.userId",
        env: "WECHAT_USER_ID",
      },
      {
        statePath: "allowedIds.wechat",
        env: "WECHAT_ALLOWED_IDS",
      },
    ],
  },

  hooks: [
    {
      id: "wechat-host-qr",
      phase: "enroll",
      handler: "wechat.ilinkLogin",
      outputs: [
        { id: "botToken", kind: "secret", required: true },
        { id: "accountId", kind: "config", required: true },
        { id: "baseUrl", kind: "config" },
        { id: "userId", kind: "config" },
      ],
      onFailure: "skip-channel",
    },
    {
      id: "wechat-seed-openclaw-account",
      phase: "post-agent-install",
      handler: "wechat.seedOpenClawAccount",
      inputs: [
        "wechatConfig.accountId",
        "wechatConfig.baseUrl",
        "wechatConfig.userId",
        "credential.wechatBotToken.placeholder",
      ],
      outputs: [
        {
          id: "openclawWeixinAccountsIndex",
          kind: "build-file",
          required: true,
        },
        {
          id: "openclawWeixinAccountFile",
          kind: "build-file",
          required: true,
        },
        {
          id: "openclawChannelPatch",
          kind: "build-file",
          required: true,
        },
      ],
      onFailure: "abort",
    },
  ],
};

WeChat-specific code should be registered separately through the hook registry:

registerMessagingHook("wechat.ilinkLogin", runWechatIlinkLogin);
registerMessagingHook("wechat.seedOpenClawAccount", seedOpenClawAccount);

The compiler should only see hook declarations and handler IDs. It should not import WeChat code directly.

Non-goals
  • Do not change current user-facing channel behavior in the first phase.
  • Do not change provider names, env keys, placeholders, or policy behavior without compatibility tests.
  • Do not remove legacy code before shadow-mode parity is proven.
  • Do not make WeChat behavior generic; isolate exceptional behavior behind hooks.
  • Do not rely on E2E tests as the primary validation mechanism.
Acceptance criteria

This work is ready when:

  • each messaging channel has a typed ChannelManifest
  • each manifest compiles into a stable SandboxMessagingPlan
  • provider names and placeholders match current behavior
  • policy plans match current channel policy behavior
  • OpenClaw and Hermes render snapshots match current output
  • WeChat QR enrollment and account seeding are isolated behind hooks
  • rebuild hydration is represented through manifest state rules
  • at least one CLI workflow uses MessagingWorkflowPlanner -> SandboxMessagingPlan -> MessagingSetupApplier
  • no raw secrets appear in plans, snapshots, logs, generated config, or test fixtures

Metadata

Metadata

Assignees

Labels

VRDCIssues and PRs submitted by NVIDIA VRDC test team.area: messagingMessaging channels, bridges, manifests, or channel lifecycle
No fields configured for Enhancement.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions