Compatibility policy
A plugin ships as a compiled assembly, built against an SDK version and updated on a schedule its author
controls, not Macro Deck. So the promise is: a plugin compiled against an older SDK keeps loading and
behaving the same against a newer host - no recompile, no behaviour change. Every contract below is
frozen while it is public and not marked [Obsolete], and changes only through the process under
how contracts change. The few deliberate exceptions to that promise are listed
under behaviour changes that moved no version.
What is covered
Section titled “What is covered”| Surface | Examples | Promise |
|---|---|---|
| SDK packages | MacroDeck.Sdk, MacroDeck.Plugin.Hosting, MacroDeck.Plugin.Testing, MacroDeck.Plugin.Packaging, MacroDeck.Plugin.Serilog, MacroDeck.Plugin.Cli, MacroDeck.Localization |
No public type or member is removed or changed incompatibly, source or binary. |
| UI model | MacroDeck.Ui, MacroDeck.Ui.Model, MacroDeck.Ui.Testing |
Frozen like the SDK; the UI model’s wire major is negotiated separately - see the UI model majors. |
| Protocol | MacroDeck.Plugin.Protocol: the envelope, DTOs, message types, error codes |
Append-only within a protocol major; a break needs a new major. |
| Plugin HTTP and WebSocket | /api/plugins/*, /plugins/ws |
Existing endpoints keep their shape and meaning - see the protocol reference. |
| Capability and host API catalogues | device-provider/devices, layout-provider/layouts, folder-view-provider/folder-views, widget-type-provider/widget-types, screensaver-provider/screensavers, messaging/messaging, video-stream-provider/video-streams, calendar, config-entries, battery-provider/batteries, email-inbox, companion-device-entries, locale, companion-devices, music-players, desktop-notifications, event-bindings, http |
Names stay; see capability operations. |
| Manifest and package format | manifest.json, the .macroDeckPlugin package |
An existing manifest and package keep installing - see the manifest reference. |
| Analyzer diagnostic ids | MDP1001, …, the MDLOC family MDLOC001-MDLOC008 |
An id keeps its meaning and is never reused - see analyzers. |
| Conformance check ids | MDC0305, … |
Stable, so you can gate CI on them - see conformance. |
Plugin .resx contract |
the default-language file, Strings.<culture>.resx, named placeholders, the bracketed parameter type in a <comment> |
A plugin’s Localization/*.resx keeps compiling - see Localization. |
| MCP tool contract | macrodeck-mcp/1: tool names, argument names, result fields, error codes |
Additive-only within the contract version - see the MCP server reference. |
| Macro Deck’s localization catalog | macrodeck:Common.Save, … exposed as MacroDeckStrings |
Additive-only: a shipped key is never deleted outright. |
Retiring a catalog key follows the same idea as SDK deprecation: the key keeps resolving, a plugin referencing it gets MDLOC006 naming the replacement, and the record stays for as long as the deprecation lifecycle requires.
Background: ADR 0026 (protocol and SDK boundary) and ADR 0029 (packaging).
Breaking or not?
Section titled “Breaking or not?”Source and binary compatibility both count. A plugin ships compiled, so a change that recompiles cleanly can still break it at run time.
| Change | Verdict |
|---|---|
| Remove or rename a public type, member, parameter, enum value, constant, message type, field or error code | Break |
| Change a parameter or return type, or parameter order | Break |
| Add a parameter to a public method, even with a default value | Binary break |
Change class to struct, or add or narrow a generic constraint |
Break |
| Add a member to a public interface without a default implementation, or make an existing member abstract | Break for every implementer |
| Add a member to a public interface with a default implementation | Allowed |
| Add a new overload | Allowed |
| Add a value to a C# enum | Allowed by .NET’s own rules - give a switch over a Macro Deck enum a default branch |
| Add an optional DTO or manifest field | Allowed - unknown fields are ignored on read |
| Rename a JSON property, change its type, or change an enum’s wire representation | Break |
| Make an optional field required, tighten validation, or change a default a plugin relied on | Break |
| Change an error code’s meaning, even with the same spelling | Break |
Change ordering guarantees, id formats (the integrationId::localId shape), thrown exception types, timing or lifecycle contracts |
Break |
The two that surprise people most:
A parameter with a default is a binary break. The compiler bakes the full argument list into the
call site, so an already-compiled plugin calls a method that no longer exists and gets a
MissingMethodException.
// illustrative method// beforepublic Task ShowAsync(string text);
// after - recompiles cleanly, breaks every compiled callerpublic Task ShowAsync(string text, TimeSpan? duration = null);
// instead - keep the old method, add an overloadpublic Task ShowAsync(string text);public Task ShowAsync(string text, TimeSpan duration);A changed meaning is a break. Nothing about the spelling changes, but a plugin branching on it now branches wrongly.
hypothetical: an error code that meant "retry later" starts to mean "give up"same spelling, same field - still a break, because plugins that retry now retry wronglyHow contracts change
Section titled “How contracts change”The rule is add, do not change.
New overloads, rather than new parameters on an existing method - see the example above.
New optional members with a sensible default, rather than required ones.
New optional DTO fields. Unknown fields are ignored on read, so an older plugin simply does not see what it was not built for.
// illustrative: a reader built before displayName existed reads this exactly as {"id":"a"}{ "id": "a", "displayName": "Kitchen" }New message types. An unrecognised message type produces UNKNOWN_MESSAGE_TYPE and is never fatal:
the peer reports it and carries on. That makes every additive protocol change backward-compatible by
construction.
New capability interfaces a plugin opts into, rather than new members on an existing one. An existing interface is extended only through a default interface implementation.
// illustrative interface names// not this: every existing implementer stops loadingpublic interface IStateProvider { Task RefreshAsync(); }
// this: a new opt-in interfacepublic interface IRefreshableStateProvider : IStateProvider { Task RefreshAsync(); }
// or, on the existing interface, a default implementationpublic interface IStateProvider { Task RefreshAsync() => Task.CompletedTask; }Anything that has to go follows the deprecation lifecycle. It is marked with [Obsolete] and
[MacroDeckDeprecated] carrying a declared removal version, registered so it appears in the registry
table, and it keeps working unchanged the whole time. Removal happens only in the major release its
RemovedIn names - never earlier, never in a minor or a patch. You are told at compile time as
MDP5002, with the removal version and replacement in the message, and at
run time in the desktop app, where the person running your plugin sees the same finding under the same
id. See deprecations and
ADR 0037.
A wire break means a new protocol major, with the previous one still served - see protocol majors.
The 3.0 metadata retype
Section titled “The 3.0 metadata retype”One break was taken deliberately before 3.0 shipped, and it is the only non-additive change on this page.
The members carrying an action’s, a parameter’s and a config-flow step’s user-facing text changed type
from string to LocalizedText:
IActionDefinition.Name/.Description, ActionParameter.Label/.Description/.Placeholder and its
factories, ActionParameterOption.Label, ActionStateDefinition.Label,
WidgetTargetOptions.Label/.Description, ConfigFlowStep.Title/.Description, ConfigFlowLink.Label,
ConfigFlowCopyValue.Label, ConfigFlowInstruction.Text, and IIntegration.Name.
Label = "Client ID" // assigning: unchanged, LocalizedText converts from stringpublic string Name => "Timer"; // implementing: no longer compilespublic LocalizedText Name => "Timer"; // implementing: fixedImplementing one of those members is a source and a binary break. It was affordable because 3.0 had not
been released and no plugin existed to break; the alternative was a permanent parallel surface (Name
beside NameLocalized). After 3.0 this page’s ordinary rules apply to these members like any other.
Two things did not change:
VariableDefinition.Nameis still astring. It is the variable’s identity - the host derives a definition id from it and substitutes it into a template placeholder - so it cannot differ by language. Localized display text goes in the additiveDisplayNameproperty.- Descriptor metadata is localized from protocol v3.
ActionDescriptorDto.Name,ActionParameterDto.Label, the event and issue descriptors and the config-flow DTOs carryLocalizedText, so an out-of-process plugin’s action names render in the reader’s language. Below v3 the SDK resolves them in the plugin’s own default language first, because an older host would reject an object where it expects a string - the negotiated version decides, and v1 and v2 are still served. A plugin’s text inside a Macro Deck UI tree was never affected; it always travelled as a reference.ConfigFlowResultDto.EntryTitlestays a plain string on purpose: the host stores it as the configured entry’s name, which the user then owns and can rename.
Secret resolution in flows
Section titled “Secret resolution in flows”A flow refers to a secret as { "$secret": "<id>" }. The host decrypts such a reference only where the
action declares a Secret or Password parameter, read from the action’s own parameter list, never
from the type a stored block names. That includes declared children of an Object parameter and the
ItemTemplate of an Array parameter. Anywhere else the reference arrives as an empty string.
- A plugin that declares its secret with
ActionParameter.SecretorActionParameter.Passwordreceives the plaintext exactly as before. - A reference in a parameter the plugin declares with any other type, or does not declare at all, no longer reaches it. A declared parameter is converted by its declared type.
- Secrets nested in a declared
ObjectorArrayparameter are now decrypted; before they arrived as the reference object. - A config-flow submit that carries a secret reference the entry does not already hold under that key is refused with an error.
This was treated as hardening, not a contract break: no published plugin read a secret through an undeclared parameter. See secrets in configuration.
Versions
Section titled “Versions”| Version | Scheme | Breaks allowed |
|---|---|---|
| SDK packages | Ordinary semver | Only at an SDK major, and only removals whose RemovedIn names that major |
| Plugin protocol | One monotonically increasing integer major, not semver | Only with a new major; the previous one stays served |
| Capability, per kind | Integer min/max range per kind | Only with a new capability version; negotiated separately from the protocol |
UI model (UiModelVersions) |
Integer major | Only with a new major; negotiated separately from the protocol |
Protocol majors
Section titled “Protocol majors”Unknown fields are ignored and unknown message types are reported rather than fatal, so every additive change is backward-compatible without a version change. A bump therefore only ever means breaking.
Today minimum is 1 and current is 3.
| Major | Changes exactly one thing | Older majors |
|---|---|---|
2 |
A widget appearance change names its states by stable id instead of the fixed Current/On/Off/Both selector, because a button may have any number of states. |
1 fully served - see the migration guide for what a version 1 plugin sees when it restyles a three-state button. |
3 |
Action names, parameter labels and config-flow text may be a {"$localized":…} reference the reader’s client resolves, instead of a plain string. |
1 and 2 fully served. A plugin below 3 must keep sending a plain string, since an older host would reject the object. |
What did not need a major:
- Actions gained the ability to supply a button’s states: an additive descriptor field plus an additive
capability operation, so a version
1plugin can be a state provider. Additive changes never move the major, however visible the feature. - The
variablescatalog half -discover,resolve,subscribeand thevariable-valueshost API’svalue/invalidate- is ordinarycapability.invoke/capability.resultandhost.invoke/host.resulttraffic. Variable definition attributes, awritecapability and asetoperation did change a payload plugins already send, so the capability version moved from1to2whileProtocolVersions.Currentstayed at3. - Widget appearance gained an accent colour:
WidgetAppearancePatch.AccentColor,WidgetAppearanceProperty.AccentColorand an optionalaccentColorfield on the wire patch. An older host ignores the field, so a patch carrying only it applies nothing there andApplyAsyncreturnsfalse. While a Slider’s or History Graph’s colour thresholds are on, a stored accent has no visible effect. - Video streams added a capability kind,
video-stream-provider, a host API,video-streams, andvideo_stream_error reasons under the existingCAPABILITY_UNAVAILABLEcode, all within major3. An older host rejects the kind non-fatally, and a plugin that does not implementIVideoStreamIntegrationnever declares it. The surface was reshaped before any release: Macro Deck now relays the media itself, and signaling, the consumer context and thewebrtcandwheptransports are gone. A development build written against the earlier, unreleased shape must be updated. - Calendars added a capability kind,
calendar, at capability version1, with its own operations, DTOs and reply limits, all within major3. An older host rejects the kind non-fatally and the plugin’s other capabilities keep working, and a plugin that does not implementICalendarProvidernever declares it. The host’s calendar widget types and thecalendartrigger provider are host-owned additions, not protocol changes. - Configuration entries added the
IConfigEntryLifecycleinterface, every member with a default, and the capability kindconfig-entriesat capability version1, within major3. An older host rejects the kind non-fatally and stores entries as before, and a plugin that does not implement the interface never declares it. IConfigEntryLifecyclegained aSynchronizeEntriesAsyncoverload that takes the integration’s ownIVariableApiand whose default calls the existing overload, so an implementation written before it is synchronized exactly as before; itsCancellationTokenhas no default, so a call such asSynchronizeEntriesAsync(entries, default)still binds to the existing overload.ConfigEntryPreparationgainedRejectedVariableNames(empty by default) andConfigEntryPreparationResultgainedRetryOnConflict(falseby default), carried as the optionalrejectedVariableNamesandretryOnConflictonconfig-entries/prepare. An older host never retries; an older plugin never asks to, and is refused on the first conflict as before.ConfigFlowResultgainedCompleteWithoutEntry()andStoresEntry(truefor every other result), carried as the optionalwithoutEntryon aflow.submitCompleteresult. An older host does not know the flag and stores an entry from the collected values under the integration’s name, as forCompletewithout a title.- Email inboxes added the
IEmailInboxProviderinterface, a capability kind,email-inbox, at capability version1, with its own operations, DTOs and reply limits, all within major3.state.updategained a meaning for this kind only: alocalIdnames an inbox whose content or status changed, and the host reads that inbox again without re-describing the kind; every other kind readslocalIdas before. An older host rejects the kind non-fatally and the plugin’s other capabilities keep working, and a plugin that does not implementIEmailInboxProvidernever declares it. The host’s email widget, variables andemailtrigger provider are host-owned additions, not protocol changes. - Event origins added
IUiEventOriginand anIUiSession.Dispatch(UiEvent, IUiEventOrigin)overload whose default forwards toDispatch(UiEvent), so a session compiled before it keeps receiving every event. Theuihost API gainedorigin.report-failureandorigin.show-modal; against an older host they answerfalseor a cancellation. IUiEventOrigingainedOpenUrlAsyncas a default interface member that checks the URL and answersfalse, so an origin implemented before it keeps compiling and loading. Theuihost API gainedorigin.open-url; against an older host, and on a client that did not declare it can open links, it answersfalse. A client declares that ability when it connects, in a form an older host ignores.- Reporting values from an action’s configuration
added
IActionConfigurationSession,ActionConfigurationSessionandActionParameterValuesReportedEventArgs. A configuration session written before them is unchanged and never reports. Theuihost API gainedreport-parameter-values, which an older host answers withCAPABILITY_UNSUPPORTEDand the SDK then drops, so a reported value is not saved there. Theuicapability gainedsession.flush, which a plugin built before it answers withCAPABILITY_UNSUPPORTEDand the host treats as flushed. IVariableApigainedRequestRefreshAsyncandNotifyDeclarationsChangedAsync, both defaulting to a no-op, and thevariableshost API gainedrefresh; an older host makes the request a no-op.NotifyDeclarationsChangedAsyncuses the existingstate.update.IVariableApigainedRequestDefinitionRefreshAsync, defaulting toRequestRefreshAsync, andvariables/refreshgained an optionaldefinitionIdslist; an older host ignores it and reads every declared variable, which is always allowed.IVariableApialso gainedBindAsync, whose default throwsNotSupportedException, and thevariableshost API gainedbind; against an older host the SDK reports the bindings as not supported. Two limits were added:maxVariableRefreshDefinitionIdsandmaxVariableBindingsPerCall.RequestDefinitionRefreshAsyncandvariables/refreshwithdefinitionIdsalso bring catalog resources a user bound forward, not only declared variables, when Macro Deck polls them; a pushed resource is unaffected. Reading more than asked for was always allowed. An older host ignores a catalog resource id, so that resource keeps its refresh interval.MacroDeck.Plugin.Hostingkeeps the named ids when it merges a pending request with a request for every declared variable and sends them next to it, split into lists of at mostmaxVariableRefreshDefinitionIds; an earlier version folded them into the request for every declared variable.VariableNames(MacroDeck.Sdk.Variables),HostOptionsSources(MacroDeck.Sdk.Actions) and theNativeKeysmembersIsPositional,TryGetWindowsScanCode,TryGetMacOsKeyCodeandTryFromWindowsScanCodeare new SDK helpers. They need no host support: the option source ids are the values every Macro Deck already answers, and the name rules are the ones every Macro Deck already applies.IIntegrationContextgainedStorage,LocaleandHttp, andIConfigFlowContextgainedHttp, each a default interface member:UnavailableIntegrationStorage.Instance(every key missing, a write throwsInvalidOperationException),LocalLocaleApi.Instance(the current culture with a 24-hour clock) andDefaultHostHttp.Instance(MacroDeck/and the SDK version), so an existing implementation keeps compiling and behaves as before. Thelocalehost API (time-of-day-format) and the push-onlyhttphost.stateare new: an older host answersCAPABILITY_UNSUPPORTEDor pushes nothing, and the SDK falls back to those same defaults; an older SDK ignores thehttppush. Storage needs no host support: a plugin keeps it in its own data directory.HourCyclesandTimeOfDayFormat(MacroDeck.Sdk.Locale) andIntegrationStorageKeys(MacroDeck.Sdk.Storage) are new SDK types. After 3.0.0-beta.17,TimeOfDayFormat.Formatwrites a 24-hour clock in a culture whose pattern is 12-hour with a two-digit hour (09:05, before9:05); the API is unchanged, and a plugin built against beta.17 keeps its own copy of the formatter until it is rebuilt.maxUiResourceBytesPerPluginandmaxUiResourcesPerPluginrose from 16 MiB and 256 to 48 MiB and 1024. The host advertises them in the protocol descriptor; a plugin built against an older SDK keeps its own, lower quota, which a newer host still accepts.MacroDeck.UiaddedUiWidgetSafeAreaandUiWidgetSurfaceinMacroDeck.Ui.Widgets.IWidgetApigainedGetWidgetDataAsync, defaulting tonull, and thewidgetshost API gainedget-data; an older host answers no data.IVariableApigainedOpenHistoryAsync, whose default throws aVariableHistoryExceptionwithUnsupported, and thevariableshost API gainedhistory.open,history.readandhistory.close; against an older host the SDK reports the history as unsupported.UiWidgetSurfaceAttributesgainedculture, with the readerUiWidgetSurface.Culture. A provider that ignores it, or reads a surface from an older host without it, draws its own text in its default language.- While the host is locked, a client’s events to a plugin’s
widgetordialogsession are refused withHOST_LOCKED, exceptreveal. A plugin never received a guarantee that such events arrive while locked, and the hardware path already refused them. - A Weather widget’s default press now runs the details action its station’s provider declares through
IWeatherProvider.DetailsAction, instead of always running the built-in Weather integration’s Weather details action. A weather plugin built against an older SDK declares none, so its stations get no default press; a flow the user added still runs. See Pressing the widget. - Weather providers gained
IIntegrationContext.WeatherProvider(IWeatherProviderApi,NotifySnapshotsChangedAsync) and theweatherhost api withsnapshots-changed, all within major3. The member is a default interface member returningUnsupportedWeatherProviderApi.Instance, so an existing implementation keeps compiling. An older host answersCAPABILITY_UNSUPPORTED, which the SDK turns into a no-op; the host then reads the stations on its own interval as before. See Telling Macro Deck the weather changed. - Android devices added typed operations to
IAndroidDevice(SendKeyAsync,TapAsync,SwipeAsync,InputTextAsync,StartAppAsync,ForceStopAppAsync,OpenUriAsync,RebootAsync,CaptureScreenshotAsync,GetStatusAsync),IAndroidDevice.LacksUsbPermission,IAndroidDevice.Mode,IAndroidDeviceManager.DefaultDevice,IAndroidDeviceManager.IsAdbUnavailable, theAndroidKey,AndroidRebootMode,AndroidDeviceModeandAndroidDeviceStatustypes, tenadbhost api operations, the asset kindandroid-screenshot, and the optionaldefaultDeviceSerial,adbUnavailable,lacksUsbPermissionandmodefields of theadbstate, all within major3. The members are default interface members, so anIAndroidDeviceorIAndroidDeviceManagerwritten against an older SDK keeps compiling and loading: its typed operations throwAndroidDeviceExceptionwithUnsupported,GetStatusAsyncanswersnull,LacksUsbPermissionandIsAdbUnavailablearefalse,ModeisNormalandDefaultDeviceisnull. An older host answers the new operations withCAPABILITY_UNSUPPORTED, which the SDK reports the same way, and an older plugin ignores the new state fields: a device lacking USB permission still readsunauthorized, one in recoveryoffline.AndroidDeviceStategains no value. The typed operations run while the host is locked, see ADR 0092. - Companion devices added
IIntegrationContext.CompanionDevices, theMacroDeck.Sdk.Companiontypes (ICompanionDeviceApi,UnsupportedCompanionDeviceApiand the device, state, command, result and failure types), thecompanion-deviceshost API (wait,send,request,screenshot) with a per-pluginhost.statepush, the error codeCOMPANION_DEVICES_NOT_ALLOWED, the asset kindcompanion-screenshotand the permissionhost:companion-devices, all within major3.CompanionDevicesis a default interface member returningUnsupportedCompanionDeviceApi.Instance, so an existingIIntegrationContextimplementation keeps compiling and loading. An older host pushes nothing and answersCAPABILITY_UNSUPPORTED; the SDK then reports accessUnsupported, answers a send withfalseand fails a request or screenshot withNotConnected, without throwing. An older plugin ignores the push, and an older host installs a manifest that declareshost:companion-deviceswith theunknown-permissionwarning. The permission is enforced from the start, which breaks no plugin because none used the API before; see ADR 0110. Capability names, failures and network types are open strings: a reader ignores a capability it does not know and reads an unknown failure asfailed. - Reading every provider’s players added
IIntegrationContext.MusicPlayers, theMacroDeck.Sdk.MusicPlayertypesIMusicPlayerApi,MusicPlayerInstanceSnapshotandUnsupportedMusicPlayerApi, and themusic-playershost API (album-color) with ahost.statepush sent to every plugin, all within major3.MusicPlayersis a default interface member returningUnsupportedMusicPlayerApi.Instance, so an existingIIntegrationContextimplementation keeps compiling and loading and reports no players. An older host pushes nothing and answersCAPABILITY_UNSUPPORTED, which the SDK reports as no players and anullcolour, without throwing; an older plugin ignores the push. Reading is not gated, like registering another player’s artwork. - Desktop notifications added
IIntegrationContext.DesktopNotifications, theMacroDeck.Sdk.NotificationstypesIDesktopNotifierandUnsupportedDesktopNotifier, thedesktop-notificationshost API (show) with a per-pluginhost.statepush on registration, the error codeDESKTOP_NOTIFICATIONS_NOT_ALLOWED, the permissionhost:desktop-notifications, andFakeDesktopNotifierinMacroDeck.Plugin.Testing, all within major3.DesktopNotificationsis a default interface member returningUnsupportedDesktopNotifier.Instance, so an existingIIntegrationContextimplementation keeps compiling and loading and shows nothing. An older host pushes nothing and answersCAPABILITY_UNSUPPORTED; the SDK then reportsIsSupportedasfalseandShowAsynccompletes without showing anything, without throwing. An older plugin ignores the push, and an older host installs a manifest that declareshost:desktop-notificationswith theunknown-permissionwarning. The permission is enforced from the start, which breaks no plugin because none used the API before. - Colour variables added
VariableType.Color, whose values travel as an ordinarytextvalue, and an optionalallowAlphafield onActionParameterDto(ActionParameter.AllowAlpha), all within major3. - Batteries added a capability kind,
battery-provider, at capability version1, a host API,batteries, with ahost.statepush sent to every plugin, and theMacroDeck.Sdk.Batteriestypes, all within major3.IIntegrationContext.Batteriesis a default interface member returningUnsupportedBatteryApi.Instance, so an existing implementation keeps compiling. An older host answers a publish withCAPABILITY_UNSUPPORTED, which the SDK turns into a no-op, and an older plugin ignores thebatteriesstate. Kinds, charge states and health values are open strings: a reader maps one it does not know tootheror unknown. An older host drops a variable definition that declaresColorand ignoresallowAlpha. A colour parameter withoutallowAlphastill receives opaque#rrggbb, also when the user bound it to a variable. A new optionalhostApiFeatureslist on the session request lets a plugin declarescripts.input-color; only then does a Color script input reach it ascolor, every other plugin keeps receiving it astextwith the hex value. The SDK declares it automatically. See Color variables. - Colour variables are also a one-way step for the host’s own data, which no plugin contract covers: once
a user variable of type
Colorexists, the persisted user-variables file cannot be read by a Macro Deck release from beforeColor, which then starts without any user variables. Downgrading the host is unsupported from that point; see Colors from a variable. - Script and automation variables added
ActionExecutionContext.ScriptIdandAutomationId, theUserVariableContextrecord,IUserVariableApi.ApplyInContextAsyncandCreateInContextAsync, theUserVariableCreateStatusvaluesUnknownScript(7) andUnknownAutomation(8), and optionalscriptIdandautomationIdfields on theuser-variablesapplyandcreatearguments and on theactionsexecutearguments, all within major3and theactionscapability version1. The new methods are default interface members that delegate toApplyAsyncandCreateAsyncwith the owner widget, so an existingIUserVariableApiimplementation keeps compiling and loading, and calls of the old methods,nullarguments included, resolve as before. An older host never sends the newexecutefields and ignores them onapplyandcreate, so a plugin gets widget and global behaviour there; an older plugin never sends them and gets the same on a new host. See Scopes. - Script and automation variables are another one-way step for the host’s own data: once a variable local
to a script or an automation exists, the persisted user-variables file cannot be read by a Macro Deck
release from before them, which then starts without any user variables. Downgrading the host is
unsupported from that point, as with
Color. - Resolving colours added the
colorshost api (resolve,watchesand acolorshost.state), the optionalmaxColorWatchesfield in the protocol descriptor’s limits, andIIntegrationContext.Colorsas a default interface member, so an existingIIntegrationContextimplementation keeps compiling and loading. An older host answersCAPABILITY_UNSUPPORTED, and the SDK falls back to resolving fixed colours locally, givingnullfor a reference and delivering a watch once. See Resolving colours yourself. - Searchable selects added
ActionParameter.SearchablewithActionParameter.AsSearchable(), and an optionalsearchablefield onActionParameterDto, all within major3. An older host ignores it and draws the plain select; a parameter from an older plugin has nosearchableand is not searchable. See Letting the user search a long list. - Action roles added
IActionDefinition.Role, a default interface member returningnull, theActionRolesconstants and an optionalrolefield onActionDescriptorDto, all within major3andactionscapability version1. An existingIActionDefinitionkeeps compiling and loading with no role; an older host ignores the field.ActionRoles.RunScriptandActionRoles.RunRemoteScriptwere added the same way; an older host’s editor offers script inputs only for its own built-in script actions. See Declaring parameters. - Companion device entries added the
member-less
ICompanionDeviceConfigEntriesinterface and a declarative capability kind,companion-device-entries, at capability version1with the single operationdescribe, answered without data, all within major3. A plugin that does not implement the interface never declares the kind and is unaffected; an older host rejects the declaration non-fatally and creates no entries for the plugin. The built-in Companion integration now reaches the same behaviour through the interface instead of being named by host code, so its entries are created and removed exactly as before. - Shareability added
VariableDefinition.IsShareable(defaulttrue) and an optionalisShareablefield onVariableDefinitionDto, defaulting totruewhen absent, all within major3and without moving thevariablescapability version: a definition from an older plugin stays shareable, and an older host ignores the field. See Defining a variable. - Code parameters added
ActionParameter.VariableInsertFormatand an optionalvariableInsertFormatfield onActionParameterDto, all within major3, and the documentedlanguagevocabulary gainedautohotkey. A parameter from an older plugin has no format and its editor offers no variable insertion; an older desktop app ignores the field. A language an editor does not know is edited as plain text, never refused. - Rendering array items added
ActionParameter.TemplatedItemswithActionParameter.WithTemplatedItems(), and an optionaltemplatedItemsfield onActionParameterDto, all within major3. Absent orfalsekeeps the items unrendered, exactly as before, so an older plugin’sArrayparameters are unchanged; an older host ignores the field and passes the items as entered. - Reading a variable as a flow sees it added
IUserVariableApi.ReadInContextAsync, a default interface member answeringNotSupported, theUserVariableReadStatusenum and theUserVariableReadResultrecord, and theuser-variablesreadoperation, all within major3. An existingIUserVariableApiimplementation keeps compiling and loading. An older host answersreadwithCAPABILITY_UNSUPPORTED, which the SDK reports asNotSupported; a transport failure or an empty reply is reported asUnavailable. - Integration issues added
IntegrationIssue.HelpUrland an optionalhelpUrlfield on a listed issue, all within major3. Only an absolutehttporhttpsaddress is shown. An issue from an older plugin has no link, and an older host ignores the field. - Integration pages added
UiSurfaceKinds.Page,UiPageSurfaceAttributes,UiPageDeclarationandIUiProvider.Pages, a default interface member answering no pages, and an optionalpagesmember onuidescribe, all within major3anduicapability version1. A provider written before pages keeps compiling and declares none; an older host ignores the member and thepagesurface and shows no tab. Conformance check MDC0316 was added for pages declared without apagesurface or with a malformed or repeated id. - Uploaded code input text added
IUiEventOrigin.ReadUploadAsync, a default interface member throwingNotSupportedException,UiUploadNotFoundException, theuihost API operationorigin.read-uploadand the host-to-plugin asset kindui-upload, all within major3. An origin implemented before it keeps compiling and loading. An older host answersCAPABILITY_UNSUPPORTED, which the SDK turns intoNotSupportedException. See the protocol reference. - UI resources accept
text/plain, served as UTF-8, for the text of a code input. Nothing changed for the image types. An older host refuses a text registration, which throwsUiResourceExceptionwithFailed. - Localization moved the UI model major, not this one - see the localization major.
Negotiation happens exactly once, in POST /api/plugins/sessions:
plugin sends: { minimum: clientMin, maximum: clientMax }host computes: negotiated = min(clientMax, current)fails with PROTOCOL_VERSION_UNSUPPORTED when negotiated < max(clientMin, minimum),echoing the host's own supported rangeThe later session.hello only asserts the outcome - it never re-negotiates.
A protocol break means bumping the major and keeping the previous version served for the negotiated
range. Version 1 is never silently redefined. A plugin declaring { minimum: 1, maximum: 1 } in its
manifest’s compatibility.protocol keeps negotiating version 1 against a host that has moved on, until 1
leaves that host’s supported range - at which point the failure is explicit and diagnosable.
Capabilities negotiate independently, per kind, on the same min/max algorithm but with a
non-fatal failure policy: an unknown kind, or one whose range does not overlap the host’s, is
rejected with a reason and the session proceeds degraded. An unknown host API call is answered with
CapabilityUnsupported rather than failing the session, and a plugin that never declares a kind is never
asked about it.
UI model majors
Section titled “UI model majors”The localization major
Section titled “The localization major”Localizable Macro Deck UI text properties moved MacroDeck.Ui.Model (UiModelVersions.Current)
from 1 to 2: a text-bearing configuration property may now carry a localization reference where it
always carried a JSON string, so the session has to say which version it speaks.
Localization did not move the plugin protocol’s major. The protocol moved to 2 for an unrelated
reason (widget state by stable id) and localization would have left it at 1. The two majors have never
been coupled (see
ADR 0038).
Localization broke neither wire: the localization capability kind and its limits are additive, and a
localized value reads a plain JSON string as literal text.
Version 1 of the UI model stays served: the host opens every session at the current version and
honours whatever a provider negotiates down to, so a plugin on version 1 keeps emitting plain strings
untouched. See
ADR 0057.
The component major
Section titled “The component major”Renaming the component vocabulary moved MacroDeck.Ui.Model (UiModelVersions.Current) from 2 to
3. Every node type was respelled: the ten a reader can draw from the tree alone became ui.*, the four
that resolve a Macro Deck reference became macrodeck.*.
widget.* -> ui.* (drawn from the tree alone)widget.* -> macrodeck.* (resolves a Macro Deck reference)This one also moved UiModelVersions.Minimum. It is a hard cut - no widget.* spelling is
accepted, and no shim is published - so majors 1 and 2 of the UI model are no longer served.
Advertising them would be worse than refusing them: an unknown node type is non-fatal, so a version 2
tree would negotiate and then render every node as the unsupported placeholder. This was affordable
exactly once, because no plugin served a component surface and a UI tree is built per session and never
persisted. See
ADR 0064.
What did not need a UI model major
Section titled “What did not need a UI model major”New component types and properties are additive. The modifiers added a
modifiers property an older reader ignores, five gesture event names it never sends, and the ui.modifier
type it answers with the node’s fallback; UiModelVersions did not move. Responsive layouts
added the ui.responsive type the same way, and first-fit layouts the ui.first-fit type. See
UI model compatibility.
The code input added the open UiCodeLanguages vocabulary and the
variableInsertFormat and contentResource properties (UiCodeInput.VariableInsertFormat and
ContentResource), the content-uploaded and content-upload-failed events with
UiCodeInput.OnContentUploaded, OnContentUploadFailed and UiContentUpload, and the page surface kind
with UiPageSurfaceAttributes; UiModelVersions did not move. An older client ignores both properties,
offers no variable insertion and edits the bound value, and never sends the new events; it has no page
surface to open.
The code input later added rows (UiCodeInput.Rows), the editor’s visible line count; an older reader
ignores it and draws its default height.
The page layout added the configuration types split (UiSplit),
list (UiConfigList), list-item (UiConfigListItem) and empty-state (UiEmptyState), the properties
railTitle, selected, badge, iconWithLabel, align and gap, variant on button and stack, and
the value vocabularies UiButtonVariants, UiStackAlignments, UiStackGaps and UiStackVariants;
UiModelVersions did not move. An older reader declines the new types and draws each node’s explicit
fallback, and ignores the new properties, drawing an ordinary button, a plain stack and a heading without a
badge.
What “maintained per major” means in practice
Section titled “What “maintained per major” means in practice”- Within an SDK major, nothing frozen is removed or changed incompatibly. A minor or patch may add, and may deprecate; it may not remove. Upgrading the SDK inside a major should never require a source change to your plugin.
- At an SDK major, only APIs whose
[MacroDeckDeprecated]named that major as theirRemovedIndisappear. You will have had at least one release cycle of compile-time and run-time warnings naming the replacement. - Within a protocol major, the error-code list and the message-type catalogue are append-only.
- At a protocol major, the previous major stays negotiable for as long as the host’s supported range
says it does. Read the descriptor at
GET /api/plugins/protocol, which needs no authentication, rather than hard-coding a version. - Limits and timeouts are advertised, not fixed. They are published in the protocol descriptor and again in the session response. A value changing is not a breaking change; read them at run time.
Verifying compatibility yourself
Section titled “Verifying compatibility yourself”- Run the conformance suite against your plugin:
macrodeck-plugin test. A broken check id is a break, and the ids are stable, so gate CI on them. See conformance. - Reference
MacroDeck.Plugin.Analyzers. Its source generator records the deprecated APIs your compilation actually references, so the host can report confirmed rather than inferred usage. A plugin without it lands ininferred, which is never presented to a user as fact. - Check the compatibility report in the session response: an overall state, the evidence source, and
per-finding guidance. States, best to worst:
compatible,deprecated_apis,update_recommended,update_required,partially_incompatible,incompatible.
If a fix cannot be made without a break
Section titled “If a fix cannot be made without a break”It is not made. A change that breaks a non-obsolete public contract is not shipped as a bug fix, is not made unilaterally, and does not arrive in a minor release. It goes through deprecation, or through a protocol major, or it does not happen.
See also
Section titled “See also”- Deprecations - the lifecycle, the registry, and the evidence model.
- Migrations - what a migration guide contains, and when one is published.
- Conformance - the contract suite and its stable check ids.
- Plugin protocol - version and capability negotiation in detail.