Config as Data
Change a port on the Console -- either the entire change takes effect, or it rolls back completely. The zhin.config.yml on disk will never be left in a half-written state. This is possible because zhin.js configuration is not code but a data document strictly constrained by schemas: each package declares its configuration contract via schema.json, the runtime validates the entire document against the effective schema composed from the plugin tree using Ajv strict mode, and then projects the configuration to each plugin by owner. Configuration changes go through transactions with no intermediate state.
Document Structure
# Root Plugin configuration (corresponding to the Root package's schema.json)
plugin:
terminal:
interactive: true
# Child plugin configuration, namespaced by instanceKey
plugins:
sandbox:
endpoints:
- id: full-bot-sandbox
owner: local-user
napcat:
connection: ws
endpoints:
- name: full-bot-napcat
url: ${ONEBOT11_WS_URL} # Environment variable interpolation
access_token: ${ONEBOT11_ACCESS_TOKEN}The effective schema is composed by ConfigComposer (packages/im/runtime/src/config-composer.ts) from the plugin tree:
plugin-- the Root Plugin's own schema;plugins.<instanceKey>-- each child plugin's schema, with nested child plugins recursively attached under the parent schema's properties;- Host-level keys
http/database/ai/mcp/a2a/speech/htmlRenderer/assistant/log_level-- consumed by the CLI's Root installer and not included in any plugin's configuration view; - Top-level structure uses
additionalProperties: false: misspelling a key name (e.g., typingplugninstead ofplugin) will produce an immediate error rather than being silently ignored.
schema.json: Declarative Contract
The schema.json in each package's root directory is its configuration contract. examples/minimal-bot/schema.json:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"commandPrefix": { "type": "string", "default": "/" },
"terminal": {
"type": "object",
"additionalProperties": false,
"default": {},
"properties": {
"interactive": { "type": "boolean", "default": true },
"prompt": { "type": "string", "default": "zhin> " }
}
}
}
}The root must be an object schema; packages without a schema.json are treated as an empty object. Purely compositional root schemas are rejected. The framework injects an optional commandNamespace field into every plugin config; plugin schemas must not redeclare it, and omitted values mean no plugin namespace. Validation uses Ajv 2020 with strict validation and defaults. A reserved-field collision or a child instanceKey collision throws ConfigSchemaCollisionError.
ConfigView: Projection by Owner
Plugins do not receive their entire subtree from the document (which also contains their descendants). ConfigComposer uses pickOwnFields to select top-level properties according to each package's own schema, freezes the result, and provides it as the plugin's ConfigView:
// PluginSetupContext.config
context.config.get(); // Contains only fields declared in this plugin's schemaEnvironment variable interpolation (${VAR}) is expanded during projection; unset variables expand to empty strings. Secrets should always use environment variables and never be written directly in YAML.
Adapter Configuration Model: Top-level Shared + endpoints[i]
Multi-account adapters (a single plugin instance connected to multiple platform connections) use the endpoints array. The expansion logic is implemented in AdapterIndex (packages/im/adapter/src/adapter-index.ts):
plugins:
icqq:
commandPrefix: "/" # Top-level: shared by all endpoints
endpoints:
- name: bot-a
uin: ${ICQQ_UIN_A}
- name: bot-b
uin: ${ICQQ_UIN_B}
commandPrefix: "" # Per-entry override of top-level- Each entry's effective configuration = instance configuration (without the
endpointskey) merged with the entry's own fields;nameis required. - After expansion, each endpoint is an independent record. The capability ID is in the form
<slot>~<name>, and Console and message chains address by name. - Entries missing
name, containing~or\0in the name, or having duplicate names are discarded with a warning; when all entries are invalid, it falls back to a single endpoint. - When
endpointsis empty or absent, a single endpoint is created from the instance configuration.
commandPrefix
The command prefix is resolved by default based on the adapter instance that owns the message (defaultCommandPrefixResolver):
- If the message carries
metadata.endpointand a matchingendpoints[i]exists in configuration, use that entry'scommandPrefix; - Otherwise, use the instance's top-level
commandPrefix; - If neither exists, use
''(no prefix -- any text is attempted as a command match).
Configuration Document Transactions and Rollback
Runtime configuration changes (Console UI, patchConfig API) do not modify the file directly. They use a two-phase transaction; the ConfigDocumentPort and structural patch semantics live in zero-dependency @zhin.js/plugin-runtime:
interface ConfigDocumentPort {
read(): Promise<ConfigDocumentSnapshot>; // Document + revision (content sha256)
prepare(current, patches): Promise<PreparedConfigDocument>; // Candidate document, lazy at this stage
}
interface PreparedConfigDocument {
commit(): Promise<ConfigDocumentSnapshot>;
rollback(): Promise<void>;
}ConfigFileDocument (@zhin.js/config-file) owns the transaction lifecycle shared by both formats:
- Optimistic concurrency: Both
prepareandcommitre-read the file and verify the revision; if the file was modified externally after reading, aConfigDocumentConflictErroris thrown. - Atomic write to disk:
commitfirst writes to a temporary file then usesrenameto replace, preserving original file permissions. - Consistency: If the candidate document diverges from the runtime-validated candidate, a
ConfigDocumentDivergenceErroris thrown -- preferring failure over writing divergent configuration. - Format polymorphism:
YamlConfigDocumentpatches the AST and preserves comments and indentation;JsonConfigDocumentreuses Runtime structural patch semantics and preserves indentation and line endings.
The composition root creates one concrete ConfigFileDocument. Root Runtime, Endpoint configuration commands, and Console all receive that instance instead of discovering, parsing, or overwriting the configuration file independently. Console source editing reads the original text, format, and revision through readSource(), then commits through prepareReplacement(); configuration keys in the response are projected from the same revision. Competing callers receive an explicit revision conflict instead of silently overwriting the earlier update.
Transactions are woven into generation handoff: RootRuntime.patchConfig first performs a shadow prepare (see Generation and Lifecycle), and the file commit happens after the new generation's resources are activated; if the handoff fails, the rollback order is reversed -- first restore the file, then deactivate the shadow generation. If any step fails, neither the Root config on disk nor the in-memory runtime will be left in a half-updated state.
Direct external editing of the configuration file is also supported: the config file itself is watched, and external modifications trigger a full reload using the disk content as the source of truth.