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.
Where it comes from
Section titled “Where it comes from”The flow is always the same:
- Settings enter the system through
RuntimeSettings. - The
ServiceRegistryresolves aPluginService. - The registry injects the service’s effective settings.
- 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;}Core getters
Section titled “Core getters”ConfigNode is intentionally small.
| Method | Behavior |
|---|---|
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 |
Coercion rules
Section titled “Coercion rules”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.
A realistic example
Section titled “A realistic example”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.
Lists and nested maps
Section titled “Lists and nested maps”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.
Raw access
Section titled “Raw access”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.
ConfigNode is read-only, on purpose
Section titled “ConfigNode is read-only, on purpose”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.
What ConfigNode is not
Section titled “What ConfigNode is not”| Not | Not |
|---|---|
| a schema validator | a required-field enforcer |
| a deep object mapper | a 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.