definePlugin
Declaring a plugin requires no base class inheritance and no manual registration in any registry -- default-exporting the return value of definePlugin(...) (from @zhin.js/plugin-runtime) is all you need. This return value is called a PluginDefinition: a name, metadata, dependency declarations, plus a setup(context) assembly function.
import { definePlugin } from '@zhin.js/plugin-runtime';
export default definePlugin<MyConfig>({
name: 'my-plugin',
metadata: { displayName: 'My Plugin', icon: 'Blocks', order: 10 },
requires: [databaseHostToken],
async setup(context) {
// Assembly: register runtime resources beyond commands (tables, cron, Agent tools, outbound push...)
return () => { /* Runs when the generation ends */ };
},
});A few hard constraints to remember first. name is required and must match ^[a-z][a-z0-9-]*$, otherwise definePlugin throws a TypeError directly; the returned definition is Object.freezed and cannot be modified. setup can be sync or async, and may optionally return a Dispose that runs when the current generation ends.
Starting from scratch: for the shortest example see single-file-bot; for the convention directories tutorial see Writing Your First Plugin; for concepts see Plugin Model.
setup context
The PluginSetupContext<TConfig> received by setup has all read-only members:
| Member | Type | Purpose |
|---|---|---|
plugin | PluginInstanceView | Instance view: id / instanceKey / parent / root / role ('root' | 'child'). Isolated by instanceKey in multi-instance deployments |
config | ConfigView<TConfig> | Configuration view, config.get() returns a read-only config. Defaults come from the plugin package's schema.json, overridden by the plugin: section (for Root itself) or plugins.<key> section of zhin.config.yml |
resources | Scope | Resource scope: has(token) / use(token) resolve Host tokens, provide(token, value, dispose?) publishes resources to child scopes |
lifecycle | DisposeStack | Generation's cleanup stack: cleanup functions registered via lifecycle.add(dispose) run in reverse order when the generation ends |
handoff | GenerationHandoffRegistry | Generation handoff registry: handoff.add(participant) participates in hot reload transactions (see "Generation Handoff" below) |
addFeature(feature, name, definition) | General Feature registration entry point | Registers an in-memory definition as the current plugin's Capability; still goes through provider validation, conflict detection, and generation transaction |
After importing a Stable Feature's authoring package, the context gains corresponding shortcut methods through type augmentation: addCommand, addComponent, addMiddleware, addAdapter. Optional AI capabilities also provide addAgent, addSkill, addTool, addMcp. These are just strongly-typed narrowings of addFeature, they do not create a second registry.
import { defineCommand } from '@zhin.js/command';
import { defineComponent } from '@zhin.js/component';
import { definePlugin } from '@zhin.js/plugin-runtime';
export default definePlugin({
name: 'single-file-bot',
setup({ addCommand, addComponent }) {
addCommand('hello', defineCommand({
description: 'Say hello',
execute: () => 'Hello.',
}));
addComponent('status', defineComponent({
render: () => 'online',
}));
},
});The shortcut registration and the directory discovery of commands/hello.ts, components/status.tsx ultimately produce the same CapabilitySlot. Having both with the same name will report Duplicate Capability Slot during the prepare phase; failing to mount the corresponding Feature will also refuse startup. Modifying the single-file entry rebuilds that plugin's Scope; splitting into convention directories enables single-capability file-level HMR.
Custom Features can declare their shortcut method name (must be in addXxx form) via the provider's authoring.setupMethod; the Runtime dynamically installs it. The TypeScript type is provided by that Feature's module augmentation of PluginSetupContext. The Runtime does not maintain a Feature name whitelist.
A real assembly example makes this clear (examples/capabilities-bot/plugin.ts, excerpt):
async setup(context) {
const { instanceKey } = context.plugin; // 1. Instance view
const config = context.config.get(); // 2. Configuration view
if (context.resources.has(databaseHostToken)) { // 3. Resource scope
const db = context.resources.use(databaseHostToken);
db.define('showcase_counter', { /* ... */ });
}
context.lifecycle.add(schedule.register({ /* ... */ })); // 4. Lifecycle cleanup
return () => console.log('disposed'); // 5. setup returns Dispose
}Scope Resolution Rules
resources is a parent-child chain: use(token) first checks the local scope, then recurses up to parent scopes if not found; if the entire chain has no match, it throws a Missing resource error. Optional capabilities should always use has(token) before use(token), degrading gracefully when absent.
metadata and requires
All three fields of metadata serve the Remote Console's plugin card (/api/plugins): displayName is the display name, icon is the icon name, and order is the sort weight. None affect runtime behavior.
requires declares an array of hard-dependency Host tokens; missing tokens cause the plugin to refuse startup -- complementing the has() + graceful degradation soft-dependency path:
// Hard dependency: won't start without a database
export default definePlugin({
name: 'my-plugin',
requires: [databaseHostToken],
// ...
});Host token
Host tokens are capability handles provided by the Host to plugins, resolved in setup via context.resources. The CLI Host automatically assembles them at startup; tokens not assembled are checked with has() for graceful degradation. The first six are exported from @zhin.js/plugin-runtime, and httpHostToken is exported from @zhin.js/host-http.
| token | token id | Availability condition | Key methods |
|---|---|---|---|
databaseHostToken | zhin.database.host | database: configured | define(name, columns) registers a table; models.get(name) gets a model (select / insert / update / delete / count); start() is called by the Host at generation activation, plugins should not call it themselves |
scheduleHostToken | zhin.schedule.host | Always available | register(job) registers a 6-field solar cron (second minute hour day month weekday), returns a cancel function; list() lists tasks |
outboundHostToken | zhin.outbound.host | Has available Adapter | send(input) proactive push (returns platform message id or null); optional addReaction / removeReaction / recall |
agentToolsHostToken | zhin.agent-tools.host | AI installed and enabled (Agent Host) | register(tool) registers an Agent tool, returns an unregister function |
htmlRendererToken | zhin.html-renderer.host | @zhin.js/html-renderer installed | render(html, { width, format, backgroundColor }) -> PNG (Buffer) or SVG (string); must degrade to plain text when not installed |
runtimeEventPublisherToken | zhin.runtime.event-publisher | Root-level, CLI console assembly | publish(type, data) broadcasts events to the Console SSE hub (used by adapters to push endpoint:request / endpoint:notice etc.) |
httpHostToken | zhin.host.http | HTTP Host enabled | route(method, path, handler, meta?) registers an HTTP route; ws(path).onConnection(cb) registers a WS endpoint; listen() / close() managed by Host |
Note the common pattern in these two typical usages: first guard with has(), then hook the returned unregister function into lifecycle -- automatically cleaned up during hot reload.
// Scheduled task: dispose hooked to lifecycle, safely cleaned up on hot reload
if (config.heartbeatCron && context.resources.has(scheduleHostToken)) {
const schedule = context.resources.use(scheduleHostToken);
context.lifecycle.add(schedule.register({
id: 'capabilities-bot/heartbeat',
cron: config.heartbeatCron,
description: 'Showcase heartbeat',
execute: () => log('heartbeat ♥'),
}));
}
// Agent tool: only exists when Agent Host is installed, silently skipped otherwise
if (context.resources.has(agentToolsHostToken)) {
const agentTools = context.resources.use(agentToolsHostToken);
context.lifecycle.add(agentTools.register({
name: 'showcase_greet',
description: 'Return the configured greeting for a name',
source: 'capabilities-bot',
inputSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
},
execute: (input) => `${config.greeting},${String(input.name ?? 'world')}!`,
}));
}Common fields of AgentToolRegistration: name (tool name visible to the model), description, inputSchema (zod or JSON Schema), platforms / scopes / permissions (restrict visibility by message dimensions), hidden (only callable by name, not listed in the tool catalog), approval ('never' | 'always', combined with execution policy), source (origin label).
Generation Handoff
Hot reload is a "generation" transaction: after the new generation finishes assembly, the old generation quiesces, the new generation activates, and on failure it rolls back. context.handoff.add(participant) registers a participant with the following hooks:
| Hook | Timing |
|---|---|
quiescePrevious(previous) | Old generation quiesces (e.g., stop accepting new events) |
activateNext() | New generation activates (e.g., start endpoints, first action after starting cron) |
deactivateNext() | Roll back new generation on activation failure |
resumePrevious() | Restore old generation after rollback |
openNext() | Open admission after transaction commits (e.g., start receiving messages) |
capabilities-bot uses this to solve startup timing races -- pushing an online message only after the endpoint is ready:
context.handoff.add({
activateNext: async () => {
await outbound.send({ ...target, content: `${config.greeting},capabilities-bot 已上线` });
},
});Real-World Examples
- capabilities-bot: A single
setup()inplugin.tsexercises all common Host facets (database / schedule / agent-tools / outbound / handoff), each withhas()graceful degradation, and is the source of all code snippets in this document. - lottery (
plugins/utils/lottery/plugin.ts): A production-grade plugin -- database-first viadatabaseHostToken, falling back to in-memory implementation when absent;provides its own token for command reuse; Agent tools useawait import()for lazy loading, ensuring IM-only installations don't pull in@zhin.js/agent; daily pipeline via cron.
The assembly skeleton of lottery is worth copying:
// plugins/utils/lottery/plugin.ts (excerpt)
async setup(context) {
const config = resolveLotteryConfig(context.config.get());
const db = context.resources.has(databaseHostToken)
? context.resources.use(databaseHostToken)
: createInMemoryLotteryDb();
if (context.resources.has(databaseHostToken)) {
defineLotteryTables(db);
}
context.resources.provide(lotteryRuntimeToken, { db });
// Lazy-load Agent tools: IM-only installs don't include @zhin.js/agent
if (context.resources.has(agentToolsHostToken)) {
const agentTools = context.resources.use(agentToolsHostToken);
const { registerLotteryAgentTools } = await import('./agent/runtime-tools.js');
context.lifecycle.add(registerLotteryAgentTools(agentTools));
}
// ...cron registration, see scheduleHostToken section
}package.json zhin Field
Plugin packages (and feature packages) declare their manifest using the top-level zhin field in package.json, replacing the old plugin.yml. Parsing and strict validation happen in @zhin.js/runtime (packages/im/runtime/src/manifest.ts); invalid manifests throw ManifestValidationError directly.
{
"zhin": {
"protocol": 1, // Required, currently always 1
"type": "plugin", // Required: "plugin" | "feature"
"entry": "./plugin.js", // Required: published packages use JS; local private Root can use ./plugin.ts directly
"engine": "^1.0.0", // Optional: semver requirement for the Runtime engine version, refuses to load if unsatisfied
"runtime": "trusted", // Optional (plugin only): "trusted" (default) | "isolated"
"platformFeatures": true, // Optional (plugin only): default true, Root plugins automatically get official Stable Features; set false to opt out
"features": [ // Optional (plugin only): list of dependent Feature packages, defaults to []
{ "package": "@zhin.js/command", "api": "^1.0.0", "optional": false }
],
"plugins": [ // Optional (plugin only): list of mounted child plugin instances, defaults to []
{ "package": "@zhin.js/adapter-icqq", "instanceKey": "icqq" }
]
}
}Field details:
| Field | Required | Meaning |
|---|---|---|
protocol | Yes | Manifest protocol version, currently must be 1 |
type | Yes | plugin (can mount child plugins, can declare features/runtime) or feature (platform capability provider) |
entry | Yes | Plugin/feature entry file, package-relative path, must start with ./ and must not contain .. |
engine | No | semver range, performs satisfies validation against the Runtime-provided engine version |
runtime | No (plugin only) | isolated means executing in an isolated runtime: limited to child plugins (not available for Root), cannot mount Host Features, and requires isolation adapter support |
platformFeatures | No (plugin only) | Default true: Root plugins get official Stable Features (@zhin.js/adapter, command, component) even without declaring them |
features | No (plugin only) | Dependent Feature packages: package supports npm package names or ./ relative paths (monorepo local); api is a semver requirement for the Feature's declared featureApi version; optional marks that absence can be degraded |
plugins | No (plugin only) | Mount child plugin instances: package follows the same package name rules as features; instanceKey required, must match ^[a-z0-9][a-z0-9-]*$, serves as the key for instance isolation and configuration -- multiple instances of the same package use different instanceKeys, instance configuration is written under plugins.<instanceKey> in the app's zhin.config.yml |
Feature packages (type: "feature") have only five fields in their manifest: protocol / type / entry / engine / featureApi, where featureApi (optional) declares the API version implemented by this feature, used for consumer-side features[].api validation.
Development Source and npm Publish Entry
Local Root Plugins and workspace-private plugins can set zhin.entry to ./plugin.ts; Node's native TypeScript support and HMR will load the source directly. Plugins published to npm must declare their entry as ./plugin.js and include plugin.js, generated JavaScript from convention directories, and lib in files:
{
"files": ["lib", "plugin.js", "commands", "middlewares"],
"scripts": {
"build": "tsc",
"prepack": "pnpm run build && node ../../../scripts/build-plugin-runtime-entries.mjs",
"postpack": "node ../../../scripts/build-plugin-runtime-entries.mjs --clean"
},
"zhin": {
"protocol": 1,
"type": "plugin",
"entry": "./plugin.js"
}
}Official plugins in the repository uniformly use build-plugin-runtime-entries.mjs during prepack: it compiles plugin.ts and TypeScript files in convention directories into same-directory JavaScript, and rewrites relative imports pointing to src/ to point to lib/ instead; after the tarball is created, postpack --clean removes these generated co-located artifacts. The regular build does not generate them, so workspace testing and HMR always hit TypeScript source; when loading from node_modules, the published JavaScript in the package is preferred, avoiding Node refusing to perform type stripping on dependency packages.
Next Steps
- Convention Directories: How commands / middlewares / adapters directories are discovered
- Module-Level State: The correct approach beyond
provide/ module singletons