Macro Deck sample plugins
September 12, 2026 · View on GitHub
Worked examples of out-of-process Macro Deck 3 plugins. They build against the published SDK packages
(MacroDeck.Sdk, MacroDeck.Plugin.Hosting, MacroDeck.Plugin.Serilog, MacroDeck.Localization),
so the Macro Deck source tree is not needed to build them - only to run a host against them during
development.
They are laid out exactly the way macrodeck-plugin new scaffolds a plugin, so anything true of a
sample here is true of a project you create yourself: the same manifest.json fields, the same
macrodeck-build.json, the same Localization/ resource set.
Each sample is one coherent, self-contained integration rather than a pile of snippets: its actions, variables, events and providers read the same state, so what a widget shows and what a variable says cannot disagree. None of them needs an external service, credentials or network access.
| Sample | Read it for |
|---|---|
MacroDeck.SampleWeatherPlugin | The smallest complete plugin: plain and dynamic-options actions, read-only and writable variables, an event, a one-step config flow and a weather provider. |
MacroDeck.SampleMusicPlayerPlugin | The full music-player surface: transport, artwork, catalogue browsing, output devices, two instances with different capabilities, dynamic event options and the client-side pickers. |
MacroDeck.SampleRestApiPlugin | A third-party REST API done properly: typed HttpClient through DI, a multi-step config flow with a secret and an OAuth branch, integration issues, notifications and API-backed variables and options. |
MacroDeck.SampleVirtualProfilePlugin | A plugin-owned virtual profile with widget interactions, and the callbacks going the other way: deck navigation, widget appearance, scripts, host variables and notifications. |
Looking for a starting point for your own plugin rather than something to read? Use the plugin template repository instead - it is the same shape, set up to be renamed and stripped down.
Capability coverage
Which sample demonstrates what, against the capability parity matrix. Use it as the checklist when the Plugin API grows: a new capability belongs in an existing sample, or in a new one.
| Capability | Weather | Music player | REST API | Virtual profile |
|---|---|---|---|---|
| Actions | ● | ● | ● | ● |
| Dynamic action options | ● | ● | ● | ● |
| Writable variables (Slider two-way binding) | ● | ● | ||
ActionResult error mapping | ● | ● | ● | ● |
Conditional parameters (OnlyWhen) | ● | ● | ● | |
| Variables (text, numeric, boolean, unavailable) | ● | ● | ● | ● |
Pushed variable updates (IVariableApi) | ● | |||
| Events and payloads | ● | ● | ● | ● |
| Dynamic event options | ● | |||
| Config flow | ● (one step) | ● (multi-step, secret, OAuth) | ||
| Integration issues and resolution | ● | |||
| Icons / assets | ● | ● | ● | ● |
| Music player, catalog, devices, artwork | ● | |||
| Weather provider | ● | |||
| Virtual profiles and widget interactions | ● | |||
| Deck navigation, widgets, scripts, notifications | ● (notifications) | ● | ||
| Action interaction pickers | ● | |||
state.update catalogue invalidation | ● | ● | ||
Localized text (Localization/*.resx) | ● (en, de) | ● (en) | ● (en) | ● (en) |
Two contracts have no sample on purpose:
IConfigurableActionDefinition.DescriptiveUiSchema- the contract exists and travels over the wire, but no host renderer consumes it yet, so a sample would have to invent a schema format.IUserVariableApi- it edits user-owned variables a deck author created; a sample plugin has no such variable to edit without inventing one that only exists on the author's machine.
Requirements
- .NET SDK 10.0
- A running Macro Deck desktop app for interactive debugging
macrodeck-pluginto pack, validate or conformance-test a sample - not needed fordotnet buildordotnet test
dotnet tool install --global MacroDeck.Plugin.Cli --version 3.0.0-preview.6
Keep the CLI on the same release as the SDK packages the samples resolve; CI derives the version from
MacroDeckSdkVersion so the two cannot drift apart there.
Quick start
dotnet build
dotnet test
Run and debug against Macro Deck
Every source project contains one .NET launch profile named Macro Deck - Real Host. It starts the
plugin project directly, connects to http://127.0.0.1:8193 in self-registering mode and stores its
local development credential under that project's .macrodeck-dev-state/ directory. Because the IDE
launches the project itself, breakpoints work without attaching to a child process.
For the first run:
- Start the Macro Deck desktop app.
- Open Developer Tools → Plugin tokens, create a token and copy it. It is shown only once.
- Store the token in the selected source project's .NET User Secrets using one of the methods
below. The projects are already initialized; do not run
dotnet user-secrets init. - Select Macro Deck - Real Host for that source project and start it with Debug.
- Once enrollment succeeds, remove the token from User Secrets. Later launches reuse the credential
in
.macrodeck-dev-state/.
Set the token in Rider or Visual Studio
In Rider, right-click the source project in the Solution Explorer and select
Tools → .NET User Secrets. In Visual Studio, right-click the source project and select
Manage User Secrets. Do this on MacroDeck.SampleWeatherPlugin, for example, not its .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, set project to the sample you want to enroll. This form
reads the token without echoing it and does not put the value in shell history or process arguments:
project="src/MacroDeck.SampleWeatherPlugin/MacroDeck.SampleWeatherPlugin.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.SampleWeatherPlugin/MacroDeck.SampleWeatherPlugin.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
Repeat the setup for each sample you want to launch; every source project has its own User Secrets
store and plugin identity. User Secrets are local-only but not encrypted. Never put an enrollment
token in launchSettings.json, a shared IDE configuration, a literal command argument or a commit.
See the official Rider User Secrets guide
and .NET Secret Manager guide
for more background.
The checked-in launch profile is the only supported interactive start path in this repository. The CLI remains useful for the non-interactive conformance check below.
Testing
Every sample has a test project next to it, built on
MacroDeck.Plugin.Testing -
the same package a plugin author is expected to test their own plugin with. The three subjects it
offers are used where each belongs, so the suite doubles as a worked example of how to test a plugin:
| Subject | Used for | Read |
|---|---|---|
PluginTestHarness | Behaviour: what an action does, what a variable reads, what an issue reports. | *IntegrationTests.cs in every test project |
MacroDeckTestHost.HostAsync | The wire: serialization, published events, operations refused across the boundary. | *OverTheWireTests.cs |
MacroDeckTestHost.LaunchAsync | The built artifact: a real process that starts, registers and shuts down. | WeatherProcessTests.cs |
The REST API sample's tests plug a deterministic fake server in as the typed client's primary handler
(FakeTaskBoardApi.cs), so the plugin's own request building and JSON parsing still run for real.
The conformance suite checks a plugin against the protocol contract itself, and every sample passes it:
macrodeck-plugin test --project src/MacroDeck.SampleWeatherPlugin
Localization
Every string a user reads comes from Localization/Strings.resx rather than from a literal:
action names and descriptions, parameter labels and placeholders, event metadata, config-flow text,
integration issues and the failure messages an action reports. MacroDeck.Plugin.Analyzers generates
a typed Strings class from the resource set, Program.cs registers the generated catalog with
UseLocalization, and the host resolves each reference in the reader's own language.
public LocalizedText Name => Strings.Actions.RefreshWeather.Name();
Three rules the samples follow, and the reason for each:
- Generic wording comes from
MacroDeckStrings, not from a plugin's own catalog.MacroDeckStrings.Validation.Required(label)is already translated everywhere else in the app; a duplicateRequiredkey would be one more string every translator has to keep in sync. - Content stays a literal. A track title, a folder name, a card's title on a remote board and a device's own name are already in their final form - there is nothing to translate about them, and wrapping them in a reference would only make the wire noisier.
- A few contracts still take a plain string, and the samples say so where they do:
ConfigFlowResult.Complete's entry title (the user renames it afterwards),UserNotificationRequest,VirtualProfileDescriptor.Nameand an action interaction'sprompt.
The weather sample ships a second language (Localization/Strings.de.resx) so the fallback chain and
the manifest's derived languages field are visible somewhere in this repository; the other three
ship English only, which is the minimum a plugin needs. You never hand-write languages - build
and pack derive it from the resource set.
Each test project has a LocalizationTests.cs guarding the wiring rather than the wording: the
catalog is scoped to the plugin id, English is the default culture, every declared key resolves to
text, and every culture carries every key the default language declares.
Packaging for all three platforms
Each sample carries a macrodeck-build.json beside its manifest naming one self-contained
dotnet publish per runtime identifier, exactly as macrodeck-plugin new writes it. One command
builds every declared platform and packs the result:
macrodeck-plugin build --output ./artifacts
Add --rid win-x64 to build a single platform - that is what CI does, one job per sample per
platform. Self-contained publishes cross-compile, so a Linux runner (or a Mac) produces the Windows
and macOS artifacts too.
Manifest entrypoints therefore name what the publish actually produces - runtimes/win-x64/<project>.exe
on Windows and runtimes/<rid>/<project> elsewhere - rather than a bare file name. Nothing here is
framework-dependent, so no entrypoint declares a runtime block and none of them ends in .dll.
The sample manifests are publication-ready rather than schema-minimal: alongside the fields the host
needs to run a plugin they declare publisher, license, homepage, repository, compatibility
and the permissions each sample actually uses. CI gates on that:
macrodeck-plugin validate --artifact ./artifacts/<id>-<version>.macroDeckPlugin --level Publication
At Publication level a missing publication field is an error rather than a warning, and the same
check reports a declared entrypoint that is not present in the packed payload - which is what stops a
manifest and its build output from drifting apart again.
Building against a local SDK build
The samples track the SDK's published packages and float to the newest one, so a plain
dotnet build always resolves the latest release. While a change is still unreleased - a new API, a
fix you want to try - 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.
Further reading
- Plugin development docs - the whole set
- Hosting - the builder API, registration modes and every
MACRO_DECK_PLUGIN_*variable - SDK reference - every interface and record you build against
- Localization - the resource set, the generated API and the MDLOC diagnostics
- Manifest reference - every field, and whether it is required to run or to publish
- CLI -
new,build,pack,validate,testand their exit codes - Testing - the test harness, the fakes and the manual clock
- Capability parity - where a plugin differs from an in-process integration, and why
- Conformance - the conformance suite and its check ids
- Publishing - what a plugin needs before it can be published
License
MIT - see LICENSE. Macro Deck itself is licensed under Apache 2.0.