This runbook walks through adding a new remote chat channel integration to
Netclaw (e.g., Microsoft Teams, WhatsApp, Signal) on the post-SPEC-015
architecture. All three existing channels — Slack, Discord, Mattermost —
are built on the generic bases in src/Netclaw.Channels/ and registered
through one fluent builder chain, so a new channel writes only the
platform-specific surface: an options class, an SDK transport adapter, a
reply client, a proactive outbound client, and an address resolver, plus
thin actor subclasses over the shared bases.
Recommended starting point: Mattermost (src/Netclaw.Channels.Mattermost/)
is the simplest and most canonical reference — clone it and adapt. Its
registration chain in
src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs
is the cleanest example of the builder API.
Background reading:
docs/spec/SPEC-015-channel-standardization.mddocuments the consolidation design, what is generic vs. per-channel, and why. This runbook is the operational walkthrough of that design.
The generic infrastructure lives in two places:
src/Netclaw.Channels/— the actor bases and delivery contracts:ChannelLifecycleActor<TSnapshot, TConnectCommand>— gateway connection state machine (reconnect, backoff, health snapshots)ChannelGatewayActor<TChannelId>— inbound routing to per-conversation children, duplicate-event tracking, andSessionIdFormat(the single owner of the session-id grammar)ChannelConversationActor<TMessage>— the per-conversation security pipeline (ACL → bot filter → ingress gate → routing policy → text normalization → session binding)ChannelDeliveryContracts.cs—ChannelDescriptor,ChannelRegistry,IChannelAddressResolver,IChannelOutputRenderer,IChannelOutboundClientIChannel.cs—IChannel,IRemoteChatChannelOptions,IGatewaySnapshot,GatewayChannelHealthProactiveSendFormatting.cs— canonical LLM-visible result strings for proactive sends
src/Netclaw.Daemon/Configuration/RemoteChatChannelBuilder.cs— theAddRemoteChatChannel<TChannel, TOptions>registration builder.
GENERIC (you inherit / call) PER-CHANNEL (you write)
┌──────────────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Netclaw.Channels │ │ Netclaw.Channels.Xxx │
│ │ │ │
│ ChannelLifecycleActor<TSnapshot,TConnect> ◄─┼───┤ XxxNetGatewayLifecycleActor │
│ ChannelGatewayActor<TChannelId> ◄─┼───┤ XxxGatewayActor │
│ ChannelConversationActor<TMessage> ◄─┼───┤ XxxConversationActor │
│ IChannel / GatewayChannelHealth ◄─┼───┤ XxxChannel │
│ IChannelOutboundClient ◄─┼───┤ XxxProactiveOutboundClient │
│ IChannelAddressResolver ◄─┼───┤ XxxAddressResolver │
│ SessionIdFormat ({channelId}/{threadKey}) │ │ XxxNetGatewayTransport (SDK adapter)│
│ │ │ XxxNetReplyClient │
│ Netclaw.Daemon │ │ XxxChannelOptions │
│ RemoteChatChannelBuilder ◄──────────────────┼───┤ one builder chain in │
│ - descriptor (even when disabled) │ │ ChannelIntegrationRegistration- │
│ - keyed IChannel + IHostedService │ │ Extensions.cs │
│ - shared tools (send/lookup), once │ │ │
└──────────────────────────────────────────────┘ └─────────────────────────────────────┘
Runtime actor hierarchy (all structure provided by the bases):
XxxChannel (IHostedService)
│ StartAsync: connect transport, spawn gateway, register actor-registry key
▼
XxxGatewayActor : ChannelGatewayActor<XxxChannelId>
│ dedup, route by channel id
▼
XxxConversationActor : ChannelConversationActor<XxxGatewayMessage>
│ ACL → bot filter → ingress gate → routing policy → normalize → forward
▼
XxxSessionBindingActor (per-thread; runs the LLM session pipeline)
The SPEC-015 "what stays per-channel" core is five files of genuinely new logic plus a builder chain; the actors are subclasses whose structural code lives in the bases. Real line counts from Mattermost:
| File | LOC | Role |
|---|---|---|
MattermostChannelOptions.cs |
44 | Config binding (IRemoteChatChannelOptions) |
MattermostTransportContracts.cs |
253 | Normalized message/snapshot records + client interfaces |
Transport/MattermostNetGatewayClient.cs |
402 | SDK adapter: transport + gateway client hosting the lifecycle actor |
Transport/MattermostNetReplyClient.cs |
110 | Replies into existing threads |
Transport/MattermostNetOutboundClient.cs |
37 | Raw platform posts (proactive) |
MattermostProactiveOutboundClient.cs |
115 | IChannelOutboundClient: ACL + post + session wiring |
MattermostDestinationAddressResolver.cs |
84 | IChannelAddressResolver |
MattermostConnectFailureClassifier.cs |
64 | Fatal-vs-transient connect failure classification |
Transport/MattermostNetGatewayLifecycleActor.cs |
276 | ChannelLifecycleActor subclass (hooks only) |
MattermostGatewayActor.cs |
112 | ChannelGatewayActor subclass (3 projections) |
MattermostConversationActor.cs |
259 | ChannelConversationActor subclass (13 projections) |
MattermostChannel.cs |
311 | IChannel hosted service |
MattermostAclPolicy.cs / MattermostRoutingPolicy.cs |
78 / 86 | Pure-function ACL + routing decisions |
MattermostReminderTargetResolver.cs |
104 | Reminder target resolution |
Transport/MattermostThreadHistoryFetcher.cs |
568 | Thread rehydration (optional but expected) |
MattermostSessionBindingActor.cs |
1,576 | Per-thread session actor — the largest remaining per-channel piece |
Builder chain in ChannelIntegrationRegistrationExtensions.cs |
~80 | All DI wiring |
Honest framing: the session binding actor and the SDK transport adapter are
where the real work is. Everything else is either a thin subclass or a small
pure-function policy, and the contract test suites
(src/Netclaw.Actors.Tests/Channels/Contracts/) pin the required behavior
of all of it.
Throughout, Xxx is your channel; concrete examples are Mattermost.
File: src/Netclaw.Actors/Channels/ChannelType.cs
Add your channel to the ChannelType enum and update ToWireValue(),
TryFromWireValue(), and SupportsInteractiveApproval(). The enum name
("Mattermost") becomes the config section name and display name; the wire
value ("mattermost") becomes the descriptor key and the keyed-service key —
the builder derives all three from the enum
(RemoteChatChannelBuilder.cs, AddRemoteChatChannel).
File: src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs
public sealed class XxxGatewayActorKey;Your IChannel.StartAsync registers the spawned gateway under this key
(see MattermostChannel.cs:
ActorRegistry.For(_system).Register<MattermostGatewayActorKey>(_gateway));
reminder delivery resolves it to route trusted session turns.
File: src/Netclaw.Channels.Xxx/XxxChannelOptions.cs
Implement IRemoteChatChannelOptions (defined in
src/Netclaw.Channels/IChannel.cs) — the builder reads Enabled and
AllowDirectMessages to build the descriptor without knowing your concrete
type:
public sealed class XxxChannelOptions : IRemoteChatChannelOptions
{
public bool Enabled { get; init; }
public SensitiveString? BotToken { get; init; }
public string? DefaultChannelId { get; init; }
public bool AllowDirectMessages { get; init; }
public bool MentionOnly { get; init; } = true;
public bool MentionRequiredInDm { get; init; }
public string[] AllowedChannelIds { get; init; } = [];
public string[] AllowedUserIds { get; init; } = [];
// ... platform-specific knobs (see MattermostChannelOptions.cs)
}Use SensitiveString for tokens. The builder binds the section named after
the enum value (configuration.GetSection("Xxx")).
Config schema sync rule (CLAUDE.md): add a matching top-level
"Xxx"section tosrc/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.jsonin the same PR. The schema is"additionalProperties": falsethroughout — unlisted properties are rejected byConfigSchemaDoctorCheckat runtime. New required properties need a"default"; enums must be"type": "string"with named values.
This is the real SDK work. Define the channel's normalized contracts first
(Mattermost: MattermostTransportContracts.cs):
- an inbound message record (
MattermostGatewayMessage) — what the gateway and conversation actors route - a snapshot record implementing
IGatewaySnapshot(MattermostGatewaySnapshot:IsConnected,IsReady,HealthDetail, plus your bot identity fields) - a gateway client interface (
IMattermostGatewayClient) exposingGetSnapshotAsync/ConnectAsync/DisconnectAsync, theMessageReceived/CleanReconnectRequired/ConnectionRestoredevents, and identity properties - a reply client interface (
IMattermostReplyClient)
Then the adapter itself (Mattermost: Transport/MattermostNetGatewayClient.cs):
a transport interface over the raw SDK (IMattermostGatewayTransport, with
StartAsync/StopAsync and Connected/Disconnected/MessageReceived
events) and a gateway client that hosts the lifecycle actor and translates
its ask protocol into the IXxxGatewayClient surface.
Connect-failure classifier (Mattermost:
MattermostConnectFailureClassifier.cs): a static Classify(Exception)
returning ChannelConnectException with ChannelConnectFailureKind.Fatal
(bad token, bad URL — auto-reconnect is disabled, see
ChannelLifecycleActor.HandleStartFailed) or Transient (network — retried
with exponential backoff). Misclassifying a fatal failure as transient makes
the daemon hammer the platform forever; write tests for it (step 10).
File: src/Netclaw.Channels.Xxx/Transport/XxxNetGatewayLifecycleActor.cs
Only needed when the channel manages its own persistent connection. Discord and Mattermost have one; Slack does not (SlackNet's socket-mode client manages its own connection).
Subclass ChannelLifecycleActor<TSnapshot, TConnectCommand>
(src/Netclaw.Channels/ChannelLifecycleActor.cs). The base owns the entire
state machine — Disconnected → Connecting → Ready → Disconnecting, the
Connect/Disconnect/GetSnapshot ask protocol, exponential-backoff
auto-reconnect with a 60-second stability window, and PostStop timer
cleanup. You supply:
StartTransportAsync/StopTransportAsync— the transport callsOnTransportStartSucceeded— validate the start result; channels whose start implies readiness callCompleteConnectToReady()ClassifyStartFailure— delegate to your classifierCreateSnapshot/ResetIdentityState— snapshot factory and identity resetSubscribeTransportEvents/UnsubscribeTransportEvents— forward transport events toSelfRef(neverSelf; callbacks run off-dispatcher)PublishCleanReconnectRequiredAsync/PublishConnectionRestoredAsync— event-sink publishesIsTransportConnected/HasBotIdentity— readiness inputs- the six
Register*Handlershooks for channel transport events:RegisterCommonChannelHandlers,RegisterNotReadyIngressHandlers, and one per state (Disconnected/Connecting/Ready/Disconnecting)
The ReadySignalTimeout decision. The base's
protected virtual TimeSpan? ReadySignalTimeout => null; encodes whether
your transport start implies readiness:
- Null (default, Mattermost):
StartTransportAsyncreturning success means the channel is ready —OnTransportStartSucceededcallsCompleteConnectToReady()and no timer is armed. - Override (Discord, 30 s): the transport start only begins a handshake
and a separate ready event completes it
(
DiscordNetGatewayLifecycleActor.ReadySignalTimeout). The base arms a timer per connect attempt; if the ready signal never arrives it takes the canonical fail path. For transports that reconnect on their own after a drop,WaitForReadySignal()re-enters Connecting with a fresh attempt stamp without tearing the client down.
The canonical fail path is RequestCleanReconnect(reason). It fails any
pending operator ask, publishes CleanReconnectRequired once per cycle,
stops the transport, and lands in Disconnected with the auto-retry scheduled
— one path whether or not an operator ask is pending. Do not invent your own
teardown branch: the comment in HandleReadySignalTimedOut documents the
0.24.0-beta.2 zombie that branching here produced (an actor parked with no
retry scheduled, silently dropping all traffic).
Constructor caveat: the base constructor calls Become, which invokes
your Register* hooks before your subclass constructor body runs. Hooks
must only register handlers — never read subclass fields at registration
time (documented on the base constructor).
File: src/Netclaw.Channels.Xxx/XxxGatewayActor.cs
Subclass ChannelGatewayActor<TChannelId>
(src/Netclaw.Channels/ChannelGatewayActor.cs) with your channel-id value
type as TChannelId. The base provides bounded duplicate-event tracking
(TryMarkEventProcessed, 4,096 ids, oldest-first eviction), conversation
child get-or-create (GetOrCreateConversation), and the
DeliverTrustedSessionTurn receive for reminder delivery. You implement
three projections — ChannelIdValue, CreateConversationProps,
TryParseSessionChannelId — and register your channel-specific inbound
receives in your own constructor. Override OnMissingEventId() only if your
platform legitimately emits id-less events (Slack does; the default rejects
them because they cannot be deduplicated).
Session-id grammar: SessionIdFormat (top of ChannelGatewayActor.cs)
is the single owner of the {channelId}/{threadKey} format. Build ids with
SessionIdFormat.Build and parse with SessionIdFormat.TrySplit — session
ids are persisted (they key session state and reminders), so the format is a
breaking migration, never a refactor. Write a typed
TryParseXxxSessionId wrapper like MattermostGatewayActor.TryParseMattermostSessionId.
File: src/Netclaw.Channels.Xxx/XxxConversationActor.cs
Subclass ChannelConversationActor<TMessage>
(src/Netclaw.Channels/ChannelConversationActor.cs). The base owns the
inbound security pipeline in a fixed order — do not reorder or bypass:
- ACL gate (
EvaluateAcl) - Bot self-loop filter (
IsBotMessage) - Ingress gate (restart drain via
SessionIngressGate) - Routing policy (
EvaluateRouting→ChannelRoutingVerdict:StartOrContinue/ContinueOnly/Ignore) - Text normalization, 4,000-char truncation, empty-text filter
- Session binding get-or-create and forward
It also owns 2-hour idle passivation, stop-on-failure supervision of session
bindings, and Terminated bookkeeping. You implement 13 abstract members:
two log-context keys (ThreadLogContextKey, EventLogContextKey) and
eleven projections/handlers (EvaluateAcl, IsBotMessage, EventIdOf,
ThreadKeyOf, TextOf, HasAttachments, NormalizeInboundText,
EvaluateRouting, CreateSessionBindingProps, CreateThreadInbound,
PostIngressClosedReplyAsync). Keep the ACL and routing decisions
themselves in pure-function policy classes (MattermostAclPolicy,
MattermostRoutingPolicy) so the contract tests can exercise them without
an actor system.
Slack's conversation actor intentionally does not use this base — its pipeline order differs observably (see the remark on
ChannelConversationActorand SPEC-015 §1.3). New channels should use the base; Mattermost is the canonical example.
Session binding actor (CreateSessionBindingProps target): there is no
generic base — this per-thread actor runs the LLM session pipeline (prompt
injection gate, approvals, output posting, failure notification) and is the
largest per-channel file. Clone MattermostSessionBindingActor.cs and
adapt; SessionBindingContractTests (16 assertions) pins its required
behavior.
Reply client (Transport/XxxNetReplyClient.cs behind
IXxxReplyClient): posts replies into an existing thread; consumed by the
session binding actor and any output renderer.
Raw outbound client (Transport/XxxNetOutboundClient.cs behind
IXxxOutboundClient): the thin SDK wrapper for proactive posts (new thread
in a channel, open a DM). Mattermost's is 37 lines.
Proactive outbound client (XxxProactiveOutboundClient.cs): implements
IChannelOutboundClient (src/Netclaw.Channels/ChannelDeliveryContracts.cs)
— the generic send_channel_message tool dispatches to it by channel key.
It owns, in order: the channel's outbound ACL checks (allowed
channels/users, the AllowDirectMessages gate), the platform post via the
raw outbound client, and wiring the new thread into the session pipeline by
asking the gateway actor (waiting ProactiveSendFormatting.ProactiveThreadAckTimeout,
30 s).
Two invariants:
- ACL sits above the SDK seam. The proactive client wraps the raw outbound interface rather than living inside it, because the ACL checks must be above the fake seam used in tests (SPEC-015 §1.5). Never push an allowlist check down into the SDK adapter.
- Result strings are the LLM-visible tool contract. Build every outcome
from
ProactiveSendFormatting(GatewayNotConnected,DirectMessagesDisabled,UserNotAllowed,ChannelNotAllowed,UnsupportedAddressKind,OpenDmChannelFailed,PostFailed,DescribeTarget,Sent,SentButPipelineFailed) — they must stay byte-stable and identical across channels. ReturnError: ...strings for expected failures; never throw (documented onIChannelOutboundClient).
File: src/Netclaw.Channels.Xxx/XxxAddressResolver.cs
Implement IChannelAddressResolver: a Key
(ChannelDescriptorKey.FromChannelType(ChannelType.Xxx)), the
AddressKinds you handle, and ResolveAsync mapping human-friendly targets
(#channel, @user, raw platform ids) to
ChannelAddressResolutionResult.Resolved / NotFound / Ambiguous /
Unsupported, with your ACL applied. A channel may register multiple
resolvers as long as their (Key, AddressKind) pairs don't collide — the
registry indexes by that pair (Mattermost registers
MattermostDestinationAddressResolver for destinations and
LookupMattermostUserTool for users).
ListDestinationsAsync is opt-in and bounded-sets-only. The default
implementation on the interface returns a loud Unsupported. Override it
only for destinations — the deliverable destination set is bounded (bot
memberships, guild channels, or a configured allowlist) while user
directories are unbounded and only make sense as server-side searches
(documented on IChannelAddressResolver). Return
ChannelAddressResolutionResult.Listed(...); an empty list is a valid
listing, and the same ACL gating as ResolveAsync applies.
Output renderer (optional): the session binding actor posts text replies
directly through the reply client; an IChannelOutputRenderer is only
needed for additional output effects beyond the
TextMessage/FileAttachment baseline — e.g. Discord's typing indicator
(DiscordProcessingOutputRenderer handling
ChannelOutputEffectKind.ProcessingIndicator). Declare the extra effects
via the additionalOutputEffects parameter of AddRemoteChatChannel and
register the renderer with .WithRenderer<T>(). Mattermost registers none.
Reminder target resolver: implement IReminderTargetResolver
(src/Netclaw.Actors/Reminders/IReminderTargetResolver.cs) so reminders can
address @user/#channel targets on your platform; register with
.WithReminderResolver<T>() (Mattermost: MattermostReminderTargetResolver).
Thread history fetcher: implement IThreadHistoryFetcher
(src/Netclaw.Actors/Channels/IThreadHistoryFetcher.cs) to rehydrate prior
thread messages when a session is re-created. Registration is keyed by
channel key, and the keying is load-bearing: with two or more channels
enabled, an unkeyed registration would resolve to the last channel's
fetcher for every channel, silently cross-wiring thread rehydration
(documented on RemoteChatChannelBuilder.WithThreadHistory and the channel
factory in AddRemoteChatChannel, which resolves the keyed fetcher and
passes it to your channel's constructor explicitly). Always register through
.WithThreadHistory(...) — never services.AddSingleton<IThreadHistoryFetcher>.
File: src/Netclaw.Channels.Xxx/XxxChannel.cs
The hosted service that ties it together (Mattermost: MattermostChannel.cs):
StartAsync— connect the gateway client, spawn the gateway actor, register it inActorRegistryunder yourXxxGatewayActorKey. A misconfigured channel degrades, never throws — a missing token is a contained channel failure with an operational alert, not a daemon crash.GetHealthAsync— for snapshot transports, one call:GatewayChannelHealth.Evaluate(snapshot, connectFailureDetail, notReadyFallback, disconnectedFallback)(src/Netclaw.Channels/IChannel.cs).- Expose the spawned gateway (
internal IActorRef? Gateway) and any runtime-resolved values (e.g. a default channel id resolved from a configured name) — the lazy accessors below read them.
Registration: one fluent chain in
src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs,
added to AddChannelIntegrations (which Program.cs already calls).
Mattermost's chain, abbreviated:
internal static void AddXxxChannel(IServiceCollection services, IConfiguration configuration)
{
services.AddRemoteChatChannel<XxxChannel, XxxChannelOptions>(ChannelType.Xxx, configuration)
.WithServices((channelServices, options) =>
{
// Escape hatch for SDK-specific registrations (the raw API client).
// Use an "unconfigured" placeholder for missing tokens — throwing
// here aborts host construction; XxxChannel.StartAsync degrades
// the channel loudly instead (see issue #1033).
})
.WithFilesHttpClient() // "{key}-files" HttpClient + Netclaw headers
.WithTransport<IXxxGatewayClient, XxxNetGatewayClient>()
.WithReplyClient<IXxxReplyClient, XxxNetReplyClient>()
.WithOutboundClient<IXxxOutboundClient, XxxNetOutboundClient>()
.WithThreadHistory((sp, options) => new XxxThreadHistoryFetcher(/* ... */)) // keyed!
.WithReminderResolver<XxxReminderTargetResolver>()
.WithResolver((sp, options) => new XxxAddressResolver(/* ... */))
.WithProactiveSendClient((sp, options) => new XxxProactiveOutboundClient(
sp.GetRequiredService<IXxxOutboundClient>(),
options,
() => sp.GetRequiredService<XxxChannel>().DefaultChannelId, // LAZY
() => sp.GetRequiredService<XxxChannel>().Gateway)) // LAZY
.WithLookupTool((sp, options) => new LookupXxxUserTool(/* ... */));
}Other builder methods when you need them: WithLookupClient<TService, TImpl>
(the lookup SDK wrapper behind your user-lookup tool) and
WithRenderer<TRenderer> (step 8). Pass extra output effects as the third
argument to AddRemoteChatChannel (Discord passes
ChannelOutputEffectKind.ProcessingIndicator).
The lazy accessor rule. ChannelRegistry constructs every registered
IChannelAddressResolver and IChannelOutboundClient eagerly at
startup — before any channel exists. Factories passed to WithResolver /
WithProactiveSendClient must therefore never resolve the channel (or
anything reached through it, like the gateway actor ref) eagerly: capture
Func<...> accessors that call sp.GetRequiredService<XxxChannel>() at
invocation time. An eager resolution recurses through the registry's own
construction (channel → registry → outbound client → channel) — the
comments on all three existing chains in
ChannelIntegrationRegistrationExtensions.cs spell this out.
What the builder does for you — do not do these manually:
- binds options from the section named after the enum and registers them as a singleton
- registers the
ChannelDescriptorviaChannelDescriptor.CreateRemoteChatalways, even when the channel is disabled (the registry lists disabled channels), with a runtime snapshot provider - registers the keyed
IChannel, its unkeyed forward, the concreteXxxChannelforward, and theIHostedServiceforward - registers the shared channel tools —
SendChannelMessageTool,LookupChannelUserTool,LookupChannelDestinationTool— exactly once across all channels (AddSharedChannelTools); there are no per-channel send tools anymore (SPEC-015 §1.5) - turns every
With*call into a no-op when the channel is disabled
Each abstract base in src/Netclaw.Actors.Tests/Channels/Contracts/ asserts
shared security-critical behavior once; your channel proves compliance by
providing a small fixture. Contracts/README.md documents the full
inventory and the fixture how-to for each — follow it. A new channel
inherits:
| Base | Assertions | Notes |
|---|---|---|
AclPolicyContractTests |
16 | Pure functions, no TestKit |
GatewayRoutingContractTests |
3 | Routing, dedup, gateway-level ACL |
RoutingPolicyContractTests |
11 | Mention gating, thread continuation, DM matrix |
SessionBindingContractTests |
16 | Injection gate, approvals, output, failure paths |
ChannelHealthContractTests |
3 | Use SnapshotChannelHealthContractTests (+2) for snapshot transports |
GatewayLifecycleContractTests |
7 | Only if you have a lifecycle actor; runs on TestScheduler virtual time |
ProactiveOutboundClientContractTests |
7 | LLM-visible result strings pinned literally |
dotnet test src/Netclaw.Actors.Tests/ --filter "FullyQualifiedName~Contracts"In src/Netclaw.Actors.Tests/Channels/ (outside Contracts/):
- Connect-failure classifier: fatal vs. transient mapping for your SDK's
exception shapes (
MattermostConnectFailureClassifierTests) - Thread-history fetcher (
MattermostThreadHistoryFetcherTests) - Message chunking, proactive thread creation, approval prompt building, and any platform-unique routing-policy reasons (the contract base tells you to throw on channel-specific ignore reasons — they belong here)
Add cases to
src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs:
- Descriptor appears in
ListChannels()with correct key, kind, capabilities, andSupportedOutputEffects - Disabled channel still lists a disabled descriptor
- Resolver, outbound client, and (if any) renderer are routable by key
- Config schema updated in the same PR
(
netclaw-config.v1.schema.json; CLAUDE.md § Configuration Schema Sync Rule), andnetclaw doctorvalidates the new section - All contract suites inherited and green; channel-specific tests added
-
dotnet slopwatch analyze— no new violations -
./scripts/Add-FileHeaders.ps1 -Verify— headers on all new.csfiles - System skill sync (CLAUDE.md table): adding a channel through the
builder adds no new tools (the shared send/lookup tools already
exist), so no skill update is needed unless you change a tool
schema or add a channel-specific tool — then update
netclaw-operationsand run the eval suite (./evals/run-evals.sh) - Operational impact documented (this runbook,
CONTRIBUTING.mdproject-structure list, CLI help if applicable)
| Layer | Files |
|---|---|
| Enum | src/Netclaw.Actors/Channels/ChannelType.cs |
| Actor key | src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs |
| Channel project | src/Netclaw.Channels.Xxx/ (new; reference Netclaw.Channels + Netclaw.Actors) |
| Options | XxxChannelOptions.cs (implements IRemoteChatChannelOptions) |
| Transport | XxxTransportContracts.cs, Transport/XxxNetGatewayClient.cs |
| Failure classifier | XxxConnectFailureClassifier.cs |
| Lifecycle actor | Transport/XxxNetGatewayLifecycleActor.cs (managed WebSocket only) |
| Actors | XxxGatewayActor.cs, XxxConversationActor.cs, XxxSessionBindingActor.cs |
| Policies | XxxAclPolicy.cs, XxxRoutingPolicy.cs |
| Reply/outbound | Transport/XxxNetReplyClient.cs, Transport/XxxNetOutboundClient.cs, XxxProactiveOutboundClient.cs |
| Channel service | XxxChannel.cs |
| Address resolver | XxxAddressResolver.cs |
| Reminder resolver | XxxReminderTargetResolver.cs |
| Thread history | Transport/XxxThreadHistoryFetcher.cs (registered keyed) |
| DI wiring | one chain in src/Netclaw.Daemon/Configuration/ChannelIntegrationRegistrationExtensions.cs |
| Config schema | src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json |
| Contract fixtures | src/Netclaw.Actors.Tests/Channels/Contracts/Xxx*ContractTests.cs |
| Channel tests | src/Netclaw.Actors.Tests/Channels/Xxx*.cs |
| Registration tests | src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs |