Reference for plugin packaging (package.json metadata), manifests (apus.plugin.json), setup entries, and config schemas.
Looking for a walkthrough? The how-to guides cover packaging in context: Channel plugins and Provider plugins.
On this page:
Package metadata
Your package.json needs an apus field that tells the plugin system what your plugin provides:
Channel plugin
```json
{
"name": "@myorg/apus-my-channel",
"version": "1.0.0",
"type": "module",
"apus": {
"extensions": ["./index.ts"],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "my-channel",
"label": "My Channel",
"blurb": "Short description of the channel."
}
}
}
```
Provider plugin / ClawHub baseline
```json apus-clawhub-package.json
{
"name": "@myorg/apus-my-plugin",
"version": "1.0.0",
"type": "module",
"dependencies": {
"typebox": "1.3.34"
},
"peerDependencies": {
"apus": ">=2026.3.24-beta.2"
},
"apus": {
"extensions": ["./index.ts"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
},
"build": {
"apusVersion": "2026.3.24-beta.2",
"pluginSdkVersion": "2026.3.24-beta.2"
}
}
}
```
Publishing externally on ClawHub requires compat and build. Canonical publish snippets live in docs/snippets/plugin-publish/.
apus fields
extensions string[]
Entry point files (relative to package root). Valid source entries for workspace and git checkout development.
runtimeExtensions string[]
Built JavaScript peers for extensions, preferred when Apus loads an installed npm package. See SDK entry points for the source/built resolution order.
setupEntry string
Lightweight setup-only entry (optional).
runtimeSetupEntry string
Built JavaScript peer for setupEntry. Requires setupEntry to also be set.
plugin object
{ id, label } fallback plugin identity, used when a plugin has no channel/provider metadata to derive an id or label from.
channel object
Channel catalog metadata for setup, picker, quickstart, and status surfaces.
install object
Install hints: npmSpec, localPath, defaultChoice, minHostVersion, expectedIntegrity, allowInvalidConfigRecovery, requiredPlatformPackages.
startup object
Startup behavior flags.
compat object
pluginApi version range this plugin supports. Required for external ClawHub publishes.
Provider ids (providers: string[]) are manifest metadata, not package metadata. Declare them in apus.plugin.json, not here — see Plugin manifest.
apus.channel
apus.channel is cheap package metadata for channel discovery and setup surfaces before runtime loads.
Channel-owned setup fields
Channel plugins should define setup fields once in runtime code with defineChannelSetupContract(...) and publish the matching serializable projection under apus.channel.setup.fields. The runtime definition infers the plugin-local input type, parses both guided and non-interactive values, and keeps channel-specific keys out of core types. Package metadata lets apus channels add <channel-id> --help and apus channels add --channel <channel-id> --help discover only the selected channel's options without loading the plugin.
import { defineChannelSetupContract } from "apus/plugin-sdk/channel-setup";
export const setupContract = defineChannelSetupContract({
fields: {
endpoint: {
kind: "string",
cli: { flags: "--endpoint <url>", description: "Service endpoint" },
},
transport: {
kind: "choice",
choices: ["native", "container"],
cli: { flags: "--transport <kind>", description: "Transport owner" },
},
},
adapter: {
applyAccountConfig: ({ cfg, input }) => ({
...cfg,
channels: { ...cfg.channels, example: input },
}),
},
});
{
"apus": {
"channel": {
"id": "example",
"setup": {
"fields": [
{
"key": "endpoint",
"kind": "string",
"cli": { "flags": "--endpoint <url>", "description": "Service endpoint" }
},
{
"key": "transport",
"kind": "choice",
"choices": ["native", "container"],
"cli": { "flags": "--transport <kind>", "description": "Transport owner" }
}
]
}
}
}
}
Supported field kinds are string, boolean, integer, string-list, and choice. Use sensitive: true for credentials. Each field key must equal the camelCased attribute name of its long CLI flag, including any negated form, such as apiToken for --api-token. Boolean fields may add cli.negatedFlags when both positive and --no-* forms are needed. channel, account, and the account display name remain the shared control envelope.
For a boolean useEnv field, set envVars to the static environment variable names required by the plugin runtime. Non-interactive channel setup then rejects --use-env before writing config when any declared variable is empty. Set envVarMode: "any" when one variable from the list is sufficient, such as an inline credential or file-path alternative. Omitting envVars preserves the plugin's existing validation behavior.
The released setup/ChannelSetupInput adapter stays available for existing external plugins. New plugins should expose setupContract. Apus always prefers it when both are present.
Use afterAccountConfigWritten (or a wizard's afterConfigWritten) for connection checks and other work that requires saved configuration. Apus runs these callbacks after the write succeeds and passes a runtime cfg reread from the exact committed file, with environment references and plugin defaults resolved. The saved file can retain ${VAR} references. Missing or invalid saved configuration prevents callback execution. Callback failures are reported as post-setup warnings without undoing the saved configuration.
| Field | Type | What it means |
|---|---|---|
id |
string |
Canonical channel id. |
label |
string |
Primary channel label. |
selectionLabel |
string |
Picker/setup label when it should differ from label. |
detailLabel |
string |
Secondary detail label for richer channel catalogs and status surfaces. |
docsPath |
string |
Docs path for setup and selection links. |
docsLabel |
string |
Override label used for docs links when it should differ from the channel id. |
blurb |
string |
Short onboarding/catalog description. |
order |
number |
Sort order in channel catalogs. |
aliases |
string[] |
Extra lookup aliases for channel selection. |
preferOver |
string[] |
Lower-priority plugin/channel ids this channel should outrank. |
systemImage |
string |
Optional icon/system-image name for channel UI catalogs. |
selectionDocsPrefix |
string |
Prefix before docs links; omit for default or use "" to suppress it. |
selectionDocsOmitLabel |
boolean |
Show the docs path directly instead of a labeled docs link in selection copy. |
selectionExtras |
string[] |
Extra short strings appended in selection copy. |
markdownCapable |
boolean |
Marks the channel as markdown-capable for outbound formatting decisions. |
exposure |
object |
Channel visibility controls for setup, configured lists, and docs surfaces. |
quickstartAllowFrom |
boolean |
Opt this channel into the standard quickstart allowFrom setup flow. |
forceAccountBinding |
boolean |
Require explicit account binding even when only one account exists. |
preferSessionLookupForAnnounceTarget |
boolean |
Prefer session lookup when resolving announce targets for this channel. |
setup |
object |
Serializable channel-owned setup fields used for lazy CLI option discovery. |
Example:
{
"apus": {
"channel": {
"id": "my-channel",
"label": "My Channel",
"selectionLabel": "My Channel (self-hosted)",
"detailLabel": "My Channel Bot",
"docsPath": "/channels/my-channel",
"docsLabel": "my-channel",
"blurb": "Webhook-based self-hosted chat integration.",
"order": 80,
"aliases": ["mc"],
"preferOver": ["my-channel-legacy"],
"selectionDocsPrefix": "Guide:",
"selectionExtras": ["Markdown"],
"markdownCapable": true,
"exposure": {
"configured": true,
"setup": true,
"docs": true
},
"quickstartAllowFrom": true
}
}
}
exposure supports:
configured: include the channel in configured/status-style listing surfacessetup: include the channel in interactive setup/configure pickersdocs: mark the channel as public-facing in docs/navigation surfaces
apus.install
apus.install is package metadata, not manifest metadata.
| Field | Type | What it means |
|---|---|---|
clawhubSpec |
string |
Canonical ClawHub spec for install/update and onboarding install-on-demand flows. |
npmSpec |
string |
Canonical npm spec for install/update fallback flows. |
localPath |
string |
Local development or bundled install path. |
defaultChoice |
"clawhub" | "npm" | "local" |
Preferred install source when multiple sources are available. |
minHostVersion |
string |
Minimum supported Apus version, >=x.y.z or >=x.y.z-prerelease. |
expectedIntegrity |
string |
Expected npm dist integrity string, usually sha512-..., for pinned installs. |
allowInvalidConfigRecovery |
boolean |
Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
requiredPlatformPackages |
string[] |
Required platform-specific npm aliases verified during npm install. |
Onboarding behavior
Interactive onboarding uses `apus.install` for install-on-demand surfaces: if your plugin exposes provider auth choices or channel setup/catalog metadata before runtime loads, onboarding can prompt for ClawHub, npm, or local install, install or enable the plugin, then continue the selected flow. ClawHub choices use `clawhubSpec` and are preferred when present. Choices from npm require trusted catalog metadata with a registry `npmSpec` (exact versions and `expectedIntegrity` are optional pins, enforced on install/update when set). Keep "what to show" in `apus.plugin.json` and "how to install it" in `package.json`.
minHostVersion enforcement
If `minHostVersion` is set, install and non-bundled manifest-registry loading both enforce it. Older hosts skip external plugins. Invalid version strings are rejected. Bundled source plugins are assumed to be co-versioned with the host checkout.
Pinned npm installs
For pinned npm installs, keep the exact version in `npmSpec` and add the expected artifact integrity:
```json
{
"apus": {
"install": {
"npmSpec": "@wecom/wecom-apus-plugin@1.2.3",
"expectedIntegrity": "sha512-REPLACE_WITH_NPM_DIST_INTEGRITY",
"defaultChoice": "npm"
}
}
}
```
allowInvalidConfigRecovery scope
`allowInvalidConfigRecovery` is not a general bypass for broken configs. It is narrow bundled-plugin recovery only, letting reinstall/setup repair known upgrade leftovers like a missing bundled plugin path or a stale `channels.<id>` entry for that same plugin. If config is broken for unrelated reasons, install still fails closed and tells the operator to run `apus doctor --fix`.
Setup-time gateway methods
If your setup/full entry registers gateway RPC methods, keep them on a plugin-specific prefix. Reserved core admin namespaces (config.*, exec.approvals.*, wizard.*, update.*) stay core-owned and always normalize to operator.admin.
Plugin manifest
Every native plugin must ship an apus.plugin.json in the package root. Apus uses this to validate config without executing plugin code.
{
"id": "my-plugin",
"name": "My Plugin",
"description": "Adds My Plugin capabilities to Apus",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webhookSecret": {
"type": "string",
"description": "Webhook verification secret"
}
}
}
}
For channel plugins, add channels (and provider plugins add providers):
{
"id": "my-channel",
"channels": ["my-channel"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
Even plugins with no config must ship a schema. An empty schema is valid:
{
"id": "my-plugin",
"configSchema": {
"type": "object",
"additionalProperties": false
}
}
See Plugin manifest for the full schema reference.
Setup entry
setup-entry.ts is a lightweight alternative to index.ts that Apus loads when it only needs setup surfaces (onboarding, config repair, disabled channel inspection):
// setup-entry.ts
import { defineSetupPluginEntry } from "apus/plugin-sdk/channel-core";
import { myChannelPlugin } from "./src/channel.js";
export default defineSetupPluginEntry(myChannelPlugin);
This avoids loading heavy runtime code (crypto libraries, CLI registrations, background services) during setup flows.
Bundled workspace channels that keep setup-safe exports in sidecar modules can use defineBundledChannelSetupEntry(...) from apus/plugin-sdk/channel-entry-contract instead of defineSetupPluginEntry(...). That bundled contract also supports an optional runtime export so setup-time runtime wiring can stay lightweight and explicit.
When Apus uses setupEntry instead of the full entry
- The channel is disabled but needs setup/onboarding surfaces.
- The channel is enabled but unconfigured.
What setupEntry must register
- The channel plugin object (via `defineSetupPluginEntry`).
- Setup-time runtime surfaces declared through `registerSetupRuntime`, when needed.
Setup-time gateway methods should still avoid reserved core admin namespaces such as `config.*` or `update.*`.
What setupEntry should NOT include
- CLI registrations.
- Background services.
- Heavy runtime imports (crypto, SDKs).
- Gateway methods only needed after startup.
Narrow setup helper imports
For hot setup-only paths, prefer the narrow setup helper seams over the broader plugin-sdk/setup umbrella when you only need part of the setup surface:
| Import path | Use it for | Key exports |
|---|---|---|
plugin-sdk/setup-runtime |
setup-time runtime helpers that stay available in setupEntry |
createSetupTranslator, createPatchedAccountSetupAdapter, createEnvPatchedAccountSetupAdapter, createSetupInputPresenceValidator, noteChannelLookupFailure, noteChannelLookupSummary, promptResolvedAllowFrom, splitSetupEntries, createAllowlistSetupWizardProxy, createDelegatedSetupWizardProxy |
plugin-sdk/setup-tools |
setup/install CLI/archive/docs helpers | formatCliCommand, detectBinary, extractArchive, resolveBrewExecutable, formatDocsLink, CONFIG_DIR |
Use the broader plugin-sdk/setup seam when you want the full shared setup toolbox, including config-patch helpers such as moveSingleAccountChannelSectionToDefaultAccount(...).
Use createSetupTranslator(...) for fixed setup wizard copy. It uses the first nonblank value from APUS_LOCALE, LC_ALL, LC_MESSAGES, and LANG, in that order, then falls back to English. Set APUS_LOCALE=en for an explicit English override. Keep plugin-specific setup text in plugin-owned code and use shared catalog keys only for common setup labels, status text, and official bundled plugin setup copy.
The setup patch adapters do no eager work when a plugin imports them. Their bundled single-account promotion contract-surface lookup is lazy, so importing plugin-sdk/setup-runtime does not eagerly load bundled contract-surface discovery before the adapter is actually used.
Channel-owned setup input fields
ChannelSetupInput is a generic envelope shared by setup callers and channel
plugins. Its permanently typed fields are name, token, tokenFile,
useEnv, allowFrom, and defaultTo. Additional plugin-owned keys can still
be present on the runtime input object, but the shared type does not declare an
index signature. Each plugin must declare and narrow its own setup fields or
validate them with a plugin-owned schema at the adapter boundary:
import type { ChannelSetupAdapter, ChannelSetupInput } from "apus/plugin-sdk/channel-setup";
type AcmeSetupInput = ChannelSetupInput & {
workspaceId?: string;
webhookUrl?: string;
};
export const acmeSetupAdapter: ChannelSetupAdapter = {
applyAccountConfig: ({ cfg, input }) => {
const setupInput = input as AcmeSetupInput;
return {
...cfg,
channels: {
...cfg.channels,
acme: {
token: setupInput.token,
workspaceId: setupInput.workspaceId,
webhookUrl: setupInput.webhookUrl,
},
},
};
},
};
Channel-specific fields that were previously declared directly on
ChannelSetupInput remain temporarily typed for external source compatibility.
They are deprecated. A 2026-07-22 registry sweep of 426 published out-of-tree
channel plugins removed 21 fields with no readers and retained 22 with known
readers. Each retained field is deleted as soon as no published plugin reads it.
No version boundary is required. New and bundled plugins must not rely on this
tier. Declare the fields they own locally.
Channel-owned single-account promotion
When a channel upgrades from a single-account top-level config to channels.<id>.accounts.*, the default shared behavior moves promoted account-scoped values into accounts.default.
Every channel plugin can extend or narrow that promotion through its setup adapter:
configPromotion: "preserve-root": keep all root values in place, including common name, policy, and delivery fields. The plugin owns its account layoutsingleAccountKeysToMove: extra top-level keys that should move into the promoted accountnamedAccountPromotionKeys: when named accounts already exist, only these keys move into the promoted account. Shared policy/delivery keys stay at the channel rootresolveSingleAccountPromotionTarget(...): choose which existing account receives promoted values
The presence of singleAccountKeysToMove marks the promotion contract complete. Declare the field even when it is an empty array to opt out of legacy key promotion. An empty array does not suppress common fields. Adapters that omit the field retain a reader-backed pre-declaration promotion tier for already-published plugins. The 2026-07-22 registry sweep removed 23 keys with no published dependents and retained six common keys plus the setup-only rooms key. Each retained key is deleted as soon as its published readers migrate to declarations. No version boundary is required.
Declare apus.setupFeatures.configPromotion: true in the plugin package manifest when doctor must load these declarations from the lightweight setup entry. Doctor discovers that entry through the plugin manifest for both bundled and installed plugins, including disabled plugins. The setup-only plugin surface and the full channel plugin must expose the same declarations.
For a plugin-owned root layout, also declare apus.setupFeatures.configPromotion: "preserve-root" in package.json. Doctor reads this static declaration for installed and bundled plugins, including disabled plugins, without executing their runtime. Omitting the declaration or using false does not opt out of promotion. The runtime declaration belongs on the adapter passed to defineChannelSetupContract, and covers shared CLI, declarative wizard, and policy-writer promotion. It does not change helpers that explicitly migrate a base name. Use a selected-account writer when the root remains an implicit identity. Buzz is an example of this layout.
When calling moveSingleAccountChannelSectionToDefaultAccount(...) with an already resolved plugin, pass its setup adapter as setupSurface. Caller-supplied setup surfaces take precedence over loaded and bundled lookup, which keeps scoped or setup-only plugins independent of global registration.
Matrix is the current bundled example. If exactly one named Matrix account already exists, or if defaultAccount points at an existing non-canonical key such as Ops, promotion preserves that account instead of creating a new accounts.default entry.
Config schema
Plugin config is validated against the JSON Schema in your manifest. Users configure plugins via:
{
plugins: {
entries: {
"my-plugin": {
config: {
webhookSecret: "abc123",
},
},
},
},
}
Your plugin receives this config as api.pluginConfig during registration.
For channel-specific config, use the channel config section instead:
{
channels: {
"my-channel": {
token: "bot-token",
allowFrom: ["user1", "user2"],
},
},
}
Building channel config schemas
Use buildChannelConfigSchema to convert a Zod schema into the ChannelConfigSchema wrapper used by plugin-owned config artifacts:
import { z } from "zod";
import { buildChannelConfigSchema } from "apus/plugin-sdk/channel-config-schema";
const accountSchema = z.object({
token: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
accounts: z.object({}).catchall(z.any()).optional(),
defaultAccount: z.string().optional(),
});
const configSchema = buildChannelConfigSchema(accountSchema);
If you already author the contract as JSON Schema or TypeBox, use the direct helper so Apus can skip Zod-to-JSON-Schema conversion on metadata paths:
import { Type } from "typebox";
import { buildJsonChannelConfigSchema } from "apus/plugin-sdk/channel-config-schema";
const configSchema = buildJsonChannelConfigSchema(
Type.Object({
token: Type.Optional(Type.String()),
allowFrom: Type.Optional(Type.Array(Type.String())),
}),
);
For third-party plugins, the cold-path contract is still the plugin manifest: mirror the generated JSON Schema into apus.plugin.json#channelConfigs so config schema, setup, and UI surfaces can inspect channels.<id> without loading runtime code.
Setup wizards
Channel plugins can provide interactive setup wizards for apus onboard. The wizard is a ChannelSetupWizard object on the ChannelPlugin:
import type { ChannelSetupWizard } from "apus/plugin-sdk/channel-setup";
const setupWizard: ChannelSetupWizard = {
channel: "my-channel",
status: {
configuredLabel: "Connected",
unconfiguredLabel: "Not configured",
resolveConfigured: ({ cfg }) => Boolean((cfg.channels as any)?.["my-channel"]?.token),
},
credentials: [
{
inputKey: "token",
providerHint: "my-channel",
credentialLabel: "Bot token",
preferredEnvVar: "MY_CHANNEL_BOT_TOKEN",
envPrompt: "Use MY_CHANNEL_BOT_TOKEN from environment?",
keepPrompt: "Keep current token?",
inputPrompt: "Enter your bot token:",
inspect: ({ cfg, accountId }) => {
const token = (cfg.channels as any)?.["my-channel"]?.token;
return {
accountConfigured: Boolean(token),
hasConfiguredValue: Boolean(token),
};
},
},
],
};
ChannelSetupWizard also supports textInputs, dmPolicy, allowFrom, groupAccess, prepare, finalize, and more. See the Discord plugin's src/setup-core.ts for a full bundled example.
Before a durable setup effect, await options.beforePersistentEffect?.() to run
host preparation. Hosted channel wizards also supply
options.assertPersistentEffectCurrent, a synchronous check of the live wizard
owner. Carry that check through asynchronous credential preparation and invoke it
at the storage owner's final write admission. Detached QR login callbacks must
stop after their wizard is disposed, replaced, or completed; a successful earlier
preparation check does not keep that wizard alive. This optional lifetime check
does not replace the existing asynchronous preparation callback.
Shared allowFrom prompts
For DM allowlist prompts that only need the standard `note -> prompt -> parse -> merge -> patch` flow, prefer the shared setup helpers from `apus/plugin-sdk/setup`: `createPromptParsedAllowFromForAccount(...)` and `createTopLevelChannelParsedAllowFromPrompt(...)`.
Standard channel setup status
For channel setup status blocks that only vary by labels, scores, and optional extra lines, prefer `createStandardChannelSetupStatus(...)` from `apus/plugin-sdk/setup` instead of hand-rolling the same `status` object in each plugin.
Optional channel setup surface
For optional setup surfaces that should only appear in certain contexts, use `createOptionalChannelSetupSurface` from `apus/plugin-sdk/channel-setup`:
```typescript
import { createOptionalChannelSetupSurface } from "apus/plugin-sdk/channel-setup";
const setupSurface = createOptionalChannelSetupSurface({
channel: "my-channel",
label: "My Channel",
npmSpec: "@myorg/apus-my-channel",
docsPath: "/channels/my-channel",
});
// Returns { setupAdapter, setupWizard }
```
`plugin-sdk/channel-setup` also exposes the lower-level `createOptionalChannelSetupAdapter(...)` and `createOptionalChannelSetupWizard(...)` builders when you only need one half of that optional-install surface.
The generated optional adapter/wizard fail closed on real config writes. They reuse one install-required message across `validateInput`, `applyAccountConfig`, and `finalize`, and append a docs link when `docsPath` is set.
Binary-backed setup helpers
For binary-backed setup UIs, prefer the shared delegated helpers instead of copying the same binary/status glue into every channel:
- `createDetectedBinaryStatus(...)` for status blocks that vary only by labels, hints, scores, and binary detection
- `createCliPathTextInput(...)` for path-backed text inputs
- `createDelegatedSetupWizardProxy(...)` when `setupEntry` needs to forward status, prepare, or finalize behavior to a heavier full wizard lazily
- `createDelegatedTextInputShouldPrompt(...)` when `setupEntry` only needs to delegate a `textInputs[*].shouldPrompt` decision
Publishing and installing
External plugins: publish to ClawHub, then install:
npm
```bash
apus plugins install @myorg/apus-my-plugin
```
Bare package specs install from npm, unless the name matches a bundled or official plugin id, in which case Apus uses that local/official copy instead. Use `clawhub:`, `npm:`, `git:`, or `npm-pack:` for deterministic source selection — see [Manage plugins](/plugins/manage-plugins).
ClawHub only
```bash
apus plugins install clawhub:@myorg/apus-my-plugin
```
npm package spec
Use npm when a package has not moved to ClawHub yet, or when you need a
direct npm install path during migration:
```bash
apus plugins install npm:@myorg/apus-my-plugin
```
In-repo plugins: place under the bundled plugin workspace tree. They are automatically discovered during build.
For npm-sourced installs, apus plugins install installs the package into a per-plugin project under ~/.apus/npm/projects with lifecycle scripts disabled (--ignore-scripts). Keep plugin dependency trees pure JS/TS and avoid packages that require postinstall builds.
Gateway startup does not install plugin dependencies. npm/git/ClawHub install flows own dependency convergence. Local plugins must already have their dependencies installed.
Bundled package metadata is explicit, not inferred from built JavaScript at gateway startup. Runtime dependencies belong in the plugin package that owns them. Packaged Apus startup never repairs or mirrors plugin dependencies.
ClawHub publishing
Skills and plugin packages use separate ClawHub publish commands. For plugin packages, use the package-specific command:
clawhub package publish your-org/your-plugin --dry-run
clawhub package publish your-org/your-plugin
clawhub skill publish <path> is a different command for publishing a skill folder, not a plugin package. See Publishing on ClawHub.
Related
- Building plugins — step-by-step getting started guide
- Plugin manifest — full manifest schema reference
- SDK entry points —
definePluginEntryanddefineChannelPluginEntry - Plugin SDK overview — import map and registration API reference
- Plugin SDK subpaths — the public entry point catalog
- Plugin architecture internals — load pipeline and registry model