Plugin Model
Connecting two QQ accounts to the same Bot, or splitting a game plugin out for independent publishing -- in zhin.js, both are solved with the same mechanism: the plugin tree. A plugin is an ordinary npm package; the only difference is an extra zhin field in package.json. The application declares direct instances in the main configuration plugins map. Package manifests retain static contracts such as Features and entry points. Like LEGO bricks: each one is independently formed and snaps into a designated position by instanceKey.
Core rules
| Rule | Example |
|---|---|
Plugin = npm package + a zhin field in package.json + a plugin.ts entry | Your project itself is the Root plugin |
| Main configuration maps instance aliases to package references | plugins: { qq: "@zhin.js/adapter-qq" } |
The instanceKey decides which config section the child reads | qq: at the top level of zhin.config.yml |
| One package can be mounted several times, each configured separately | Two instanceKeys mount two copies of the same game plugin |
Any plugin with a zhin.config.yml can start as a standalone application | Develop and debug one plugin on its own |
| A Feature is a special package that scans a convention directory; regular plugins don't write one | @zhin.js/command scans commands/ |
The zhin Field in package.json
Using examples/minimal-bot/package.json as an example:
{
"name": "minimal-bot",
"type": "module",
"dependencies": {
"zhin.js": "workspace:*"
},
"zhin": {
"protocol": 1,
"type": "plugin",
"entry": "./plugin.ts",
"engine": "^1.0.0",
"runtime": "trusted",
"features": [],
"plugins": []
}
}The fields are strictly validated by @zhin.js/runtime's manifest parser (packages/im/runtime/src/manifest.ts). Any invalid field throws a ManifestValidationError listing all issues. After parsing, the package-contract module enforces the current engine and Feature API semver contracts and throws PackageContractError on a contract violation.
| Field | Type | Description |
|---|---|---|
protocol | Must be 1 | Manifest protocol version |
type | "plugin" or "feature" | See "Plugin vs. Feature" below |
entry | String | Plugin entry point, must be a package-relative path starting with ./, .. escaping the package root is not allowed |
engine | String, optional | Engine version constraint, e.g., "^1.0.0" |
runtime | "trusted" or "isolated", optional | Whether to load this plugin in an isolated runtime |
platformFeatures | Boolean, optional | Default true; whether the Root inherits platform Stable Features (see "platformFeatures Inheritance" below) |
features | Array | Feature capability packages this package depends on: { "package": "...", "api": "^1.0.0", "optional": false } |
metadata | Object, optional | Static displayName and icon; the same field cannot also be declared in code |
plugins | Legacy array | Must be empty or absent in an application Root; use the v2 main configuration |
Runtime only creates application instances from configVersion: 2. Nonempty Root manifest mounts require explicit migration; historical dependency mounts do not create hidden instances. See the migration guide.
Packages with type: "feature" have fewer fields: protocol / type / entry / engine / featureApi. For example, @zhin.js/adapter:
{
"zhin": {
"protocol": 1,
"type": "feature",
"entry": "./lib/provider.js",
"engine": "^1.0.0",
"featureApi": "1.0.0"
}
}Example projects are private workspaces, so they can use plugin.ts as the entry point directly and benefit from source-level HMR. Publishable npm plugins must declare ./plugin.js and include that file along with the JavaScript build output from convention directories in the published package; the Runtime will not execute TypeScript entry points as published artifacts within node_modules. Official plugin builds are handled by scripts/build-plugin-runtime-entries.mjs.
Plugin vs. Feature
- A Plugin is an assembly unit: it declares which Features it uses; the application configures instances. Its entry point default-exports
definePlugin({...})(import fromzhin.js). - A Feature is a capability type: it defines "what a class of capabilities looks like" (contract + discovery convention + projection).
@zhin.js/adapterdefines the Adapter capability,@zhin.js/commanddefines the command capability, and so on.
After a plugin declares a dependency on a capability type via the features array, it can provide capability implementations following that Feature's conventions. For example, the sandbox adapter plugin (@zhin.js/adapter-sandbox) declares the @zhin.js/adapter capability and places a convention-based entry in the adapters/ directory:
// plugins/adapters/sandbox/adapters/sandbox/index.ts
import { defineAdapter } from 'zhin.js/adapter';
import { outboundMessageToken } from '@zhin.js/core/runtime';
import { SandboxWsEndpoint } from '../src/endpoint.js';
export default defineAdapter({
capabilities: ['inbound', 'outbound'],
create(context) {
return new SandboxWsEndpoint({
gateway: context.use(outboundMessageToken),
defaults: resolveSandboxEndpoint(context.config),
// ...
});
},
});instanceKey: A Plugin's Name in the Tree
The same plugin package can be mounted multiple times (e.g., two QQ accounts using two instances of the same adapter package), distinguished by instanceKey. The naming rule is /^[a-z0-9][a-z0-9-]*$/ (lowercase alphanumeric plus hyphens). It determines two things: the PluginId -- a child sandbox of root has the ID root/sandbox, with further nesting appended sequentially; and the configuration namespace -- the instance's configuration is written under the top-level <instanceKey> block in zhin.config.yml (see Config as Data).
configVersion: 2
plugins:
sandbox: '@zhin.js/adapter-sandbox'
icqq: '@zhin.js/adapter-icqq'
disable_plugins:
- icqq
sandbox: {}Duplicate or reserved aliases are rejected. Disabled instances retain their configuration without loading their entry point.
Two Sources for Child Plugins
The main configuration plugins.<instanceKey> supports two reference formats:
- npm package name:
"@zhin.js/adapter-icqq". The package must be a dependency of the current package. - Local relative path:
"./plugins/my-local-plugin". Starts with./and points to a directory within the package (a common pattern for private plugins in monorepos)...to escape the package root is not allowed.
features[i].package similarly supports both sources.
Plugin as Root: Every Brick Can Run Independently
"Root Plugin" is not a special type -- any type: "plugin" package can be started independently as Root. In the package directory, run:
zhin runtime startThe CLI treats the current directory as the project root, scans its manifest tree, and starts up. This means: when developing a game plugin, you can start it directly in the plugin directory for testing, without first installing it into a Bot project. examples/minimal-bot is the minimal complete form of a Root Plugin.
platformFeatures: Inherited Platform Features of Root
@zhin.js/core declares five Stable Features in its own manifest:
"features": [
{ "package": "@zhin.js/adapter", "api": "^1.0.0" },
{ "package": "@zhin.js/command", "api": "^1.0.0" },
{ "package": "@zhin.js/component", "api": "^1.0.0" },
{ "package": "@zhin.js/middleware", "api": "^1.0.0" },
{ "package": "@zhin.js/handler", "api": "^1.0.0" }
]When the Root Plugin depends on @zhin.js/core (or depends on the zhin.js facade, which in turn depends on core), even if its own features array is empty, it automatically inherits these Features -- adapter, command, component, middleware, and handler are available out of the box. Merge rule: user-explicit declarations of the same package take priority (can be used to pin versions or override), and the rest are auto-filled.
Setting "platformFeatures": false in the Root's manifest disables this inheritance (e.g., for creating a Root that intentionally has no command system).
Setup Context
The PluginSetupContext received by the plugin entry definePlugin({ setup(context) {...} }):
| Field | Description |
|---|---|
plugin | Instance view: id, instanceKey, parent, root, role (root/child) |
config | ConfigView, get() returns configuration projected by this plugin's schema (see Config as Data) |
resources | This plugin's scoped resource Scope |
lifecycle | DisposeStack: registered resources are automatically deregistered when the generation ends (see Generation and Lifecycle) |
handoff | Entry point for participating in generation handoff |
addFeature | Generic entry for declaring Capabilities within setup; corresponding Feature packages extend this with addCommand / addComponent / addMiddleware / addHandler / addAdapter / addAgent / addSkill / addTool / addMcp |
setup can return a Dispose synchronously or asynchronously, or attach cleanup functions directly to lifecycle. Registration in setup and discovery from convention directories feed into the same Feature projection; the former is suited for single-file Bots, while the latter is suited for scaled organization and file-level granular HMR.