Skip to content

Configuration

Configuration gets annoying fast.

A feature starts with two booleans. Then it wants a model id. Then a nested map. Then half the values arrive from JSON, env vars, or a form submission, and suddenly three services are all hand-parsing the same Map<String, dynamic> slightly differently.

ConfigNode exists to stop that drift. It is the typed settings wrapper exposed on every PluginService. It takes a Map<String, dynamic> and gives you predictable read helpers with light coercion. The goal is convenience, not a full schema engine.

You do not usually construct ConfigNode yourself. The registry injects one into your service during resolution, and your service reads from it.

That sounds small, but it is the difference between a runtime settings screen being fun to build and being a swamp of Map<String, dynamic> casts. The host can stay dynamic. Services still get typed reads.

This page is about the service side of configuration: “I have settings, how do I read them safely?” The runtime side, including plugin enablement, priority overrides, and live reconciliation, lives in Settings & Overrides.

The flow is always the same:

  1. Settings enter the system through RuntimeSettings.
  2. The ServiceRegistry resolves a PluginService.
  3. The registry injects the service’s effective settings.
  4. The service reads those settings through config.
class AnthropicService extends PluginService {
/// The API key read from injected settings.
String get apiKey => config.getString('api_key') ?? '';
/// The temperature value read from injected settings.
double get temperature => config.getDouble('temperature') ?? 0.7;
}

ConfigNode is intentionally small.

MethodBehavior
get<T>(key)exact typed lookup, no coercion
getString(key)string lookup
getInt(key)int lookup with numeric and string coercion
getDouble(key)double lookup with numeric and string coercion
getBool(key)bool lookup with string and numeric coercion
list<T>(key)typed list lookup
map(key)Map<String, dynamic> lookup, or null when absent or invalid
has(key)true when the key exists and is non-null
raw(key)untyped raw value

This is where ConfigNode is deliberately pragmatic.

getInt accepts int, num (via toInt()), and string values parseable as ints.

getDouble accepts double, num (via toDouble()), and string values parseable as doubles.

getBool accepts bool, the strings 'true'/'false', and numeric values where 0 is false and anything else is true.

The point is not magic. The point is to be forgiving enough for real-world settings payloads (which often arrive as JSON strings, environment variables, or form submissions) without making the API vague.

class ModelRouter extends PluginService {
/// The default model name read from injected settings.
String get defaultModel => config.getString('default_model') ?? 'gpt-4.1';
/// The temperature value read from injected settings.
double get temperature => config.getDouble('temperature') ?? 0.7;
}

With settings arriving as:

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'},
),
},
);

The service reads every field through the typed getters with no manual parsing.

When you have a real double or bool in code, pass it as double or bool. The coercion rules above are the forgiving fallback for values you cannot control, not a style to reach for on purpose. If temperature or streaming arrived as '0.4' / 'true' (out of a JSON payload, env var, or form submission), getDouble and getBool would still handle it, but that is the rescue path.

For structured settings, use list<T>(...) and map(...).

/// Demonstrates list and map() on a ConfigNode.
void demonstrateConfigNodeListMap() {
const node = ConfigNode({
'tools': ['hammer', 'wrench'],
'headers': {'timeout_ms': 5000},
});
final tools = node.list<String>('tools') ?? const [];
final headers = node.map('headers') ?? const {};
final timeoutMs = headers['timeout_ms'] as int?;
print('$tools $timeoutMs');
}

map(...) returns null when the value is missing or cannot be cast. Use ?? with a fallback when you need a default map.

If you need the source value exactly as stored, use raw(...).

const node = ConfigNode({
'advanced_payload': {'nested': true},
});
final payload = node.raw('advanced_payload');
print(payload);

Useful when the shape is highly dynamic, when you want custom decoding, or when the typed helpers are too narrow.

Treat config as the current effective settings snapshot for that service. Do not mutate it.

If your service needs to react to changed settings:

  • Override onSettingsInjected() in the service for service-local reactions.
  • Or use Plugin.onPluginSettingsChanged(...) at the plugin level for broader reactions.

ConfigNode is the read model handed to the service by the registry. It is a window, not a container you fill in.

NotNot
a schema validatora required-field enforcer
a deep object mappera defaults manager (beyond what you write in getters)

That is all intentional. The runtime handles delivery. Your service handles meaning. If you need metadata a host app can read without instantiating the service (a settings schema, a supported-formats list, a “this is slow” tag), attach a custom Capability at registration time.