Skip to content

Settings & Configuration

This page is the API reference for the configuration model: the types the host hands to the runtime, and the typed reader services use to consume those values. The two halves pair tightly. Settings flow in from the host as RuntimeSettings, the registry routes the relevant slice to each service, and the service reads its slice through ConfigNode.

For the conceptual treatment of ConfigNode, see Configuration. For the workflow of applying settings live, see Settings & Overrides.

host app
│ RuntimeSettings (plugins map + services map)
PluginRuntime
│ registry materializes overrides, picks the winner per slot
ServiceRegistry.resolve<T>(serviceId)
│ injectSettings(map) on the resolved instance
PluginService.config (ConfigNode)

The host owns a RuntimeSettings (empty, hand-built, loaded from JSON, or produced by Plugin Kit Dialog). The registry combines it with the priority chain to produce the effective configuration map for each resolved service. The service reads through config, a ConfigNode over the injected map. Defaults live in the service, not in the settings model.

Top-level container for plugin enablement and service overrides. Immutable, JSON-round-trippable, value-equal.

final settings = RuntimeSettings(
plugins: {
const PluginId('sql_language'): const PluginConfig(enabled: true),
const PluginId('experimental_router'): const PluginConfig(enabled: false),
},
services: {
const PluginId('linter_suite').service('line_length_linter'):
const ServiceSettings(config: {'max_line_length': 120}),
PluginId.wildcard.service('agent_service'): const ServiceSettings(
priority: 200,
config: {'provider': 'openai'},
),
},
);
FieldTypeNotes
pluginsMap<PluginId, PluginConfig>Keyed by PluginId, not raw String. Wrap literals as const PluginId('foo').
servicesMap<Pin, ServiceSettings>Keyed by Pin, an extension type wrapping the canonical pluginId:serviceId (or *:serviceId) wire string. See the key formats below.

Both key types are typed handles, not raw Strings. A Map<String, PluginConfig> or Map<String, ServiceSettings> literal will not type-check against the corresponding field.

The default constructor is const; RuntimeSettings.fromJson(...) is a non-const factory. The default constructor’s parameters default to const {}. RuntimeSettings() is the canonical “no overrides” starting point; prefer it over passing two empty maps explicitly.

const emptySettings = RuntimeSettings();
bool isPluginEnabled(PluginId pluginId);
bool isServiceEnabled(Pin scopedKey);
Map<String, dynamic> getPluginConfig(PluginId pluginId);
Map<String, dynamic> getServiceConfig(Pin scopedKey);
MethodReturnsWhen the entry is missing
isPluginEnabledbooltrue (plugins default to enabled)
isServiceEnabledbooltrue (services default to enabled)
getPluginConfigMap<String, dynamic>const {}
getServiceConfigMap<String, dynamic>const {}

isPluginEnabled reads only the explicit PluginConfig.enabled value. It does not consult feature flags or experimental defaults. For the base runtime enablement decision (locked plugins, explicit config, experimental fallback), use PluginRuntime.isPluginEnabled. For the post-cascade runtime truth, use PluginRuntime.isPluginAttached. See Plugins & Lifecycle.

RuntimeSettings copyWith({
Map<PluginId, PluginConfig>? plugins,
Map<Pin, ServiceSettings>? services,
}) {

Returns a copy with the given replacements. When a parameter is omitted, the result holds a shallow clone of the existing map (a fresh Map instance with the same entries), not the original reference, so mutating the copy’s maps does not affect the original.

RuntimeSettings.services keys are Pin values. Two forms cover every override:

PinTargets
Pin('pluginId', ['service', 'segments'])A specific plugin’s registration for that slot.
Pin.wildcard(['service', 'segments'])Whichever registration currently wins that slot.

The wire format (used by Pin.wire for JSON serialization) is pluginId:serviceId or *:serviceId. Both forms accept namespaced service ids (agent.temperature, mcp.tool_provider); the segments list is joined with dots into the wire serviceId.

In Dart code, prefer the typed-chain helpers when you have the underlying PluginId / Namespace / ServiceId handles in scope:

final settingsWithPriority = RuntimeSettings(
plugins: {const PluginId('formal'): const PluginConfig(enabled: false)},
services: {
Pin('chat', ['agent', 'model']): const ServiceSettings(
config: {'temperature': 0.7},
),
Pin.wildcard(['agent', 'tools']): const ServiceSettings(priority: 200),
},
);

The wildcard form is winner-scoped, not layered. The runtime resolves the current winner first, then materializes the wildcard override onto that winner. For the full reasoning and how resolveAfter interacts with wildcards, see Settings & Overrides.

When you receive an override key as a wire string (e.g., from JSON your own code is parsing), build a Pin via Pin.fromWire:

/// Demonstrates Pin.fromWire parsing a wire-format key.
void demonstratePinFromWire() {
const pin = Pin.fromWire('main_agent:agent.temperature');
// pin.pluginId == PluginId('main_agent')
// pin.serviceId == ServiceId('agent.temperature')
// pin.wire == 'main_agent:agent.temperature'
print('${pin.pluginId} ${pin.serviceId} ${pin.wire}');
}

Pin.fromWire is a const constructor that accepts any string and stores it verbatim. Validation happens lazily on access: reading pin.pluginId or pin.serviceId throws FormatException if the wire string has no : separator (or its plugin half is empty). The error message names the offending key, which makes JSON-fed typos easy to spot.

Plugin-level enablement plus an arbitrary plugin-wide map.

const pluginConfig = PluginConfig(
enabled: true,
config: {'api_key': 'sk-demo'},
);
FieldTypeDefaultNotes
enabledbooltrueWhen false, Plugin.register is skipped and the plugin’s services do not enter that scope’s registry (global or session).
configMap<String, dynamic>const {}Plugin-wide values. Persisted in the settings model but not auto-injected into the plugin instance.

The asymmetry with ServiceSettings.config is deliberate. Service configs flow through the registry into PluginService.injectSettings. Plugin-level configs live alongside the model for the host’s benefit (a place to stash API keys or global toggles spanning several services), but reaching them is the host’s job. Read PluginConfig.config from the application layer, or push the value down onto a service the plugin registers and let the registry inject it.

copyWith({bool? enabled, Map<String, dynamic>? config}) follows standard semantics: omitted parameters fall back to the current value.

Per-slot configuration. Three knobs.

class ServiceSettings {
final bool enabled;
final Map<String, dynamic> config;
final int? priority;
const ServiceSettings({
this.enabled = true,
this.config = const {},
this.priority,
});
}
FieldTypeDefaultNotes
enabledbooltrueWhen false, the registry emits a disable override for the slot. Resolution falls through to the next-highest-priority enabled registration.
configMap<String, dynamic>const {}Injected into PluginService.injectSettings on resolve. The service reads it via config (a ConfigNode).
priorityint?nullOptional override. Replaces the priority used at registration. null means “leave the registration default in place”.

Higher priority wins resolution; the default registration priority is Priority.normal (500). A priority override applies to the targeted registration, not the slot, so two competing plugins on the same slot can each carry independent overrides.

copyWith({bool? enabled, Map<String, dynamic>? config, int? priority}) follows standard semantics.

All three settings types ship toJson() returning Map<String, dynamic> and a matching fromJson(Map<String, dynamic>) factory. Round-tripping returns an equal value:

/// Demonstrates the JSON round-trip assertion.
void demonstrateJsonRoundtrip() {
const settings = RuntimeSettings(
plugins: {
PluginId('main_agent'): PluginConfig(enabled: true),
PluginId('experimental_router'): PluginConfig(enabled: false),
},
);
final restored = RuntimeSettings.fromJson(settings.toJson());
assert(settings == restored);
}

The wire format mirrors the field shape:

{
"plugins": {
"main_agent": {"enabled": true},
"experimental_router": {"enabled": false}
},
"services": {
"main_agent:agent.model": {
"config": {"provider": "anthropic", "model": "claude-sonnet-4-5-20250929"}
},
"*:agent.temperature": {
"config": {"value": 0.7},
"priority": 200
}
}
}

A few details:

  • RuntimeSettings.fromJson rewraps each plugin key as PluginId(k). The JSON side stays plain strings; the in-memory side stays typed.
  • ServiceSettings.toJson omits priority when it is null.
  • ServiceSettings.fromJson reads priority through (json['priority'] as num?)?.toInt(), so 200 and 200.0 both round-trip safely.
  • Missing enabled keys default to true in both PluginConfig.fromJson and ServiceSettings.fromJson. Older payloads that omit the field still parse as enabled.
  • Missing config maps default to const {}.

All three types use deep collection equality for == and hashCode.

Typed read accessor over an injected settings map. The single const constructor takes a Map<String, dynamic>, but you usually do not construct one yourself: the registry passes a fresh ConfigNode to every resolved service through injectSettings, which assigns it to PluginService.config. Direct construction is fair game in tests and in code that handles settings payloads outside the runtime.

MethodReturnsBehavior
get<T>(key)T?Exact runtime-type match. No coercion.
getString(key)String?Equivalent to get<String>(key).
getInt(key)int?Accepts int, any num (via toInt(), truncating doubles), and String (via int.tryParse).
getDouble(key)double?Accepts double, any num (via toDouble()), and String (via double.tryParse).
getBool(key)bool?Accepts bool, the strings 'true' / 'false' (case-insensitive), and num where 0 is false and any other value is true.
list<T>(key)List<T>?Returns value.cast<T>() when the value is a List and the cast succeeds. Returns null on cast failure or non-list value.
map(key)Map<String, dynamic>?Returns value.cast<String, dynamic>() when the value is a map and the cast succeeds. Returns null on cast failure or non-map value.
raw(key)dynamicUntyped passthrough. The value as stored.
has(key)booltrue only when the key exists and the value is non-null.

There is no separate getMap accessor, and no path-style traversal. The accessor is intentionally flat. To walk a nested map, read it with map(key) and index into the result.

/// Demonstrates reading a nested map with map() then indexing.
void demonstrateNestedMapAccess() {
const node = ConfigNode({
'headers': {'timeout_ms': 3000},
});
final headers = node.map('headers') ?? const {};
final timeoutMs = headers['timeout_ms'] as int?;
print(timeoutMs);
}

ConfigNode does not carry default values. The convention is to apply the default at the call site through ??:

/// Demonstrates applying defaults via ?? at the call site.
void demonstrateConfigNodeDefaults() {
const node = ConfigNode({
'temperature': 0.4,
'tools': ['search'],
});
final temperature = node.getDouble('temperature') ?? 0.7;
final tools = node.list<String>('tools') ?? const [];
final provider = node.getString('provider') ?? 'openai';
print('$temperature $tools $provider');
}

map(key) follows the same null-on-miss contract as the other typed accessors. If you want “missing map means empty map,” apply the fallback explicitly at the call site:

final headers = config.map('headers') ?? const <String, dynamic>{};

The coercion in getInt, getDouble, and getBool is forgiving at the edge so service code stays narrow when payloads arrive from JSON, env vars, or form submissions. When your code controls the shape of the value, pass the native type and stay out of the coercion path. A few sharp edges:

  • getInt truncates real-valued doubles. getInt('x') on 1.9 returns 1.
  • getDouble does not consume int literals via is double; it falls through to the is num branch and calls toDouble(). The result is the same double, but the path through the function differs.
  • getBool matches 'true' / 'false' case-insensitively but accepts no other string values. 'yes', '1', and 'on' all return null.
Iterable<String> get keys;
bool get isEmpty;
bool get isNotEmpty;

keys returns the underlying map’s keys in insertion order. isEmpty and isNotEmpty mirror the map’s own properties. Useful in tests, custom field renderers, and code forwarding a config slice elsewhere.

There are no setters. The class wraps a Map<String, dynamic> and only exposes read accessors. The registry replaces the entire config field on the service when settings change; it does not mutate the existing node.

To react to changed settings, override PluginService.onSettingsInjected(), or Plugin.onPluginSettingsChanged(...) for plugin-wide reactions. Treating config as a snapshot for the current resolution is safe; holding a stale reference after a settings change returns old data but never panics.

static String hashSettings(Map<String, dynamic> settings);

Stable hex-encoded hash over a settings map, computed via deep collection equality. The registry uses it to skip redundant injectSettings calls on singleton and lazy-singleton services when the new map is structurally equal to the previous one. Exposed so host code caching its own settings-keyed work can use the same hash the registry does.