This guide takes you from nothing to a working, validated plugin. The fast path is two commands:
hyp plugin new @yourorg/my-thing --kind source # scaffold
hyp plugin doctor ./my-thing # validatehyp plugin doctor runs static checks and a dry-run of your
activate() function, then prints every problem at once with a fix for
each. Run it after every change. It also accepts --json for use by
agents and scripts.
The dry-run imports and runs your entrypoint in-process, isolating only its state/cache/temp paths to a throwaway directory: it is not a security sandbox. Run the doctor only on plugin code you trust, just as you would before installing it.
A plugin is two things:
- a manifest (
hypaware.plugin.json) that declares what the plugin contributes, and - an entrypoint (
src/index.js) that exportsactivate(ctx)and registers those contributions at runtime.
The doctor's most important check is that these two agree: anything you
declare in the manifest must actually be registered in activate().
# 1. Scaffold (kinds: source | sink | dataset)
hyp plugin new @yourorg/widget --kind source --dir hypaware-core/plugins-workspace
# 2. Edit src/index.js - fill in the TODOs in activate()
# 3. Validate
hyp plugin doctor hypaware-core/plugins-workspace/widgetThe scaffold is intentionally minimal and passes the doctor out of the box, so you always start from green and edit toward your feature.
hypaware.plugin.json lives at the plugin root. Required and optional
fields (validated by src/core/manifest.js):
| Field | Required | Notes |
|---|---|---|
schema_version |
yes | Must be 1. |
name |
yes | Scoped, @scope/slug by convention, e.g. @yourorg/widget. |
version |
yes | Semver X.Y.Z. |
hypaware_api |
yes | Semver range against the kernel API, e.g. ^1.0.0. |
runtime |
yes | "node" (the only V1 runtime). |
entrypoint |
yes | Path to the module exporting activate, e.g. ./src/index.js. |
node_engine |
no | e.g. ">=20". |
description |
no | One line; shown in help. |
permissions |
no | String array, e.g. ["network", "read_env"]. |
requires |
no | { plugins?, capabilities? }: see Capabilities. |
provides |
no | { capabilities? }: see Capabilities. |
contributes |
no | What the plugin adds: sources, sinks, datasets, commands, skills, agents, init_presets, config_sections, client. |
Each entry under contributes.{sources,sinks,datasets,commands,skills,agents,init_presets}
needs a non-empty name; config_sections entries use section.
The entrypoint exports one function:
// @ts-check
/**
* @import { PluginActivationContext } from '<path>/hypaware-plugin-kernel-types.d.ts'
*/
const PLUGIN_NAME = '@yourorg/widget'
export async function activate(ctx) {
// Register everything the manifest declares. Do NOT do real work
// (open sockets, read large config, hit the network) here - defer
// that to a source's start() or a sink's create().
}activate() runs once at boot. Its job is to register contributions
on the registries hanging off ctx. The kernel handles dependency
order, paths, logging, and lifecycle. ctx gives you:
ctx.sources,ctx.sinks,ctx.query,ctx.commands,ctx.skills,ctx.agents,ctx.initPresets,ctx.configRegistry: the registries.ctx.requireCapability(name, range)/ctx.provideCapability(name, version, value).ctx.config: the validated config slice for this plugin.ctx.paths:{ rootDir, stateDir, cacheDir, tempDir }, created for you.ctx.log: structured logger;ctx.log.info('event', { ... }).ctx.permissions: check declared permissions.
Style (see
CLAUDE.md): JavaScript, no semicolons, JSDoc types. Declare type imports with@importat the top of the file; never use inlineimport('...')types or@typedef. Define shared types asinterfaces in a.d.tsand@importthem.
A source produces rows and owns a lifecycle. Declare it in the manifest
(contributes.sources: [{ name: "widget" }]) and register it:
ctx.sources.register({
name: 'widget',
plugin: PLUGIN_NAME,
summary: 'Widget event source',
configSection: 'widget',
async start(startCtx) {
// Read startCtx.config, begin producing rows.
return {
async status() { return { state: 'ready' } },
async reload(reloadCtx) { /* config changed */ },
async stop() { /* clean up */ },
}
},
})A sink is an export target. Declare contributes.sinks: [{ name, supports }]
and register a create() that returns a Sink:
ctx.sinks.register({
name: 'widget',
plugin: PLUGIN_NAME,
supports: ['queryable'], // or []
async create(sinkCtx) {
return {
async exportBatch(batch) {
// Write batch.partitions to your destination.
return { status: 'exported', partitionsExported: batch.partitions.length }
},
async close() {},
}
},
})Blob sinks pair with an encoder (hypaware.encoder) or table-format
writer (hypaware.table-format); see Capabilities.
Declare contributes.datasets: [{ name }] and register a schema plus
the partition/row callbacks:
ctx.query.registerDataset({
name: 'widget_events',
plugin: PLUGIN_NAME,
schema: [
{ name: 'event_time', type: 'TIMESTAMP', nullable: false },
{ name: 'message', type: 'STRING', nullable: true },
],
primaryTimestampColumn: 'event_time',
async discoverPartitions() { return [] },
async refreshPartition() { return { rowCount: 0 } },
createDataSource() {
return { async *[Symbol.asyncIterator]() { /* yield rows */ } }
},
})Column type is one of STRING | INT32 | INT64 | DOUBLE | BOOLEAN | TIMESTAMP | JSON.
Declare contributes.commands: [{ name }] and register a run:
ctx.commands.register({
name: 'widget sync',
plugin: PLUGIN_NAME,
summary: 'Sync widgets now',
usage: 'hyp widget sync',
run: async (argv, runCtx) => { runCtx.stdout.write('ok\n'); return 0 },
})Register a plain object. The registry stores a shallow copy of what you pass
and runs its shape checks on that copy, so only own enumerable properties
survive: a class instance whose run() lives on its prototype, or a member
defined non-enumerable, is refused with missing run() even though the
registration visibly declares it. TypeScript cannot warn you here, because it
has no notion of property ownership, so the error arrives at runtime as a
plugin.activate_failed log line and the plugin does not load.
Only the four required members are checked, so only they are refused. An
optional member the copy leaves behind (plugin, aliases, hidden,
audience, a help string) is not refused: registration succeeds and the
command runs with that member simply absent, so a prototype-resident
aliases is a dead alias and a prototype-resident hidden still lists in
hyp --help. Nothing fails, so the only sign is a WARN the registry writes
at register time, on stderr and into the structured log, naming the command
and the members its copy did not carry. plugin is worth naming separately,
because the registry derives category and audience from it: losing it
does not leave a field blank, it files the command under a category named
after the first word of its own name and gives it the everyday audience
instead of operator. Assign optional members onto the instance too, or
register a plain object.
Every declared command is public CLI surface: it appears in hyp --help and
in its group's subcommand table, and a visible diagnostic should carry a
help string explaining what its output means. A command whose caller is a
program rather than a person (a wrapper script, an orchestration step another
command drives) is an internal mechanism: keep the manifest entry, so a
dispatch miss can still name the owning plugin, and set hidden: true on
both the manifest entry and the register call. The manifest flag governs
the help rendered before boot; the registration flag governs group help after
it. See LLP 0268.
Materialize a skill into client skill directories. Declare
contributes.skills: [{ name, clients }] and register:
ctx.skills.register({
name: 'hypaware-widget',
plugin: PLUGIN_NAME,
clients: ['claude', 'codex'],
sourceDir: '/abs/path/to/skill/dir',
})Materialize a custom subagent into client agent directories (e.g.
.claude/agents/). Unlike a skill, an agent is a single markdown
definition file installed flat as <agent_dir>/<name>.md. Declare
contributes.agents: [{ name, clients }] and register:
ctx.agents.register({
name: 'hypaware-widget-analyst',
plugin: PLUGIN_NAME,
clients: ['claude'],
sourceFile: '/abs/path/to/agents/hypaware-widget-analyst.md',
})Only clients whose manifest declares contributes.client.agent_dir
receive agents; a target without one is skipped.
Skills and agents are both client assets and share one install path
(LLP 0138): attaching a client materializes them, and hyp client skills install re-copies both on demand. There is no separate agents
command.
Declare contributes.init_presets: [{ name }] and register a run that
writes a starter config:
ctx.initPresets.register({
name: 'widget',
plugin: PLUGIN_NAME,
summary: 'Initialize HypAware pointed at widget',
run: async (argv, runCtx) => 0,
})Capabilities are versioned contracts between plugins. To provide one,
declare it in the manifest and call provideCapability in activate():
ctx.provideCapability('hypaware.blob-store', '1.0.0', blobStoreImpl)To require one, declare the range and resolve it at use time:
"requires": { "capabilities": { "hypaware.ai-gateway": "^1.0.0" } }const gateway = ctx.requireCapability('hypaware.ai-gateway', '^1.0.0')The doctor checks that every required capability is provided by some
bundled or installed plugin (hyp plugin list shows what is available).
If your plugin reads config, document the section in the manifest
(contributes.config_sections: [{ section, summary }]). To have the
kernel validate that section, register a validator:
ctx.configRegistry.registerSection({
section: 'widget',
plugin: PLUGIN_NAME,
validate(raw) { return { ok: true } },
})Registering a validator is optional: a declared section without one is documented but unvalidated.
Declare what the plugin needs in manifest.permissions (e.g. network,
read_env, read_state, write_state). At runtime, check before use:
if (ctx.permissions.has('network')) { /* ... */ }
ctx.permissions.require('network') // throws if not grantedUse ctx.log with structured fields, not console.log:
ctx.log.info('widget.sync', { component: 'widget', operation: 'sync', status: 'ok', count })Tag thrown errors with a stable hypErrorKind so telemetry and smokes
can group them:
const err = new Error('widget endpoint unreachable')
/** @type {any} */ (err).hypErrorKind = 'widget_unreachable'
throw errKeep dev telemetry local and secret-safe: no credentials, raw prompts,
or private data, hash or redact when identity matters (see CLAUDE.md).
Every hyp plugin doctor finding has a stable kind. What each means
and how to fix it:
kind |
Meaning | Fix |
|---|---|---|
manifest_invalid |
hypaware.plugin.json is missing, not JSON, or fails validation |
Compare against Manifest; hyp plugin new emits a valid one |
entrypoint_missing |
entrypoint doesn't resolve to a file |
Create the file or fix the path (usually ./src/index.js) |
semver_invalid |
version isn't X.Y.Z, or hypaware_api isn't a valid range |
Use "1.0.0" / "^1.0.0" |
name_convention (warn) |
name isn't @scope/slug |
Rename to a scoped form |
contributes_malformed |
A contributes entry is missing its name/section |
Give every entry a name |
entrypoint_import_failed |
Importing the entrypoint threw | Fix the syntax/import error shown |
activate_missing |
The entrypoint exports no activate function |
Add export async function activate(ctx) { ... } |
activate_threw |
activate(ctx) threw during the dry run |
Only register in activate(); defer work to start()/create() |
contribution_not_registered |
Manifest declares something activate() never registered |
Add the matching ctx.<registry>.register(...) call |
contribution_undeclared (warn) |
activate() registered something the manifest doesn't declare |
Add it to contributes.* so discovery and inactive-command ownership stay complete |
command_help_drift |
A declared command's manifest summary or hidden visibility differs from its registration. An entry with no summary at all counts: top-level help then lists the command blank |
Make the summary and visibility agree on both sides |
command_help_drift (warn) |
ctx.commands.registerGroup describes a group the manifest declares no command under |
Declare the group's subcommands, or drop the registerGroup call |
capability_unresolved |
A required capability has no provider, or none in the required version range | Install a provider matching the range, widen the range, or drop the requirement |
capability_unprovided (warn) |
Manifest says it provides a capability activate() never provided |
Call ctx.provideCapability(...) |
hypaware-plugin-kernel-types.d.ts: the full plugin API surface.hypaware-core/plugins-workspace/gascity/: a complete worked example (source + dataset + commands + init preset + skill).hypaware-core/plugins-workspace/s3/: a blob-store sink that provides a capability.