Skip to content

Events

An integration tells Macro Deck “this just happened” with events. IEventProvider declares which events exist; IEventPublisher, handed to you on the integration context, publishes an occurrence. Users react to them with triggers.

using MacroDeck.Sdk;
using MacroDeck.Sdk.Actions;
using MacroDeck.Sdk.Events;
public sealed class StreamStudioIntegration : IPluginIntegration, IEventProvider
{
private StudioClient? _client;
public IReadOnlyList<EventDefinition> EventDefinitions { get; } =
[
new()
{
Id = "scene-changed",
Name = Strings.Events.SceneChanged(),
Category = Strings.Events.ScenesCategory(),
ConfigurationParameters =
[
ActionParameter.DynamicChoice("sceneId",
label: Strings.Parameters.Scene(),
placeholder: Strings.Parameters.AnyScene())
],
PayloadParameters =
[
ActionParameter.DynamicChoice("sceneId", label: Strings.Parameters.SceneId()),
ActionParameter.Text("sceneName", label: Strings.Parameters.Scene())
]
}
];
public Task InitializeAsync(IIntegrationContext context)
{
var events = context.Events; // safe to keep for the process lifetime
_client = new StudioClient();
_client.SceneChanged += scene => events.Publish("scene-changed", new Dictionary<string, object?>
{
["sceneId"] = scene.Id,
["sceneName"] = scene.Name
});
return Task.CompletedTask;
}
// Remaining IPluginIntegration members omitted.
}

The user now finds “Scene changed” in the trigger editor, can narrow it to one scene, and can use sceneId and sceneName in the flow it runs.

Things to know:

  • Id is persisted in every trigger that uses the event. Treat it as a public identity: never rename it once shipped. It must be unique within your integration; the host namespaces it as integrationId::eventId.
  • Publish is fire-and-forget. It never throws into the caller and has no reply, so it is safe to call from a socket callback or polling loop. An occurrence nobody subscribed to is simply dropped.
  • Payload keys must match the declaration. Publish the names you listed in PayloadParameters - those are what the user can pick and filter on.
Property What it does Example
Id Provider-local id, stable across releases. "scene-changed"
Name, Description Localized text in the event picker. Strings.Events.SceneChanged()
Category Groups events in the picker. Optional. Strings.Events.ScenesCategory()
IconName An icon the UI already ships. No image data. "movie"
ConfigurationParameters What the user authors on the trigger - see below. ActionParameter.DynamicChoice("sceneId", ...)
PayloadParameters What an occurrence carries. Never rendered as an input. ActionParameter.Text("sceneName", ...)
DeliveryKind Push (default): you publish. Scheduled: the host produces occurrences from the configuration. EventDeliveryKind.Push

ProviderName on IEventProvider is optional; leave it empty and the event picker shows your integration’s name (for a plugin, the manifest.json name).

EventDefinitions is read whenever the host builds the event catalogue, so it may change after a reconfigure. An out-of-process plugin must tell the host to re-read it:

using MacroDeck.Plugin.Hosting.Integrations.HostApis; // IPluginCatalogNotifier, injected
using MacroDeck.Plugin.Protocol.Handshake; // CapabilityKinds
catalogNotifier.CatalogChanged(CapabilityKinds.Events);

Configuration parameters vs payload parameters

Section titled “Configuration parameters vs payload parameters”
ConfigurationParameters =
[
ActionParameter.DynamicChoice("trackId", label: Strings.Parameters.Track(), placeholder: Strings.Parameters.AnyTrack())
],
PayloadParameters =
[
ActionParameter.DynamicChoice("trackId", label: Strings.Parameters.TrackId()),
ActionParameter.Text("trackName", label: Strings.Parameters.Track()),
ActionParameter.Toggle("muted", label: Strings.Parameters.Muted())
]

Both lists use the ordinary ActionParameter schema, so any control the action builder renders is available.

  • Configuration parameters are what the user fills in on the trigger. When one shares its name with a payload parameter, the host compares the two and only fires the trigger on a match. Left empty, it matches any occurrence - “any track” above.
  • Payload parameters describe what an occurrence carries. They populate the picker that inserts { "$event": "trackName" } references into the triggered flow and label values in the live preview. When a user writes a condition against $event, the comparison value is authored with the control the payload parameter’s type implies, while the condition still stores the raw value the occurrence carries. So declare an enumerable payload value the same way as its matching filter (a DynamicChoice here): the user picks a track name instead of pasting an id.
_events.Publish("hotkey-pressed", new Dictionary<string, object?>
{
["key"] = "F3", // string
["repeat"] = 2, // number
["held"] = false, // boolean
["combo"] = new { modifiers = new[] { "Ctrl", "Shift" }, key = "F3" } // object
});
Published value What triggers, templates and conditions see
string, number, boolean the value itself
object or array its compact JSON text: {"modifiers":["Ctrl","Shift"],"key":"F3"}

A condition on an object or array value compares that text. Publishing without parameters is fine for events that carry nothing:

_events.Publish("connected");

A hotkey plugin that wants to swallow a bound combo, or an integration that only subscribes upstream to what the user actually uses, can read the triggers bound to its own events instead of asking the user to enter the same values twice:

public Task InitializeAsync(IIntegrationContext context)
{
_events = context.Events;
_events.BindingsChanged -= ApplyBoundCombos;
_events.BindingsChanged += ApplyBoundCombos;
ApplyBoundCombos();
return Task.CompletedTask;
}
private void ApplyBoundCombos()
{
var combos = _events.GetBindings()
.Where(binding => binding.EventId == "hotkey-pressed")
.Select(binding => binding.Parameters.GetValueOrDefault("combo"))
.Where(value => value is { Operator: "==", Value.ValueKind: JsonValueKind.Object })
.Select(value => value!.Value!.Value)
.ToList();
_hook.Swallow(combos);
}

InitializeAsync runs again after a reconnect or a config change, on the same publisher, so remove the handler before adding it or it fires once per initialization. Remove it in ShutdownAsync as well, so a stopped integration stops reacting.

Each EventBinding is one widget flow or one enabled automation triggered by one of your events. You see only your own events, never which widget or automation holds the trigger.

Member Meaning
EventId Provider-local event id, without the integrationId:: prefix.
Parameters The configuration parameters the user set, by name. A parameter left empty is absent.
EventBindingValue.Value The value as the editor stored it: a scalar, an object for a KeyboardCombo, or a variable reference the host resolves only when it matches an occurrence. null for the state operators.
EventBindingValue.Operator ==, !=, >, <, >=, <=, or one of isEmpty, isNotEmpty, isAvailable, isNotAvailable.

A trigger can also carry a filter the host evaluates on its own, so a value in GetBindings() does not guarantee the trigger fires for it.

BindingsChanged fires on a thread-pool thread after the list changed for your integration, and only then: moving a widget or editing another plugin’s trigger does not raise it. Out of process, GetBindings() serves the host’s last event-bindings push, so it is empty until the first push arrives. Against a host that predates this API it stays empty and BindingsChanged never fires, so treat an empty list as “nothing bound”, never as an error.

public sealed class StreamStudioIntegration : IPluginIntegration, IEventProvider, IDynamicEventOptionsProvider
{
public Task<DynamicOptionsResult> GetEventOptionsAsync(EventOptionsContext context,
CancellationToken cancellationToken)
{
var session = _client?.Session ?? StudioSession.Empty;
IReadOnlyList<ActionParameterOption> options = context.ParameterName switch
{
"sceneId" => session.Scenes.Select(s => new ActionParameterOption { Value = s.Id, Label = s.Name }).ToList(),
_ => []
};
return Task.FromResult(new DynamicOptionsResult
{
Options = options,
AllowsCustomValue = true,
CacheSeconds = 30
});
}
}

Implement IDynamicEventOptionsProvider when a choice is only known at edit time - a scene list, a device, a channel. It answers configuration and payload parameters alike and never changes the event definition itself. EventOptionsContext carries:

Member Meaning
EventId Provider-local event id, without the integrationId:: prefix.
ParameterName The parameter being edited.
Filter Text the user typed, for a filterable autocomplete.
CurrentParameters The other configuration values, so options can depend on an earlier choice.

The context names only the event and the parameter, so a request for the configuration parameter sceneId looks exactly like one for the payload parameter sceneId. Declare the same name in both lists only where the same options answer for both - as in every example on this page.

var context = new FakeIntegrationContext();
await integration.InitializeAsync(context);
studio.RaiseSceneChanged(new Scene("s1", "Intro"));
var published = context.Events.Published.Single();
Assert.That(published.EventId, Is.EqualTo("scene-changed"));
Assert.That(published.Parameters!.Value.GetProperty("sceneName").GetString(), Is.EqualTo("Intro"));

FakeEventPublisher serializes parameters exactly as the wire protocol does, and like the real one it never throws. SetBindings(...) replaces what GetBindings() returns and raises BindingsChanged, standing in for a user who binds or edits a trigger. PluginTestHarness.Events calls describe and options over the protocol. See testing.

Events are one provider-shaped capability per plugin: describe returns the merged catalogue of every IEventProvider in the process, options routes to IDynamicEventOptionsProvider. The catalogue is snapshot-backed. Occurrences travel as the fire-and-forget event.publish message; the host qualifies the id with the authenticated plugin id. What the user bound arrives as the push-only host.state api event-bindings, scoped to the plugin’s own events and sent on registration and whenever that list changes. See capability parity.