Flow Studio
IMessagingChannelAdapter
The channel adapter interface — contract definition, the MessagingChannelRegistry, and how to add a new messaging channel in three steps.
Interface Contract
public interface IMessagingChannelAdapter
{
/// Unique channel type key — must match the node's "provider" config field.
string ChannelType { get; }
/// Send a message. Throw MessagingException on unrecoverable errors.
/// Transient errors (network, rate limit) should throw TransientMessagingException
/// so the executor's retry policy can retry without counting it as a failure.
Task<MessagingResult> SendAsync(
MessagingMessage message,
MessagingConfig config,
CancellationToken cancellationToken);
}
public record MessagingMessage
{
public string To { get; init; } = default!;
public string? Subject { get; init; }
public string? Title { get; init; }
public string? Text { get; init; }
public string? BodyHtml { get; init; }
public string? BodyText { get; init; }
public object? Blocks { get; init; }
public string? Body { get; init; }
public Dictionary<string, object?>? Data { get; init; }
public List<EmailAttachment>? Attachments { get; init; }
}
public record MessagingResult
{
public string MessageId { get; init; } = default!;
public string Channel { get; init; } = default!;
public string Recipient { get; init; } = default!;
public DateTimeOffset SentAt { get; init; }
public string Status { get; init; } = "sent";
}
MessagingChannelRegistry
public class MessagingChannelRegistry : IMessagingChannelRegistry
{
private readonly IReadOnlyDictionary<string, IMessagingChannelAdapter> _adapters;
public MessagingChannelRegistry(IEnumerable<IMessagingChannelAdapter> adapters)
{
_adapters = adapters.ToDictionary(a => a.ChannelType, StringComparer.OrdinalIgnoreCase);
}
public IMessagingChannelAdapter GetAdapter(string channelType)
{
if (_adapters.TryGetValue(channelType, out var adapter))
return adapter;
throw new InvalidOperationException(
$"No messaging adapter registered for channel type '{channelType}'. " +
$"Registered: {string.Join(", ", _adapters.Keys)}");
}
public IReadOnlyCollection<string> RegisteredChannels =>
_adapters.Keys.ToList().AsReadOnly();
}
Adding a New Channel — Three Steps
Step 1: Implement IMessagingChannelAdapter
// Example: Microsoft Teams adapter
public class TeamsChannelAdapter : IMessagingChannelAdapter
{
private readonly ICredentialResolver _credentials;
private readonly IHttpClientFactory _httpFactory;
public string ChannelType => "Teams";
public async Task<MessagingResult> SendAsync(
MessagingMessage message,
MessagingConfig config,
CancellationToken ct)
{
// Retrieve webhook URL stored as credential
var webhookUrl = await _credentials.GetPasswordAsync(config.CredentialId, ct);
var client = _httpFactory.CreateClient();
// Teams Adaptive Card payload
var payload = new
{
type = "message",
attachments = new[]
{
new
{
contentType = "application/vnd.microsoft.card.adaptive",
content = new
{
type = "AdaptiveCard",
version = "1.4",
body = new[] { new { type = "TextBlock", text = message.Text, wrap = true } }
}
}
}
};
var response = await client.PostAsJsonAsync(webhookUrl, payload, ct);
response.EnsureSuccessStatusCode();
return new MessagingResult
{
MessageId = Guid.NewGuid().ToString(),
Channel = "teams",
Recipient = config.ChannelId,
SentAt = DateTimeOffset.UtcNow,
Status = "sent"
};
}
}
Step 2: Register with DI
// In your module's service registration
services.AddSingleton<IMessagingChannelAdapter, TeamsChannelAdapter>();
// The MessagingChannelRegistry is registered as:
services.AddSingleton<IMessagingChannelRegistry, MessagingChannelRegistry>();
Step 3: Add Atlas Form Fields for Config
Add a new entry to the SendMessage node's Atlas Form definition so the designer can configure the Teams-specific fields (webhook credential, channel name) when provider = "Teams" is selected.
Executor — Channel Resolution
public class SendMessageExecutor : BaseNodeExecutor<SendMessageConfig>
{
private readonly IMessagingChannelRegistry _registry;
private readonly IExpressionEvaluator _evaluator;
protected override async Task<NodeExecutionResult> ExecuteAsync(
SendMessageConfig config,
NodeDataContext ctx,
CancellationToken ct)
{
// Resolve the correct adapter for the configured provider
var adapter = _registry.GetAdapter(config.Provider);
// Evaluate all expression fields in the config
var message = await _evaluator.EvaluateMessagingMessage(config, ctx);
var result = await adapter.SendAsync(message, config, ct);
return NodeExecutionResult.Success(new
{
result.MessageId,
result.Channel,
result.SentAt,
result.Status,
result.Recipient
});
}
}
ChannelType key casing: The
MessagingChannelRegistry performs case-insensitive lookup. Use a consistent casing in your ChannelType property (e.g., "Teams") and matching node config value (e.g., "provider": "teams") — both will resolve correctly.