Macro Deck plugin template
September 18, 2026 · View on GitHub
A starting point for an out-of-process Macro Deck 3 plugin: one integration with a single localized example action, and the developer tooling wired up. The action exists to show the shape - the manifest, the resource file, the executor contract - and is meant to be replaced rather than grown.
Looking for worked examples of each capability instead? The sample plugins repository has one coherent plugin per area: actions and variables, a music player, a REST API with a multi-step config flow, a virtual profile.
Getting started
Either install the template and generate a project:
dotnet new install MacroDeck.Plugin.Templates@*-*
@*-* installs the newest published version. The floating form is what you want while the 3.0
template is in preview: dotnet new install picks stable versions by default, and there is no stable
release yet.
dotnet new macrodeck-plugin -n Acme.LightControl --pluginId com.acme.light-control --pluginName "Acme Light Control" \
--publisher "Acme Inc" --repository https://github.com/acme/light-control \
--platforms win-x64 --platforms osx-arm64 --platforms linux-x64
| Parameter | Default | What it sets |
|---|---|---|
-n, --name | MacroDeckPlugin | The project, namespace, solution and the executable names in manifest.json |
--pluginId | com.example.my-plugin | The manifest id: reverse-domain, lowercase, at least two dot-joined kebab segments |
--pluginName | My Plugin | The display name Macro Deck shows |
--publisher | Example Publisher | publisher.name |
--description | A minimal Macro Deck 3 plugin. | description |
--license | MIT | license, as an SPDX identifier |
--repository | https://github.com/example/my-plugin | repository: the GitHub repository the plugin is built from. The Store refuses the placeholder |
--homepage | (omitted) | homepage. Left out of the manifest entirely when not supplied |
--platforms | win-x64, osx-arm64, linux-x64 | Which runtime identifiers land in entrypoints and macrodeck-build.json. Repeat the option per platform; win-arm64, osx-x64 and linux-arm64 are also available |
--homepage is omitted rather than written empty on purpose: the manifest schema requires an absolute
http/https URL, so "" would fail validation. repository is always written, because the Macro
Deck Store refuses an upload without it: set it to the repository the plugin is built in.
The macrodeck-plugin new wizard collects the same values and passes them straight through, so
dotnet new and the CLI produce the same project.
Or clone this repository and rename by hand - the two are the same content. If you clone, change the
id, name, version and description in src/MacroDeck.PluginTemplate/manifest.json, then rename
the projects, the solution file and the namespace. The plugin project pins its AssemblyName and
RootNamespace. The AssemblyName is the executable name the manifest's entrypoints declare: change
both together, or they stop matching.
Either way, replace Assets/icon.svg. It is your plugin's icon: the manifest's icon path is the
single source of truth and the host reads that file directly, so there is no code to change.
Requirements
- .NET SDK 10.0
- A running Macro Deck desktop app for interactive debugging
Quick start
dotnet build
dotnet test
Build and tests need no Macro Deck installation. For an interactive session, use the checked-in Macro Deck - Real Host launch profile after the one-time setup below.
Building against a local SDK build
The template tracks the SDK's published packages and floats to the newest one, so a plain
dotnet build always resolves the latest release. While a change is still unreleased, pack the SDK
from a Macro Deck 3 checkout into this repository's local-feed/ and build against that version:
dotnet pack MacroDeck.slnx -c Release -p:Version=3.0.0-local.1 -o <path-to-this-repo>/local-feed
dotnet build -p:MacroDeckSdkVersion=3.0.0-local.1
NuGet.config already lists local-feed/ as a package source, and MacroDeckSdkVersion sets the
version for every Macro Deck package at once (see Directory.Packages.props). Nothing in the
repository pins the local version, so a plain dotnet build goes back to the published one.
Pick a version that cannot collide with a real release - 3.0.0-local.N rather than reusing a
published preview version, which would put a hand-built package into the global NuGet cache under the
name of a published one.
How a plugin is put together
Project layout
src/MacroDeck.PluginTemplate/
Program.cs the host builder - a few lines and a RunAsync
manifest.json identity, icon and per-platform entrypoints
macrodeck-build.json how `macrodeck-plugin build` publishes each platform
PluginIntegration.cs the integration: lifecycle and capability opt-ins
LogMessageAction.cs the example action, localized end to end
Localization/Strings.resx the default-culture strings, one file per language
Assets/icon.svg the icon the manifest declares
Properties/launchSettings.json the shared real-host debug profile
tests/MacroDeck.PluginTemplate.Tests/
PluginIntegrationTests.cs the plugin builds, the action runs, the catalog is wired
The entry point
MacroDeckPlugin.CreatePlugin(args) wraps WebApplication.CreateBuilder, so everything an ASP.NET
Core application has is available - configuration, options binding, IHttpClientFactory, hosted
services, dependency injection:
var plugin = MacroDeckPlugin.CreatePlugin(args)
.UseMacroDeckLogging()
.UseLocalization(Strings.LocalizationCatalog)
.RegisterIntegration<PluginIntegration>()
.Build();
await plugin.RunAsync();
RegisterIntegration<T>() is the one door: it registers the integration's actions plus a capability
handler for every SDK interface the type implements. The integration is built by DI, so it can take
IHttpClientFactory, IOptions<T>, Serilog's ILogger, PluginMetadata or IPluginCatalogNotifier
in its constructor. UseMacroDeckLogging() routes your log output to the host's log viewer.
Anything the container needs beyond that goes on builder.Services before Build().
The manifest
manifest.json is the plugin's identity, read from the content root at startup. Build() validates it
and fails fast on an invalid id, a missing name or version, or an unreadable icon.
{
"manifestVersion": 1,
"id": "app.macro-deck.template",
"name": "Macro Deck Plugin Template",
"version": "1.0.0",
"description": "A minimal Macro Deck 3 plugin.",
"icon": "Assets/icon.svg",
"entrypoints": {
"win-x64": {
"executable": "runtimes/win-x64/MacroDeck.PluginTemplate.dll",
"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }
},
"osx-arm64": {
"executable": "runtimes/osx-arm64/MacroDeck.PluginTemplate.dll",
"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }
},
"linux-x64": {
"executable": "runtimes/linux-x64/MacroDeck.PluginTemplate.dll",
"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }
}
},
"publisher": { "name": "Example Publisher" },
"license": "MIT",
"compatibility": { "macroDeck": ">=3.0.0-0" }
}
Only manifestVersion, id, name, version and entrypoints are required; description, icon,
license, publisher.name and compatibility are what publishing to the store additionally needs. A
manifest may also declare permissions, dependencies, conflicts, iconPacks, languages and
files[] - macrodeck-plugin inspect reports all of them, and pack recomputes files[] and
languages for you. Never hand-maintain those two.
Each entrypoint lives under runtimes/<rid>/ so a multi-platform artifact cannot collide with itself.
The template is framework-dependent: each entrypoint names the .dll and carries a runtime
block, and macrodeck-build.json publishes with --self-contained false and -p:UseAppHost=false.
Macro Deck ships a .NET 10 runtime, including ASP.NET Core, with the host (from 3.0.0) and starts a
framework-dependent plugin on it, so the artifact carries only your own assemblies - about 0.7 MB per
platform for this template instead of about 43 MB self-contained. Because the process is the shared
dotnet executable running your .dll, the plugin shows up as dotnet in Task Manager, Activity
Monitor or ps, not under its own name.
Self-contained is still supported, and is what an entrypoint without a runtime block means. Choose
it when the plugin needs a runtime Macro Deck does not ship, such as another .NET major version. Switch
all three places together, per platform: drop the runtime block, point executable at the apphost
(MacroDeck.PluginTemplate.exe on Windows, MacroDeck.PluginTemplate elsewhere), and publish with
--self-contained true without -p:UseAppHost=false. Mixing the two pairings fails validation.
win-arm64 falls back to win-x64 and osx-arm64 falls back to osx-x64; there is no "any" key,
and linux-musl-* resolves no fallback at all.
The build configuration
macrodeck-build.json sits beside the manifest and tells macrodeck-plugin build how to produce each
runtime identifier the manifest declares:
{
"version": 1,
"targets": {
"win-x64": {
"executable": "dotnet",
"arguments": [
"publish", "MacroDeck.PluginTemplate.csproj",
"-c", "Release",
"-r", "win-x64",
"--self-contained", "false",
"-p:UseAppHost=false",
"-o", "bin/publish/win-x64"
],
"output": "bin/publish/win-x64"
}
}
}
executable plus arguments rather than a shell string, and one output directory per target. The
shape carries no .NET assumptions - the values do - so a plugin built with another toolchain replaces
the values and keeps the keys. A target is required for every runtime identifier the manifest
declares; adding a platform means adding it in both files.
Capabilities
PluginIntegration implements IPluginIntegration - lifecycle and actions - and opts into
everything else by implementing that capability's interface. The host discovers each one by filtering
on the interface, so you only implement what you need. Identity and the icon are not on this list:
they come from the manifest.
| Capability | Interface |
|---|---|
| Actions | IActionDefinition, in Actions |
| Config flow | IConfigFlowProvider |
| Variables | IVariableProvider |
| Events | IEventProvider |
| Issues | IIntegrationIssueProvider |
| Music players | IMusicPlayerProvider |
| Weather | IWeatherProvider |
| Virtual profiles | IProfileProvider |
An integration that provides a config flow starts disabled until the user configures it; everything else defaults to enabled.
Capability ids are namespaced by the host as integrationId::localId, so you declare provider-local
ids and never the qualified form.
The sample plugins are the worked examples for each of these.
Localization
Every string a user reads comes from Localization/Strings.resx, not from a literal. The
MacroDeck.Plugin.Analyzers source generator turns that folder into a typed Strings class whose
members return a LocalizedString - a reference, not text - and
UseLocalization(Strings.LocalizationCatalog) in Program.cs hands the catalog to the host. The host
resolves each reference for whoever is reading it, so a language change takes effect without the plugin
rebuilding anything.
public LocalizedText Name => Strings.Actions.LogMessage.Name();
public IReadOnlyList<ActionParameter> Parameters { get; } =
[
ActionParameter.Text(
"message",
label: Strings.Actions.LogMessage.Message.Label(),
description: Strings.Actions.LogMessage.Message.Description(),
placeholder: Strings.Actions.LogMessage.Message.Placeholder(),
required: true),
];
A dotted key becomes a nested class, so Actions.LogMessage.Name in the resource file is
Strings.Actions.LogMessage.Name() in code. Name keys after where they are used, so a translator can
place a string without reading the source.
Anywhere the SDK takes a LocalizedText takes one of these: action names and descriptions, parameter
labels, descriptions and placeholders, ActionStateDefinition labels, config flow step titles and field
labels, event and variable metadata, issue text, and the message on ActionResult.Failed. A plain
string also converts, and stays untranslated - which is what makes a missed one easy to spot once a
second language exists.
One field is deliberately not localized: ConfigFlowResult.Complete(title, …) takes a plain string,
because the host stores that title as the configured entry's name and the user can rename it.
Reuse Macro Deck's own strings
MacroDeckStrings is the catalog Macro Deck already ships translated - Common.*, Validation.*,
Connection.*, Settings.*. Use it instead of declaring your own copy of a generic string; the example
action composes one with a key of its own:
ActionResult.Failed(
ActionErrorCodes.InvalidParameter,
MacroDeckStrings.Validation.Required(Strings.Actions.LogMessage.Message.Label()));
Adding a key
- Add a
<data name="..."><value>...</value></data>entry toLocalization/Strings.resx. - Build. The generator adds the matching
Strings.*member. - Use it wherever the SDK asks for a
LocalizedText.
Placeholders are named and substituted by name, not by argument order:
<data name="Actions.Ping.Result" xml:space="preserve">
<value>Reached {host} in {milliseconds} ms.</value>
<comment>[milliseconds:int] Round-trip time.</comment>
</data>
The generator turns each into a method parameter, so forgetting one is a compile error. A placeholder is
a string unless a bracketed prefix on the <comment> narrows it to int, long, double or bool;
the rest of the comment stays the note a translator reads.
A count-dependent sentence is one key with [plural] on every form and keys suffixed .One and
.Other (Other is required). The two entries generate a single member taking the count first. The rule
is count == 1 for every language - deliberately not CLDR - so phrase Other to stay grammatical for
languages that need forms this model has no room for.
Adding a language
Add Localization/Strings.<culture>.resx beside the default file, using a well-formed BCP-47 name:
Strings.de.resx, Strings.pt-BR.resx, Strings.zh-Hant-TW.resx. Full tags only - zh-Hans and
zh-Hant are different languages and both would collapse onto zh. An underscore (Strings.de_DE.resx)
is a build error rather than a culture nobody ever reaches.
A translation needs only the keys it actually translates. Resolution tries the requested culture, its
neutral culture, the catalog's default language, then en, so a half-finished translation degrades to
English. A key no culture carries renders as a conspicuous [[plugin:<id>:Key]] rather than blank.
There is nothing to register per language - Strings.LocalizationCatalog already carries every culture
in the folder.
macrodeck-plugin build and pack derive the manifest's languages array from this folder. Do not
maintain it by hand.
The generator reports its own diagnostics while you type - a key only a translation has, a placeholder
set that disagrees with the default language, a malformed culture suffix, a broken plural family
(MDLOC001-MDLOC008).
Editing translations
.resx is the canonical format because JetBrains Rider's Localization
Manager reads it: every key as a
row, every culture as a column, missing translations highlighted, CSV export for handing a translator a
spreadsheet, and renames applied across every culture at once. Nothing requires Rider - these are plain
.resx files - but that is the workflow the format was chosen to unlock.
The full reference, including every diagnostic, is the localization guide.
Run and debug against Macro Deck
The project contains exactly one interactive launch profile: Macro Deck - Real Host. It launches
the plugin project directly, so Rider and Visual Studio attach the debugger to plugin code without a
wrapper or child-process attach. The profile connects in self-registering mode to the installed Macro
Deck desktop app at http://127.0.0.1:8193.
For the first run:
- Start Macro Deck.
- Open Developer Tools → Plugin tokens, create a token and copy it. It is shown only once.
- Store the token in the source project's .NET User Secrets using one of the methods below. The
project is already initialized; do not run
dotnet user-secrets init. - Select Macro Deck - Real Host and start it with Debug.
- Once enrollment succeeds, remove the token from User Secrets.
Set the token in Rider or Visual Studio
In Rider, right-click MacroDeck.PluginTemplate in the Solution Explorer and select
Tools → .NET User Secrets. In Visual Studio, right-click the same source project and select
Manage User Secrets. Do not select the .Tests project.
The IDE opens a secrets.json file stored in your user profile, outside this repository. Replace its
contents with:
{
"MacroDeck:Plugin:EnrollmentToken": "<paste the one-time token here>"
}
Save the file, then start Macro Deck - Real Host. After enrollment, reopen secrets.json and
remove the MacroDeck:Plugin:EnrollmentToken entry.
Set the token from a terminal
From the repository root on macOS or Linux, use the following form. It reads the token without echoing it and does not put the value in shell history or process arguments:
project="src/MacroDeck.PluginTemplate/MacroDeck.PluginTemplate.csproj"
printf "Enrollment token: "
read -rs md_enrollment_token
printf '\n'
printf '{"MacroDeck:Plugin:EnrollmentToken":"%s"}\n' "$md_enrollment_token" |
dotnet user-secrets set --project "$project"
unset md_enrollment_token
After the first successful profile launch, remove the one-time token:
dotnet user-secrets remove "MacroDeck:Plugin:EnrollmentToken" --project "$project"
With PowerShell 7, use the equivalent masked-input form:
$project = "src/MacroDeck.PluginTemplate/MacroDeck.PluginTemplate.csproj"
$token = Read-Host "Enrollment token" -MaskInput
@{ "MacroDeck:Plugin:EnrollmentToken" = $token } |
ConvertTo-Json -Compress |
dotnet user-secrets set --project $project
Remove-Variable token
Then remove it after enrollment:
dotnet user-secrets remove "MacroDeck:Plugin:EnrollmentToken" --project $project
The profile persists the exchanged plugin credential under
src/MacroDeck.PluginTemplate/.macrodeck-dev-state/, which is ignored by Git and excluded from the
template package. Later profile launches reuse that credential. The User Secrets id is renamed with a
generated project, so each plugin gets a separate local secret store.
User Secrets are local-only but not encrypted. Never put the enrollment token in launchSettings.json,
a shared IDE configuration, a literal command argument or a commit. If you intentionally clear the
local state, create a fresh token and repeat the User Secrets step. Self-registration only works
against a host on the same machine. See the official
Rider User Secrets guide and
.NET Secret Manager guide
for more background.
The developer CLI
macrodeck-plugin validates, inspects, packs and conformance-tests a plugin. Interactive starts use the
launch profile above.
dotnet tool install --global MacroDeck.Plugin.Cli --prerelease
--prerelease is required while the 3.0 SDK is in preview: only preview versions are published, and
dotnet tool install picks stable ones by default. Drop it once 3.0 ships.
The tool needs the ASP.NET Core shared framework, not just the .NET runtime - its stub host is a real Kestrel server.
| Command | What it does |
|---|---|
new | Scaffolds a project from this template, prompting for the values dotnet new takes as parameters. |
build | Publishes every runtime identifier the manifest declares using macrodeck-build.json, then packs the result. |
validate | Checks a manifest, version directory or artifact against the real manifest reader, the JSON Schema, the permission vocabulary and declared file digests. |
inspect | Reports what installing an artifact would find - entrypoints, permissions, dependencies, conflicts, compatibility, signature shape, size. |
pack | Builds a .macroDeckPlugin artifact, validating the manifest first and recomputing files[] digests. |
run | Launches the plugin against a real host or a disposable stub one, streaming its output. |
test | Runs the conformance suite and writes a text, JSON or Markdown report. |
sign, verify, keygen | Creator signing for a packed artifact. |
Running without a host
macrodeck-plugin run --project src/MacroDeck.PluginTemplate --stub-host
--stub-host starts a disposable in-process host, so this needs no Macro Deck installation: the plugin
registers, negotiates the protocol and initializes, and its log output is streamed until you interrupt
it. --artifact <file> does the same for a packed artifact, which is what proves an entrypoint path in
the manifest matches what build actually wrote. Drop --stub-host to attach to the running desktop
app instead; for debugging with breakpoints, use the launch profile above rather than this.
Packing a release
build is the whole path: it reads macrodeck-build.json, publishes each declared platform into its
runtimes/<rid>/ slot and packs the artifact.
macrodeck-plugin build --source src/MacroDeck.PluginTemplate --output ./artifacts
macrodeck-plugin build --source src/MacroDeck.PluginTemplate --rid win-x64 --output ./artifacts
The second form builds one platform, which is what a CI matrix job wants.
macrodeck-plugin inspect --artifact ./artifacts/<id>-<version>.macroDeckPlugin
Packing validates before it writes, so a bad manifest never becomes an artifact. It discards whatever
files[] the source manifest declared and recomputes every digest from disk, and fills in languages
from Localization/. It cannot sign anything: sign after packing, against the packed manifest, or the
digest will not match.
A plain dotnet build -c Release does not produce a packable layout - the manifest points at
runtimes/<rid>/, which only build assembles. Use validate against a built artifact or a version
directory rather than against bin/Release/net10.0.
Conformance
macrodeck-plugin test --project src/MacroDeck.PluginTemplate --report markdown --output conformance.md
The suite drives a real session against your plugin: capability contracts, invocation and cancellation
semantics, reconnect and resume behaviour, the reserved /_macrodeck/* endpoints, and logging limits.
Checks are Required or Recommended, each with a stable id (MDC0401, …) you can select with --check
or --category. A check can report SKIP with a reason when your plugin gives it nothing to observe -
which is most of them until you add capabilities.
Exit codes make it usable as a CI gate - 0 conformant, 1 the plugin is wrong, 2 usage error, 3
input unreadable, 4 cancelled. 1 and 3 are deliberately distinct: a missing file is an
environment problem, not a verdict about the plugin.
Testing
dotnet test
The test project references MacroDeck.Plugin.Testing, which provides a loopback test host, fakes and
assertions for testing a plugin without a running Macro Deck. PluginTestHarness.Create builds your
plugin from the same Action<PluginHostBuilder> Program.cs uses - no socket, no host, no built
executable - with a ManualTimeProvider for the clock and a FakeIntegrationContext you can seed and
assert against:
await using var harness = PluginTestHarness.Create(builder => builder.RegisterIntegration<PluginIntegration>());
await harness.InitializeIntegrationsAsync();
Drive capabilities through the typed clients it exposes (harness.Actions, harness.Variables, …)
rather than calling an executor directly, so parameter binding is under test too.
MacroDeckTestHost.HostAsync puts the wire itself under test, and MacroDeckTestHost.LaunchAsync
runs a real child process.
The conformance suite above covers the protocol contract; these tests are for your own behaviour.
Contributing to the template
The template repository's root is the dotnet new content, so changing the template is an ordinary
change to the plugin in src/. How the package is built and released is documented in
packaging/README.md.
License
MIT - see LICENSE. Macro Deck itself is licensed under Apache 2.0.
A generated project carries this MIT LICENSE file whatever --license you passed: the parameter sets
the manifest's license field only. If you chose something else, replace LICENSE to match.
Further reading
- Plugin development docs
- Sample plugins - a worked example per capability
plugin-hosting.md- the builder API, registration modes, the artifact format and everyMACRO_DECK_PLUGIN_*variablesdk-reference.md- every interface and record you build againstcli.md- every CLI command and optiontesting-plugins.md- the test harness, the fakes and the manual clockconformance.md- the conformance suite and its check idsanalyzers.md- the compile-time diagnostics