Skip to content

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.

ts
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:

MemberTypePurpose
pluginPluginInstanceViewInstance view: id / instanceKey / parent / root / role ('root' | 'child'). Isolated by instanceKey in multi-instance deployments
configConfigView<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
resourcesScopeResource scope: has(token) / use(token) resolve Host tokens, provide(token, value, dispose?) publishes resources to child scopes
lifecycleDisposeStackGeneration's cleanup stack: cleanup functions registered via lifecycle.add(dispose) run in reverse order when the generation ends
handoffGenerationHandoffRegistryGeneration handoff registry: handoff.add(participant) participates in hot reload transactions (see "Generation Handoff" below)
addFeature(feature, name, definition)General Feature registration entry pointRegisters 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.

ts
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):

ts
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:

ts
// 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.

tokentoken idAvailability conditionKey methods
databaseHostTokenzhin.database.hostdatabase: configureddefine(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
scheduleHostTokenzhin.schedule.hostAlways availableregister(job) registers a 6-field solar cron (second minute hour day month weekday), returns a cancel function; list() lists tasks
outboundHostTokenzhin.outbound.hostHas available Adaptersend(input) proactive push (returns platform message id or null); optional addReaction / removeReaction / recall
agentToolsHostTokenzhin.agent-tools.hostAI installed and enabled (Agent Host)register(tool) registers an Agent tool, returns an unregister function
htmlRendererTokenzhin.html-renderer.host@zhin.js/html-renderer installedrender(html, { width, format, backgroundColor }) -> PNG (Buffer) or SVG (string); must degrade to plain text when not installed
runtimeEventPublisherTokenzhin.runtime.event-publisherRoot-level, CLI console assemblypublish(type, data) broadcasts events to the Console SSE hub (used by adapters to push endpoint:request / endpoint:notice etc.)
httpHostTokenzhin.host.httpHTTP Host enabledroute(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.

ts
// 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:

HookTiming
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:

ts
context.handoff.add({
  activateNext: async () => {
    await outbound.send({ ...target, content: `${config.greeting},capabilities-bot 已上线` });
  },
});

Real-World Examples

  • capabilities-bot: A single setup() in plugin.ts exercises all common Host facets (database / schedule / agent-tools / outbound / handoff), each with has() 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 via databaseHostToken, falling back to in-memory implementation when absent; provides its own token for command reuse; Agent tools use await 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:

ts
// 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.

jsonc
{
  "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:

FieldRequiredMeaning
protocolYesManifest protocol version, currently must be 1
typeYesplugin (can mount child plugins, can declare features/runtime) or feature (platform capability provider)
entryYesPlugin/feature entry file, package-relative path, must start with ./ and must not contain ..
engineNosemver range, performs satisfies validation against the Runtime-provided engine version
runtimeNo (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
platformFeaturesNo (plugin only)Default true: Root plugins get official Stable Features (@zhin.js/adapter, command, component) even without declaring them
featuresNo (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
pluginsNo (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:

jsonc
{
  "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