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) 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';

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, addHandler, 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';

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, and httpHostToken is exported from @zhin.js/host-http.

tokentoken idAvailability conditionKey methods
databaseHostTokenzhin.database.hostdatabase: configureddefine(name, columns) registers a plugin-private logical table; Runtime maps it to a physical name by PluginId, and models.get(name) can access only that plugin's tables (select / insert / update / delete / count)
scheduleHostTokenzhin.schedule.hostAlways availableregister(job) registers a plugin-private logical job id with a 6-field solar cron (second minute hour day month weekday), returns a cancel function; list() returns only that plugin's jobs

databaseHostToken and scheduleHostToken do not expose process-wide start / stop, Console administration, or the raw database. The CLI owns those root-only lifecycles; plugins use logical table names and job ids, so equal names cannot collide with sibling or child plugins. | outboundHostToken | zhin.outbound.host | Has available Adapter | send(input) proactive push (returns platform message id or null); optional addReaction / removeReaction / recall | | 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 |

Host-token unregister functions belong in lifecycle; Tool capabilities are written directly to the candidate generation and need no manual cleanup.

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: shares the same candidate capability table as tools/*.ts
context.addTool('showcase_greet', defineAgentTool<{ name?: string }>({
    description: 'Return the configured greeting for a name',
    approval: 'never',
    inputSchema: {
      type: 'object',
      properties: { name: { type: 'string' } },
      required: ['name'],
    },
    execute: (input) => `${config.greeting},${String(input.name ?? 'world')}!`,
}));

Generation Handoff

Hot reload is a generation transaction: after the candidate completes every fallible readiness step, the snapshot and admission gates publish atomically. The old generation keeps serving until that publish point. context.handoff.add(participant) registers a candidate resource participant:

HookTiming
activateNext(signal)Establish the candidate resource and prove readiness; must support cancellation
deactivateNext()Roll back new generation on activation failure

For example, require a custom resource connection before the generation may publish:

ts
context.handoff.add({
  activateNext: (signal) => client.connect({ signal }),
  deactivateNext: () => client.close(),
});

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 });

  context.addTool('lottery_sync', createLotterySyncTool());
  // ...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