Config as data
Change a port in Console and either the whole change applies or it rolls back completely — zhin.config.yml on disk is never left half-written.
Remember these points
- Config is data, not code. Each plugin declares its options, defaults and constraints with
schema.json(orconfigindefinePlugin). - Validated as a whole at startup. Zhin combines every plugin's declaration and validates the full document strictly (Ajv strict); errors prevent startup or the change.
- Each plugin sees only its own section. Your bot reads
plugin:, child plugins read<instanceKey>:, and they can't see each other's. - Changes are transactions. When editing through Console or
zhin config, a new config that fails validation or activation rolls back automatically and the old one stays in effect. - Secrets go in
.env. Reference them in config as${VAR}.
Document Structure
configVersion: 2
# Root Plugin configuration (corresponding to the Root package's schema.json)
plugin:
terminal:
interactive: true
# Child plugin configuration, namespaced by instanceKey
plugins:
sandbox: "@zhin.js/adapter-sandbox"
napcat: "@zhin.js/adapter-napcat"
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;<instanceKey>-- each direct instance's business schema;pluginsonly maps aliases to packages;- 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; - Framework menu settings
menuKeyword/menuStyleremain at the top level and are projected only into the Root generation view for the Command Feature. Child plugins never receive them, and the Root business schema cannot redeclare them. - 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 optional commandNamespace and disabledCommands fields into every plugin config; plugin schemas must not redeclare them. Omitted values mean no plugin namespace and no disabled commands. 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):
configVersion: 2
plugins:
icqq: "@zhin.js/adapter-icqq"
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.