Plugin Developers: What You Need to Know for v26

Plugin Developers: What You Need to Know for v26

Plugin Developers: What You Need to Know for v26

Connexion v26 moves custom device development from .NET Framework and AppDomains to .NET 10 and AssemblyLoadContext.

The overall device model remains familiar: a device belongs to a channel, has configuration and optional configuration UI, processes messages, and participates in the same operational lifecycle. However, v16 plugins are not binary- or source-compatible without changes. They must be migrated and rebuilt for v26.

The main API changes are an asynchronous lifecycle and message model, a clearer separation between cross-platform runtime code and Windows UI code, and simplified batch processing through the base device API. The examples below use C# and show the common v16 pattern followed by its v26 equivalent.

The new project shape

In v16, runtime logic and WPF configuration UI commonly lived in one .NET Framework project:

v16 - one Windows/.NET Framework project

<TargetFrameworkVersion>v4.8</TargetFrameworkVersion> <Reference Include="PresentationFramework" /> <Reference Include="Connexion.Core" />

In v26, put cross-platform runtime code and Windows UI code in separate projects:

Project

Target

Contains

Project

Target

Contains

Device runtime

net10.0

Device class, configuration model, processing logic, runtime metadata, and cross-platform dependencies

UI companion

net10.0-windows

WPF controls, view models, configuration factory, overlays, and other Windows-only UI code

v26 - runtime project

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> </PropertyGroup> <ItemGroup> <Reference Include="Connexion.Core" /> </ItemGroup> </Project>

v26 - UI companion project

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0-windows</TargetFramework> <UseWPF>true</UseWPF> </PropertyGroup> <ItemGroup> <Reference Include="Connexion.Core" /> <Reference Include="Connexion.Share.Ui" /> <ProjectReference Include="..\MyDevice\MyDevice.csproj" /> </ItemGroup> </Project>

The server-side/runtime project should not reference WPF, Connexion.Share.Ui, or any UI components. A clean runtime device can run on both Windows and Linux. The UI companion remains Windows-only because the Connexion management client uses WPF. The UI project will reference the server-side project.

Runtime API migration

The v26 API is asynchronous throughout device startup, shutdown, processing, and error handling. Lifecycle methods accept a CancellationToken and return Task.

v16 pattern

v26 pattern

v16 pattern

v26 pattern

Load, ChannelStarting, ChannelLoaded, ChannelStopping, Start, Stop, and Pause

LoadAsync, ChannelStartingAsync, ChannelStartedAsync, ChannelStoppingAsync, StartAsync, StopAsync, and PauseAsync

ProcessMessage(...) or ProcessMessageAsync(...)

ProcessMessageAsync(...); the synchronous method is removed

OnError(...)

OnErrorAsync(...)

Constructor receives the device key and channel object

Parameterless construction; Connexion supplies DeviceKey and Host

MessageChannel

Host and the channel-device services supplied by v26

context.Message or context.GetMessage<T>()

await context.GetMessageAsync<T>(cancellationToken)

context.Message = value

context.SetMessage(value)

context.Clone(...)

await context.CloneAsync(...)

Top-level message metadata properties

Properties nested under context.Metadata

context.WriteEvent(...)

context.Logger.Write(...)

Separate IBatchDevice and queue batch mode

Override BaseDevice.ProcessMessagesAsync(...) only when custom batch behavior is required

Lifecycle methods

v16

public override void Load(string configuration) { base.Load(configuration); } public override void ChannelStarting() { Logger.Write(EventSeverity.Info, "Channel is starting"); } public override void Start() { OpenConnection(); } public override void Stop() { CloseConnection(); }

v26

public override async Task LoadAsync( string configuration, CancellationToken cancellationToken) { await base.LoadAsync(configuration, cancellationToken); } public override Task ChannelStartingAsync(CancellationToken cancellationToken) { Logger.Write(EventSeverity.Info, "Channel is starting"); return Task.CompletedTask; } public override async Task StartAsync(CancellationToken cancellationToken) { await OpenConnectionAsync(cancellationToken); } public override async Task StopAsync(CancellationToken cancellationToken) { await CloseConnectionAsync(cancellationToken); }

If the v16 device already overrides ProcessMessageAsync, retain that basic shape and migrate the APIs used inside it. The important change is that v26 no longer falls back to the synchronous ProcessMessage method.

Message processing and error handling

v16

public override void ProcessMessage(IMessageContext context) { var message = context.GetMessage<string>(); context.Message = Transform(message); } public override void OnError(IMessageContext context, ErrorEventArgs args) { args.ShouldRetry = true; args.SleepTime = TimeSpan.FromSeconds(5); }

v26

public override async Task ProcessMessageAsync( IMessageContext context, CancellationToken cancellationToken) { var message = await context.GetMessageAsync<string>(cancellationToken); context.SetMessage(Transform(message)); } public override Task OnErrorAsync( IMessageContext context, ErrorEventArgs args, CancellationToken cancellationToken) { args.ShouldRetry = true; args.SleepTime = TimeSpan.FromSeconds(5); return Task.CompletedTask; }

Construction and channel services

v16

public sealed class MyDevice : BaseDevice<MyConfiguration> { public MyDevice(Guid deviceKey, IMessageChannelDevice messageChannelDevice) : base(deviceKey, messageChannelDevice) { } private async Task PostAsync(object message) { var context = MessageChannel.CreateMessageContext(message); await MessageChannel.PostOnChannelAsync(context); } }

v26

public sealed class MyDevice : BaseDevice<MyConfiguration> { // No device constructor is required. Connexion supplies DeviceKey and Host. private async Task PostAsync(object message, CancellationToken cancellationToken) { var context = Host.CreateMessageContext(message); await Host.PostOnChannelAsync(context, cancellationToken); } }

Message access, metadata, cloning, and processing events

v16

var hl7 = context.GetMessage<HL7Message>(); context.PatientId = hl7["PID-3.1"]; context.SendingFacility = hl7["MSH-4.1"]; context.WriteEvent( EventSeverity.Info, "Validated patient {0}", context.PatientId); var branch = context.Clone( "derived message", CloneOptions.AttachmentsAndUserStorage); context.Message = hl7;

v26

var hl7 = await context.GetMessageAsync<HL7Message>(cancellationToken); context.Metadata.PatientId = hl7["PID-3.1"]; context.Metadata.SendingFacility = hl7["MSH-4.1"]; context.Logger.Write( EventSeverity.Info, "Validated patient {0}", context.Metadata.PatientId); var branch = await context.CloneAsync( "derived message", includeAttachments: true, cancellationToken); context.SetMessage(hl7);

Direct mutations to an object returned by GetMessageAsync<T> remain authoritative. Call SetMessage when replacing the primary message with a different object.

Message context, payloads, and storage

IMessageContext no longer exposes a public Message property. Use GetMessageAsync<T> to request the required representation and SetMessage to replace it. Connexion returns the existing compatible representation or performs a supported conversion; if the requested representation cannot be produced, the call fails rather than returning an ambiguous result.

Ordinary and large payloads

v16 materialized the complete string or byte array:

v16

var text = context.GetMessage<string>(); var bytes = Encoding.UTF8.GetBytes(text); await destination.WriteAsync(bytes, 0, bytes.Length);

v26 retains the convenient materialized form and adds scalable carriers for large content:

v26 - materialized access

var text = await context.GetMessageAsync<string>(cancellationToken);

v26 - streamed access

using var payload = await context.GetMessageAsync<StringPayload>(cancellationToken); await using var source = await payload.OpenStreamAsync(cancellationToken); await source.CopyToAsync(destination, cancellationToken);

Use BinaryPayload instead of StringPayload when every input byte must be preserved exactly. Streaming is an optional access pattern inside the normal ProcessMessageAsync method, not a separate device mode.

Attachments

v16

context.Attachments.Add("documents/report.pdf", inputStream, shouldCompress: true); using var report = context.Attachments["documents/report.pdf"].GetStream(); report.CopyTo(destination);

v26

await context.Attachments.AddAsync( "documents/report.pdf", inputStream, shouldCompress: true, cancellationToken); var attachment = await context.Attachments.GetAsync( "documents/report.pdf", cancellationToken); await using var report = await attachment.OpenStreamAsync(cancellationToken); await report.CopyToAsync(destination, cancellationToken);

Replacing UserStorage

In v16, one API selected transient or persistent per-message storage:

v16

context.UserStorage.Add( UserStorageType.Transient, "routingDecision", "priority"); context.UserStorage.Add( UserStorageType.Persistent, "validationResult", new ValidationResult(true));

In v26, choose the API whose lifetime matches the data:

v26

// Memory-only and available to later devices in the current process. context.TransientItems["routingDecision"] = "priority"; // Persistent with this message across queue boundaries. context.Attachments.Add( "state/validation-result.json", new ValidationResult(true)); // Persistent state owned by this device rather than by a message. await DeviceStorage.SetAsync( new DeviceCheckpoint(DateTimeOffset.UtcNow), "checkpoint");

TransientItems is memory-only, Attachments persists with the message, and DeviceStorage persists values scoped to the device.

Batch processing and event hooks

Custom batch processing

In v16, the device implemented IBatchDevice, and batch processing also had to be enabled on the queue:

v16

public sealed class MyDevice : BaseDevice<MyConfiguration>, IBatchDevice { public async Task ProcessMessagesAsync( IMessageBatchContext batchContext, CancellationToken cancellationToken) { foreach (var context in batchContext.GetMessageContexts()) { await ProcessMessageAsync(context, cancellationToken); batchContext.MarkSuccessfullyProcessedForThisDevice(context); } } }

In v26, the separate interface and queue mode are gone. Override the base method only when custom batching is required:

v26

public sealed class MyDevice : BaseDevice<MyConfiguration> { public override async Task ProcessMessagesAsync( IMessageBatchContext batchContext, CancellationToken cancellationToken) { foreach (var context in batchContext.GetMessageContexts()) { await ProcessMessageAsync(context, cancellationToken); batchContext.MarkSuccessfullyProcessedForThisDevice(context); } } }

If you override batch processing, ProcessMessageAsync is no longer called. You are responsible for handling both serial and batch processing within the ProcessMessagesAsync method.

Processing event subscriptions

v16

[EventSubscription("topic://MessageChannel/BeforeDeviceProcessMessage")] public void OnBeforeDeviceProcessed(object sender, ProcessingEventArgs args) { Inspect(args.MessageContext); }

v26

[AsyncEventSubscription("topic://Channel/BeforeDeviceProcessMessage")] public async ValueTask OnBeforeDeviceProcessed( object sender, ProcessingEventArgs args, CancellationToken cancellationToken) { await InspectAsync(args.MessageContext, cancellationToken); }

Handlers registered with [AsyncEventSubscription] are awaited, allowing cancellation and failures to participate in the normal processing flow.

Configuration, metadata, and UI

Configuration models

v16

[DataContract(IsReference = true)] public sealed class MyConfiguration : NotifyBase, IDataErrorInfo { private string m_Endpoint = string.Empty; [DataMember] public string Endpoint { get => m_Endpoint; set { m_Endpoint = value; RaisePropertyChanged(); } } public string Error => string.Empty; public string this[string columnName] => string.Empty; }

v26

[DataContract(IsReference = true)] public sealed class MyConfiguration : DeviceConfigurationBase, IDataErrorInfo { private string m_Endpoint = string.Empty; [DataMember] public string Endpoint { get => m_Endpoint; set { m_Endpoint = value; RaisePropertyChanged(); } } public string Error => string.Empty; public string this[string columnName] => string.Empty; }

Alternatively, a configuration type can implement IDeviceConfiguration directly.

Device metadata

In v16, the attribute constructor included message types and the UI factory type:

v16

[DevicePlugin( "My Device", "Transforms a message", DeviceDefinitionFlags.None, typeof(object), typeof(object), typeof(MyDeviceFactory))] public sealed class MyDevice : BaseDevice<MyConfiguration> { }

In v26, the constructor has three required values; runtime and UI metadata use named properties:

v26

[DevicePlugin( "My Device", "Transforms a message", DeviceDefinitionFlags.None, Categories = ["Transformation"], DetailedDescriptionResourceName = "MyCompany.MyDevice.DetailedDescription.md", DeviceImageResourceName = "MyCompany.MyDevice.DeviceIcon.png", UserInterfaceFactoryTypeName = "MyCompany.MyDevice.Ui.MyDeviceFactory")] public sealed class MyDevice : BaseDevice<MyConfiguration> { }

Rich runtime descriptions allow API and MCP clients to understand a plugin without loading its Windows UI assembly.

Configuration UI host

v16

public sealed class MyDeviceFactory : BaseDeviceFactory<MyConfiguration> { public override FrameworkElement GetUserInterface( IDeviceUIParams deviceUiParams) { return new MyDeviceView(Configuration, deviceUiParams); } }

v26

public sealed class MyDeviceFactory : BaseDeviceFactory<MyConfiguration> { public override FrameworkElement GetUserInterface(IUiHost uiHost) { return new MyDeviceView(Configuration, uiHost); } }

The v26 factory and WPF view belong in the net10.0-windows UI companion project.

Shared messages between devices

Do not define a typed message inside one device assembly and assume another isolated device can use it reliably. Put shared DTOs, records, enums, value objects, and stable interfaces in a small dedicated contract assembly referenced and packaged at the same version by every participating device.

v16

var order = context.GetMessage<MyCompanyOrder>();

v26

var order = await context.GetMessageAsync<MyCompanyOrder>(cancellationToken);

Treat a patch version as a promise of runtime compatibility. Use a minor or major version when older consumers must remain on the contract they were built against.

Custom Code and external services

The Custom Code device remains available on .NET 10 and uses the same asynchronous lifecycle and message-context APIs as compiled devices.

Generated SOAP/WCF client method names depend on the service contract, but the migration generally looks like this:

v16 - blocking proxy call

var request = context.GetMessage<SubmitRequest>(); var response = client.Submit(request); context.Message = response;

v26 - async-first generated proxy

var request = await context.GetMessageAsync<SubmitRequest>(cancellationToken); var response = await client.SubmitAsync(request); context.SetMessage(response);

The Add Service Reference workflow now uses Microsoft's dotnet-svcutil toolchain and can generate from HTTP/HTTPS metadata or a local WSDL file. Connexion's own runtime transport has moved away from WCF, but custom code can still consume an external SOAP/WCF service through the generated client.

For REST services, prefer modern HttpClient asynchronous methods:

using var response = await httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode();

Inbound endpoints in dynamic clusters

A custom device that opens an inbound TCP, TLS, HTTP, HTTPS, WebSocket, gRPC, or other listener should register that listener when it may run in a multi-active or dynamically scaled cluster. Registration lets Connexion keep the external route aligned with whichever application server or Kubernetes pod currently owns the channel.

For a socket listener, call the protected BaseDevice.RegisterRouteAsync helper after the device has bound its listener successfully:

await RegisterRouteAsync( InboundRouteProtocol.Tcp, listenerPort, cancellationToken);

The helper supplies the device key and accesses the route manager on the device's behalf. Use the InboundRouteRegistration overload when an HTTP-style route needs a PathPrefix or HostMatch, or when the route needs diagnostic Metadata. Each registration call replaces the device's previously registered route set. A device with multiple listeners should call RegisterRoutesAsync with its complete collection of InboundRouteRegistration values.

A registration describes a route that Connexion can expose. It is published only while the channel is running and its owning application server is healthy. Stopping or moving the channel therefore removes or redirects the active route automatically. Registration does not create the public load balancer, Kubernetes Gateway listener, firewall rule, TLS policy, or network policy; those remain deployment responsibilities.

See Dynamic Inbound Routing for supported publishing integrations and the end-to-end routing model.

Windows and Linux compatibility

A net10.0 target does not automatically guarantee Linux compatibility. Replace Windows-only assumptions in runtime code or isolate the device to Windows-capable execution groups.

Windows-only pattern

var folder = @"C:\ProgramData\MyCompany\MyDevice"; var endpoint = Registry.GetValue( @"HKEY_LOCAL_MACHINE\Software\MyCompany", "Endpoint", null) as string;

Cross-platform pattern

var folder = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "MyCompany", "MyDevice"); var endpoint = Configuration.Endpoint;

Also review the runtime project for COM components, Windows service APIs, WPF or Windows Forms references, Windows-only native DLLs, authentication behavior, file permissions, case-sensitive filenames, and platform-specific certificate-store access.

Development and debugging

Normal unit and integration tests remain the first line of defense. Also load the packaged plugin into a test Connexion instance and review its assembly-load diagnostics.

The v26 MCP tools can assist by inspecting plugin metadata, inserting a device into a test channel, patching configuration, validating and saving channel structure, and correlating runtime events, logs, queue state, and device statistics. See AI-Assisted Connexion with MCP.

MCP does not itself control the Visual Studio or Visual Studio Code step-through debugger. A local AI connected to both Connexion MCP and IDE tooling could combine the two contexts, while the debugger remains responsible for breakpoints, stepping, variables, and source navigation.