Variables
An integration exposes variables by implementing IVariableProvider. For most plugins that means two
members: a list of VariableDefinitions and a ReadAsync that returns the current value of one of them.
Quick start
Section titled “Quick start”using MacroDeck.Sdk;using MacroDeck.Sdk.Variables;
public sealed class MusicPlayerIntegration : IPluginIntegration, IVariableProvider{ private readonly PlaybackEngine _engine = new();
public IReadOnlyList<VariableDefinition> Variables { get; } = [ VariableDefinition.Eager("music_track", VariableType.Text) with { Id = "track" }, VariableDefinition.Eager("music_is_playing", VariableType.Boolean) with { Id = "is-playing" }, VariableDefinition.Eager("music_position", VariableType.Numeric, refreshInterval: TimeSpan.FromSeconds(1)) with { Id = "position", Unit = "s", SemanticKind = VariableSemanticKinds.Duration } ];
public ValueTask<VariableReading> ReadAsync(string localId, CancellationToken cancellationToken = default) => ValueTask.FromResult(localId switch { "track" => VariableReading.Of(_engine.CurrentTrack.Title), "is-playing" => VariableReading.Of(_engine.IsPlaying), "position" => VariableReading.Of(_engine.PositionSeconds), _ => VariableReading.Unavailable });
// IPluginIntegration members omitted.}The user now has {{ vars.music_track }}, {{ vars.music_is_playing }} and {{ vars.music_position }}.
The host calls ReadAsync for each variable on its own refresh interval - you never push eager values.
Three things to know:
Nameis what the user types,music_track. Lowercase,[a-z0-9_], and prefix it with your plugin so it does not collide with another one.Idis whatReadAsyncreceives and what ends up in saved profiles. Leave it out and the host derives one from the name - but setting it explicitly keeps yourswitchreadable and lets you rename the variable later without breaking anyone’s configuration.- A value is a
string, a number or abool. Anything else, andVariableReading.Unavailable, is shown as “not available” - use that for “no value right now” (not connected, not configured) rather than returning""or0.
Defining a variable
Section titled “Defining a variable”VariableDefinition.Eager(name, type, decimalPlaces, refreshInterval) covers the common case; add
anything else with with { ... }.
| Property | What it does | Example |
|---|---|---|
Name |
Variable name in templates. | "weather_temperature" |
Id |
Local id passed to ReadAsync / SetValueAsync. Stable once shipped. |
"temperature" |
Type |
Text, Numeric, Boolean or Color - see Color variables. |
VariableType.Numeric |
DisplayName, Description |
Localized text shown in the variable picker. | Strings.Variables.Temperature() |
Unit |
Symbol shown next to the value, reachable as vars.x.unit. |
"°C", "%", "GB" |
SemanticKind |
How the host formats it - see below. | VariableSemanticKinds.Percentage |
DecimalPlaces |
Digits shown for a numeric value. | 1 |
RefreshInterval |
How often the host calls ReadAsync. Host default when null. |
TimeSpan.FromSeconds(5) |
Write |
Makes the variable writable - see Writable variables. | new VariableWriteCapability() |
Attributes |
Free-form strings, readable as vars.x.<key>. Not interpreted by the host. |
new Dictionary<string, string> { ["room"] = "office" } |
Configuration |
Groups the variable under one configured instance (two OBS connections). | new VariableConfiguration(entryId, "Studio PC") |
IsShareable |
false keeps the variable on this Macro Deck: the user cannot share it with other Macro Decks, and one shared earlier is no longer served. Defaults to true. |
false for a variable that mirrors another Macro Deck |
SemanticKind tells the host how to render a number; Unit is what it shows next to it:
SemanticKind |
Unit |
stored | rendered |
|---|---|---|---|
duration |
s |
187 |
03:07 |
percentage |
% |
12.5 |
12.5 % |
bytes |
B |
1536 |
1.5 KB |
bytesPerSecond |
B/s |
3670016 |
3.5 MB/s |
none |
fps |
60 |
60 fps |
An unknown kind renders as a plain number with its unit, so naming a newer one is never an error. A host
built before bytesPerSecond existed shows such a value as 3670016 B/s, which is why the unit is still
declared. Use
bytes only for a value that really is in bytes - a value already in GB is none with a GB unit.
A provider may declare at most VariableLimits.MaxEagerVariablesPerProvider (256) eager variables; the
host keeps the first 256 and logs an error. More than that belongs in the catalog.
Color variables
Section titled “Color variables”VariableType.Color holds a color. Its value is text, lowercase #rrggbb when opaque or #rrggbbaa with
alpha: return it from ReadAsync as a string, and expect one in SetValueAsync. The host also accepts
#rgb, #rgba, rgb(...) and rgba(...) from you and stores the canonical form; anything else is
invalid. Over the protocol the value travels as a text value, like any string.
Users can bind a widget’s colors, folder and profile backgrounds, the accent color and action color parameters to a Color variable. See Colors from a variable for what they see.
A Macro Deck release from before Color drops a definition that declares it, with the rest of your
variables unaffected.
A script input can be a color too, ScriptInputType.Color with a value in the same format. A plugin built
on this SDK announces that it knows the type and sees such an input as color; an older plugin sees it as
text carrying the hex value.
Resolving colours yourself
Section titled “Resolving colours yourself”Two places hand you colours already resolved: a Color action parameter when the
action runs, and the widget data Macro Deck gives your widget’s session. Anywhere else a colour may be a
reference string such as {{ vars.primary | color | color_darken: 20 }}: your own settings, a config or
setup flow, or a view of your own with
AllowVariables. Resolve those through
IIntegrationContext.Colors, an IColorApi:
var color = await context.Colors.ResolveAsync(settings.LightColor) ?? "#ffffff";
_colorWatch = await context.Colors.WatchAsync(settings.LightColor, async (color, ct) => await _lights.SetColorAsync(color ?? "#ffffff", ct));
// later, to stop:await _colorWatch.DisposeAsync();- Both take a fixed colour or a reference and return lowercase
#rrggbbor#rrggbbaa, ornullfor “no colour, use your default”: the variable is missing, unavailable or not aColor. Pass awidgetIdto let that widget’s own variables shadow global ones; an unknown widget givesnull. ColorReference.IsReference(value)tells a reference from a fixed colour without asking the host.- A watch’s callback receives the current colour once, then again only when it changes, including to
null. Watches survive reconnects and are released when the integration is shut down or initialized again; dispose one to stop it earlier. Callbacks run on thread-pool threads. - A plugin may hold at most
ProtocolLimits.MaxColorWatches(1024) watches across its integrations;WatchAsyncthrowsInvalidOperationExceptionabove that. - A reference is at most
ProtocolLimits.MaxColorReferenceLength(1024) characters with at mostMaxColorReferenceSteps(32) modifiers. A longer value is not a reference. - On a Macro Deck release without colour resolution, a fixed colour still resolves locally, a reference
gives
null, and a watch delivers its value once.
In tests, PluginTestHarness’s Context.Colors is a FakeColorApi: seed a value with Set(value, color),
which also notifies matching watches, and check ActiveWatchCount. Over the wire, MacroDeckTestHost.Colors
does the same with SetAsync, and WatchesOf(pluginId) lists a plugin’s watches. Unseeded, a fixed colour
resolves to its canonical form and a reference to null.
await harness.Context.Colors.Set("{{ vars.primary | color }}", "#3366ff");Writable variables
Section titled “Writable variables”A writable variable is how a Slider widget gets two-way binding: the slider writes through
SetValueAsync and reads the real value back through ReadAsync. Declare Write and implement
SetValueAsync:
public IReadOnlyList<VariableDefinition> Variables { get; } =[ VariableDefinition.Eager("music_volume", VariableType.Numeric) with { Id = "volume", Unit = "%", SemanticKind = VariableSemanticKinds.Percentage, Write = new VariableWriteCapability() }];
public ValueTask<VariableReading> ReadAsync(string localId, CancellationToken cancellationToken = default) => ValueTask.FromResult(localId switch { // min, max and step give a bound Slider its range. "volume" => VariableReading.Of(_engine.VolumePercent, 0, 100, 1), _ => VariableReading.Unavailable });
public ValueTask<VariableWriteResult> SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default){ if (value is not (double or int or long)) { return ValueTask.FromResult(VariableWriteResult.InvalidValue()); }
_engine.SetVolume((int)Convert.ToDouble(value, CultureInfo.InvariantCulture)); // clamps to 0-100 return ValueTask.FromResult(VariableWriteResult.Applied());}- The host only calls
SetValueAsyncfor a variable that declaresWrite; every other write is refused before it reaches you, so there is no need to checklocalIdagainst a read-only list. Appliedmeans you applied it. The host does not echo the requested value - the nextReadAsyncis the truth, so clamping or rounding needs no extra work.- The other results are
NotWritable,NotFound,Unavailable(can write, just not right now - e.g. disconnected),InvalidValueandFailed. Never declareWriteand then answerNotWritable: that is a slider that silently does nothing, and conformance check MDC0314 fails it. - Set
Write = new VariableWriteCapability { CommitOnRelease = true }when every intermediate value of a drag would be disruptive (seeking a track). Leave it off for volume, where live feedback is the point.
Min, Max and Step come from the reading rather than the definition because they can change - a
seek bar’s maximum is the current track’s length.
Step is the slider’s default grid, not a guarantee: a user can give a bound slider a custom step, so
SetValueAsync may receive a value that is not a multiple of your step. It is always within Min and
Max. Round or reject it the way your target needs.
Variables that depend on configuration
Section titled “Variables that depend on configuration”Variables may change with configuration. After the configuration changed, tell the host to read it
again with context.Variables.NotifyDeclarationsChangedAsync(). For a plugin this sends the same
state.update for the variables kind that CatalogChanged(CapabilityKinds.Variables) on an injected
IPluginCatalogNotifier sends, so it works with every Macro Deck version - the
weather sample does this after its config flow. It is about the
declared eager set only; a catalog announces a changed set of resources with InvalidateCatalogAsync
(see Push instead of poll).
Reading again now
Section titled “Reading again now”Macro Deck reads each eager variable at its RefreshInterval. When something changed what ReadAsync
would answer - a connection came up, a device reported new state - ask for every declared variable to be
read on the next poll instead:
await context.Variables.RequestRefreshAsync(cancellationToken);Requests made while one is still on its way are merged into one, so calling it from every callback of an event stream is fine. It is best effort: it never throws because the request could not be delivered, and a Macro Deck that predates it treats it as a no-op, so the variables are still read at their interval. Pushed catalog resources are unaffected; publish those through the sink.
When a change affects only a few variables, name them by definition id so the rest keep their cadence:
await context.Variables.RequestDefinitionRefreshAsync(["volume-percent", "muted"], cancellationToken);The ids can also name catalog resources a user bound, so a catalog provider that polls (SupportsPush is
false) can turn an event from its service into an immediate read of only the affected resources:
// The service reported that the microphone input was muted.await context.Variables.RequestDefinitionRefreshAsync([$"{connectionId}/input/mic/muted"], cancellationToken);Ids that match neither a declared variable nor a bound catalog resource are ignored, and an empty list asks for
nothing. Reading more than asked for is always allowed: a pending request for every declared variable covers
pending requests for declared ones, while named catalog resources are still sent next to it. An IVariableApi
that does not override the member, or a Macro Deck that predates it, reads every declared variable instead. An
older Macro Deck ignores catalog resource ids, so those resources keep their refresh interval there. Pushed
catalog resources are unaffected; publish those through the sink.
A canonical variable name matches ^[a-z][a-z0-9_]*$. VariableNames.IsValid checks one, and
VariableNames.Sanitize derives one from any text the way Macro Deck does: vars. is dropped, accents are
removed, letters are lowercased, every other character becomes _, runs of _ collapse, and a leading digit
gets a v_ prefix. The same text always gives the same name, so a name derived from a stable label stays
stable:
var name = VariableNames.Sanitize($"{machineName} fps"); // "gaming_pc_fps"DeclaredVariables is what the host shows for an integration that is not configured yet. It defaults to
Variables; override it only when Variables is empty until something is configured, and set
VariablesDependOnConfiguration when its names contain a VariableNameTemplate placeholder for a
per-instance segment.
History of your own variables
Section titled “History of your own variables”await using var window = await context.Variables.OpenHistoryAsync("weather_berlin_temperature", 40, cancellationToken);window.Changed += (_, _) => Redraw(window.Values);OpenHistoryAsync opens a rolling window on one of your own global numeric variables, for example to draw a
graph in a widget. Macro Deck samples the variable once per second while a window on it is open, so the
values are evenly spaced and the graph advances whether the value changes or not. Values holds at most the
requested number of samples, oldest first, and is empty until the variable first reads as a number. The
window may be opened before the variable exists. Dispose it when the graph is no longer shown.
A name that belongs to someone else throws a VariableHistoryException with NotOwned, and too many open
windows LimitExceeded. A Macro Deck that predates variable history, or an IVariableApi that does not
implement it, throws Unsupported; catch it and draw without a graph.
Testing
Section titled “Testing”PluginTestHarness reads and writes variables the way the host does:
await using var harness = /* your harness setup */;await harness.InitializeIntegrationsAsync();
var track = (await harness.Variables.GetAsync("track")).DataAs<VariableReadingDto>();Assert.That(track!.Value.Text, Is.EqualTo("Intro"));
var written = (await harness.Variables.SetAsync("volume", new VariableValueDto { Kind = "number", Number = 35 })).DataAs<VariableSetResult>();Assert.That(written!.Status, Is.EqualTo("Applied"));GetAsync and SetAsync take the local id, not the name. The
sample plugins test every variable they declare this way.
FakeIntegrationContext.Variables counts RequestRefreshAsync and NotifyDeclarationsChangedAsync calls in
RefreshRequests and DeclarationChanges, lists the ids of each RequestDefinitionRefreshAsync call in
DefinitionRefreshRequests, and keeps what BindAsync recorded in Bindings. RecordSample adds a sample to every history window open on a
name, and OpenHistoryWindows counts the windows that are not disposed yet.
Templates
Section titled “Templates”A variable is {{ vars.<name> }} in any template; its static attributes are suffixes on the same
reference: {{ vars.cpu.unit }}, {{ vars.room_sensor.room }}.
vars.<name>.state is computed by the host and tells an unavailable variable apart from an empty one:
| member | true when |
|---|---|
state.is_available |
the reference resolved to a value |
state.is_not_available |
it did not - unknown name, or a provider that went quiet |
state.is_empty |
it resolved and renders as zero characters |
state.is_not_empty |
it resolved and renders as at least one character |
{% if vars.music_artist.state.is_not_empty %}By {{ vars.music_artist }}{% endif %}Color filters derive a shade from a color. The value piped in must be a Color variable or a color
literal; a Text variable is refused even when it holds a hex value. color reads the value as it is, and
the result of every filter is #rrggbb or #rrggbbaa:
{{ vars.primary | color | color_darken: 20 | color_opacity: 70 }}{{ "#ff0000" | color_darken: 10 }}| filter | argument |
|---|---|
color_lighten, color_darken |
percent of lightness to add or remove |
color_saturate, color_desaturate |
percent of saturation to add or remove |
color_opacity |
the alpha to set, in percent |
color_increase_opacity, color_reduce_opacity |
percent to move the alpha toward opaque, or to reduce it by |
color_hue |
degrees to rotate the hue |
color_mix |
another color ("#ffffff" or a Color variable such as vars.other) and the percent of it to blend in |
A missing, unavailable or invalid color renders as an empty string, which a color field treats as unset.
Because state is resolved first, an Attributes key named state is unreachable. state works on
vars references only, not on event parameters or script inputs.
The variable catalog
Section titled “The variable catalog”Use the catalog when your variables are a runtime resource space too large to declare up front - Home
Assistant entities, OBS sources, MQTT topics. Nothing is registered until the user picks a resource in
the variable browser; only then does it become an ordinary {{ vars.name }} variable. A provider can
have eager variables and a catalog at the same time.
public sealed class Foobar2000Integration : IPluginIntegration, IVariableProvider{ private readonly Foobar2000Client _client;
public IReadOnlyList<VariableDefinition> Variables { get; } = [];
public bool SupportsCatalog => true;
public bool SupportsSearch => true;
public string CatalogName => "foobar2000";
public async ValueTask<VariableCatalogPage> DiscoverAsync( VariableCatalogQuery query, CancellationToken cancellationToken = default) { if (!_client.IsConnected) { return VariableCatalogPage.Empty; }
// The client's cursor is handed straight through as the continuation token. var page = await _client.GetCustomTagsAsync(query.Search, query.PageSize, query.ContinuationToken, cancellationToken);
return new VariableCatalogPage { Items = page.Tags .Select(tag => VariableDefinition.OnDemand(tag.Name, VariableType.Text) with { Name = $"foobar_{tag.Name}", }) .ToList(), ContinuationToken = page.NextCursor, }; }
// A tag typed by hand or read from an old profile is still valid - resolve it. public ValueTask<VariableDefinition?> ResolveAsync( string localId, CancellationToken cancellationToken = default) => ValueTask.FromResult(Foobar2000Tags.IsValidName(localId) ? VariableDefinition.OnDemand(localId, VariableType.Text) : null);
public async ValueTask<VariableReading> ReadAsync( string localId, CancellationToken cancellationToken = default) => _client.IsConnected ? VariableReading.Of(await _client.GetCustomTagValueAsync(localId, cancellationToken)) : VariableReading.Unavailable;}Catalog rules
Section titled “Catalog rules”- Ids. A catalog id may contain anything except
::, whitespace and control characters, up toMacroDeckId.MaxResourceLocalIdLength-sensor.office_temperatureand GUIDs are fine. Encode names with spaces (Main Camera→Main_Camera) and decode them inResolveAsync; an item with an invalidIdis dropped. Ids are local: the host adds and strips your integration’s prefix. - Paging. Return one page per
DiscoverAsynccall, at mostMaxVariableCatalogPageSizeitems, and put your source’s own cursor inContinuationToken. It is opaque and never persisted. - Hierarchy.
query.ParentIdisnullfor the roots, otherwise the node being opened. SetIsContaineron nodes with children andIsBindable = falseon pure grouping nodes. A flat provider ignoresParentId. - Search. Leave
SupportsSearchoff unless you honorquery.Search; the host then shows no search box rather than filtering a single page. CatalogEntryCount. Return a total only when it is cheap;nullotherwise. Without a total the host shows how many entries it has loaded so far, or no count at all whenSupportsSearchis on, because a searchable catalog is only loaded as the user expands or searches it.ResolveAsyncreturnsnullonly for an invalid id. A resource that is merely gone right now (an unplugged device, a disconnected integration) must still resolve: the binding then shows as unavailable and resumes on its own, whilenullmakes it a broken reference the user has to fix. A plugin that is offline appears as unresolvable until it reconnects, whatever your code returns.
Push instead of poll
Section titled “Push instead of poll”By default the host polls every bound resource with ReadAsync. A provider backed by an event stream
sets SupportsPush => true and publishes instead:
OnAttachedAsync(sink)hands you anIVariableSinkonce (only when bothSupportsCatalogandSupportsPusharetrue).SubscribeAsync(localIds)is called with the complete set of bound ids every time it changes (an empty set means “watch nothing”). Return the current values you already have, or an empty list.- Call
sink.PublishAsyncwith values for ids in the latest set; others are dropped. CallInvalidateCatalogAsyncwhen the set of resources itself changed.
Push applies to the catalog only - eager variables are always polled.
Resource lifetime
Section titled “Resource lifetime”A resource that disappears keeps its binding and variable; it just reads as unavailable and resumes without re-binding once it can be resolved and read again.
Recording bindings yourself
Section titled “Recording bindings yourself”The user binds catalog resources in the variables view. An integration can record bindings itself, for example to carry variables over from an older version that created them another way:
var outcomes = await context.Variables.BindAsync([ new VariableBindingRequest("entity/light.kitchen/state", "ha_light_kitchen", VariableType.Text) { MigratedFrom = "my-plugin/watched-entities-v1" }], cancellationToken);- The bindings always belong to the calling integration, and are recorded together or not at all.
- A resource the integration already has a binding for answers
AlreadyBoundand keeps its binding, even when the user renamed its variable since. Running the same migration twice is therefore safe. - Every name must satisfy
VariableNames.IsValidand every resource id must be a valid resource id; otherwiseArgumentExceptionis thrown and nothing is recorded. A plugin sends at most 512 per call. - Recording does not create the variables right away. Macro Deck creates them when it restores bindings, at the latest on its next start; a requested name another variable holds by then is replaced with one derived from it, as for any binding.
InvalidOperationExceptionmeans the bindings could not be stored. The default implementation, and a Macro Deck that predates the member, throwNotSupportedException. Keep the data you migrate from until a call succeeds, so the next start can try again.
Over the plugin protocol
Section titled “Over the plugin protocol”Each eager variable is declared as one capability, like an action. Catalog ids are never declared -
they travel in the operation arguments, which keeps a large catalog under MaxDeclaredCapabilities. A
provider with only a catalog or only on-demand variables therefore declares nothing; Macro Deck asks it for
describe when it connects and offers its variables from then on, so it needs no announcement. See
the WebSocket reference for the
describe/get/set/discover/resolve/subscribe operations, the variables/refresh host operation
behind RequestRefreshAsync and RequestDefinitionRefreshAsync (with an optional definitionIds list), the
variables/bind operation behind BindAsync, the variables/history.open, history.read and history.close operations
behind OpenHistoryAsync (a plugin’s window reads its samples once per second and ends with the
connection), and the variable-values host API.
Declare host:variable-values in manifest.json when you use it.
MacroDeckTestHost drives all six operations; see conformance for MDC0311, MDC0314,
MDC0315 and MDC0403, which also check a plugin whose variables come only from a catalog or on demand.
Writing a user variable
Section titled “Writing a user variable”To write a variable the user owns instead of declaring your own, use the user-variable API on
IIntegrationContext: CreateAsync creates one, ApplyAsync changes an existing one. Pass an owner
widget id (from ActionExecutionContext.OwnerWidgetId) to make it local to that widget, where it shadows
a global of the same name; the host refuses an unknown widget id.
ApplyAsync’s Set also works on provider variables that declare Write; NotEditable for the rest.
Add, Toggle and Append are user-variable only, because they compute from the last value the host
saw. A Color user variable takes Set only, with a value in the formats above. Unavailable means the owner accepts writes but could not take this one - retry later.
A user variable can read its value from a file. Without Allow write-back it is read-only: every
operation answers NotEditable and the file is left alone. With write-back it behaves like any other
user variable, and Macro Deck also writes the new value to the file. This needs no new SDK: a plugin built
against an older one gets the same NotEditable it already handles for read-only variables. A user
variable that renders a template is read-only the same way: every operation answers NotEditable, and
its value changes only when a variable its template reads changes. CreateAsync always creates a
variable that holds its own value.
Scopes
Section titled “Scopes”A user variable is global, or local to one widget, one script or one automation. A local variable shadows a variable of the same name further out. A running flow resolves a name in this order:
| The flow runs in | Order |
|---|---|
| A widget | The widget’s locals, then globals |
| A script | Its inputs, then the script’s locals, then, for a script that runs on a widget, that widget’s locals, then globals |
| An automation | The automation’s locals, then globals |
A script started by Run Script resolves only along its own chain, never the caller’s. Script inputs are read-only: an operation on one is refused.
From an action, write in the context of the step that is running so script and automation locals are
found too. UserVariableContext.From copies the step’s OwnerWidgetId, ScriptId and AutomationId
(see What the executor gets). Here _userVariables is
the IIntegrationContext.UserVariables the integration kept from InitializeAsync:
var variables = UserVariableContext.From(context);var result = await _userVariables.ApplyInContextAsync( "count", variables, UserVariableOperation.Add, "1", context.CancellationToken);if (result.Status == UserVariableWriteStatus.NotFound){ await _userVariables.CreateInContextAsync( "count", variables, VariableType.Numeric, "1", cancellationToken: context.CancellationToken);}ApplyInContextAsyncresolves the name along the order above and otherwise behaves likeApplyAsync.CreateInContextAsynccreates the variable in the most local scope the context names: the script, else the automation, else the owner widget, else global. It refuses a script that does not exist withUnknownScriptand an automation that does not exist withUnknownAutomation.- Both are default interface members. An
IUserVariableApiimplementation that predates them, and a host that predates script and automation variables, use onlyOwnerWidgetId: the call behaves likeApplyAsyncorCreateAsyncwith the owner widget, so the plugin keeps working with widget and global variables.ApplyAsyncandCreateAsynckeep that behaviour on every host. - Over the protocol, the context travels as the optional
scriptIdandautomationIdfields of theuser-variablesapplyandcreatearguments, next toownerWidgetId. - In
MacroDeck.Plugin.Testing,ActionsTestClient.ExecuteWithContextAsyncruns a step inside a script or automation, andFakeUserVariableApiresolves script and automation locals and keeps the last context it was called with inLastContext.
Reading a variable as a flow sees it
Section titled “Reading a variable as a flow sees it”ReadInContextAsync answers what {{ vars.<name> }} resolves to for a step running in a context: a
script local, then an automation local, then a widget local, then a global. Integration and widget
variables are found by their name like user variables. Script inputs are not part of the lookup. Every
call is a live read; nothing is cached on the plugin’s side.
var read = await _userVariables.ReadInContextAsync( "volume", UserVariableContext.From(context), context.CancellationToken);if (read.Status == UserVariableReadStatus.Found){ // read.Value is a double for Numeric, a bool for Boolean and a string for Text and Color; // read.Text is the value as a template renders it, e.g. "0.50" with two decimal places.}UserVariableReadStatus |
Meaning |
|---|---|
Found |
Type, Value and Text are set. |
NotFound |
No variable of that name is visible in the context. |
InvalidName |
The name cannot be a variable name. |
NotSupported |
The host does not implement reads: an IUserVariableApi implementation or a Macro Deck release that predates the method. |
Unavailable |
The host could not be reached, or the variable exists but its owner has no value right now (a template shows it as unavailable). Type is set in the second case. Retry later. |
The method is a default interface member, so an implementation written against an older SDK keeps
compiling and answers NotSupported. Over the protocol it is the user-variables read operation with
the same name, ownerWidgetId, scriptId and automationId fields as apply; a host that predates it
answers CAPABILITY_UNSUPPORTED, which the SDK reports as NotSupported. FakeUserVariableApi reads
the variables seeded on it along the same order.