@bernierllc/onboarding-feature-plugin
v0.2.0
Published
Plugin contract and registry for extending onboarding agents with goals, tools, side-panel slots, and handoffs.
Readme
@bernierllc/onboarding-feature-plugin
Plugin contract and registry for extending onboarding agents with additional goals, questions, tools, side-panel UI slots, and post-completion handoffs.
Overview
This package owns the extension contract and merge logic for the BernierLLC onboarding system. Concrete plugins (e.g. @bernierllc/onboarding-plugin-website-builder) live in their own packages and depend on this contract.
Key capabilities:
OnboardingPlugininterface — the complete plugin contract every author must implementPluginRegistry— in-memory registry with duplicate-id detectionmergePluginContributions()— deterministic merge of goals, phases, tools, panels, and handoffs into a baseOnboardingConfig- Bulk collision detection — reports all id/name conflicts at once, not just the first
- Ordering rules — deterministic phase and handoff ordering with warning logging for inter-base-phase slots
Installation
npm install @bernierllc/onboarding-feature-pluginUsage
Registering and using a plugin
import {
PluginRegistry,
mergePluginContributions,
OnboardingPluginError,
OnboardingPluginConflictError,
} from '@bernierllc/onboarding-feature-plugin';
import type {
OnboardingPlugin,
PluginContext,
OnboardingGoal,
PluginToolDefinition,
PluginPanelDescriptor,
HandoffTarget,
MergedConfig,
} from '@bernierllc/onboarding-feature-plugin';
// 1. Define a plugin (typically imported from a concrete plugin package)
const websiteBuilderPlugin: OnboardingPlugin = {
id: 'website-builder',
name: 'Website Builder Plugin',
version: '1.0.0',
configSchema: {
type: 'object',
properties: { siteTemplate: { type: 'string' } },
required: ['siteTemplate'],
},
validateConfig(config: unknown): asserts config is Record<string, unknown> {
if (
typeof config !== 'object' ||
config === null ||
typeof (config as Record<string, unknown>)['siteTemplate'] !== 'string'
) {
throw new OnboardingPluginError('siteTemplate (string) is required', {
code: 'INVALID_PLUGIN_CONFIG',
});
}
},
contributeGoals(_ctx: PluginContext): OnboardingGoal[] {
return [
{
id: 'website-builder:domain-chosen',
description: 'Domain preference captured',
required: false,
phase: 'getting-specific',
completionCheck: { type: 'field-present', field: 'preferredDomain' },
},
];
},
contributePhases: () => [],
contributeTools(_ctx: PluginContext): PluginToolDefinition[] {
return [
{
name: 'previewWebsite',
description: 'Generate a live website preview',
parameters: {
type: 'object',
properties: { template: { type: 'string' } },
required: ['template'],
},
requiredPermissions: ['website:preview'],
},
];
},
contributePanel(_ctx: PluginContext): PluginPanelDescriptor | null {
return {
slotId: 'website-builder-preview',
label: 'Website Preview',
triggerToolNames: ['previewWebsite'],
metadata: { position: 'right' },
};
},
contributeHandoffs(
_ctx: PluginContext,
state: Record<string, unknown>
): HandoffTarget[] {
if (!state['sitePublished']) return [];
return [
{
id: 'website-builder:view-site',
type: 'external',
label: 'View your live site',
order: 10,
},
];
},
};
// 2. Register the plugin
const registry = new PluginRegistry();
registry.register(websiteBuilderPlugin);
// 3. Validate per-instance config before merge
registry.validatePluginConfig('website-builder', {
siteTemplate: 'contractor-default',
});
// 4. Merge into a base config
const merged: MergedConfig = mergePluginContributions(
baseConfig,
[{ plugin: websiteBuilderPlugin, config: { siteTemplate: 'contractor-default' } }],
currentSessionState // optional — defaults to {}
);
// merged.config — OnboardingConfig with combined goals, phases, handoffs
// merged.tools — PluginToolDefinition[] to register with agent-tool-registry
// merged.panels — PluginPanelDescriptor[] to pass to the UI host
// merged.pluginIds — ['website-builder'] — registration order for auditabilityHandling multiple plugins
import { PluginRegistry, mergePluginContributions } from '@bernierllc/onboarding-feature-plugin';
const registry = new PluginRegistry();
registry.register(websiteBuilderPlugin);
registry.register(calendarPlugin);
console.log(registry.listRegistered()); // ['website-builder', 'calendar']
const merged = mergePluginContributions(
baseConfig,
[
{ plugin: websiteBuilderPlugin, config: { siteTemplate: 'default' } },
{ plugin: calendarPlugin, config: { timezone: 'America/New_York' } },
]
);Error handling
import {
OnboardingPluginError,
OnboardingPluginConflictError,
} from '@bernierllc/onboarding-feature-plugin';
// Duplicate registration
try {
registry.register(websiteBuilderPlugin); // already registered
} catch (err) {
if (err instanceof OnboardingPluginError) {
console.error(err.code); // 'PLUGIN_ALREADY_REGISTERED'
console.error(err.context); // { pluginId: 'website-builder' }
}
}
// Config validation failure
try {
registry.validatePluginConfig('website-builder', { siteTemplate: 42 });
} catch (err) {
if (err instanceof OnboardingPluginError) {
console.error(err.code); // 'PLUGIN_CONFIG_INVALID'
console.error(err.cause); // original error from plugin.validateConfig
}
}
// Id collision during merge (all conflicts reported at once)
try {
mergePluginContributions(baseConfig, [pluginWithDuplicateGoalId]);
} catch (err) {
if (err instanceof OnboardingPluginConflictError) {
console.error(err.conflicts); // ['goal:goal-name', 'tool:sharedTool']
}
}API
PluginRegistry
class PluginRegistry {
constructor(logger?: Logger)
/** Registers a plugin. Throws OnboardingPluginError if id is already registered. */
register(plugin: OnboardingPlugin): void
/** Returns the plugin for the given id, or undefined if not found. */
resolve(id: string): OnboardingPlugin | undefined
/** Validates per-instance config via the plugin's own validateConfig. Wraps errors with plugin context. */
validatePluginConfig(id: string, config: unknown): void
/** Returns registered plugin ids in registration order. */
listRegistered(): string[]
}mergePluginContributions()
function mergePluginContributions(
base: OnboardingConfig,
plugins: ReadonlyArray<{ plugin: OnboardingPlugin; config: unknown }>,
state?: Record<string, unknown>,
logger?: Logger
): MergedConfigDeterministic merge algorithm:
| Item | Strategy |
|------|----------|
| Goals | Appended after base goals in plugin registration order |
| Phases | Appended, then sorted by order; inter-base-phase slots logged as warnings |
| Handoffs | Merged, sorted by order; ties broken by registration order (stable sort) |
| Tools | Concatenated in registration order |
| Panels | Concatenated — each plugin may contribute one or zero panels |
All id/name collisions (goals, phases, handoffs, tools) are collected before throwing OnboardingPluginConflictError, so callers see the complete picture.
OnboardingPlugin interface
interface OnboardingPlugin {
id: string;
name: string;
version: string;
configSchema: Record<string, unknown>;
validateConfig(config: unknown): asserts config is Record<string, unknown>;
contributeGoals(context: PluginContext): OnboardingGoal[];
contributePhases(context: PluginContext): OnboardingPhase[];
contributeTools(context: PluginContext): PluginToolDefinition[];
contributePanel?(context: PluginContext): PluginPanelDescriptor | null;
contributeHandoffs(context: PluginContext, state: Record<string, unknown>): HandoffTarget[];
}Error classes
| Class | Code | When thrown |
|-------|------|-------------|
| OnboardingPluginError | ONBOARDING_PLUGIN_ERROR | Base error for all plugin-related failures |
| OnboardingPluginError | PLUGIN_ALREADY_REGISTERED | Duplicate id on register() |
| OnboardingPluginError | PLUGIN_NOT_FOUND | Unknown id on validatePluginConfig() |
| OnboardingPluginError | PLUGIN_CONFIG_INVALID | Plugin's validateConfig throws |
| OnboardingPluginConflictError | ONBOARDING_PLUGIN_CONFLICT | Id/name collision during merge |
All errors follow ES2022 Error.cause chaining — underlying errors are never swallowed.
Integration Documentation
Logger integration
PluginRegistry and mergePluginContributions accept an optional @bernierllc/logger Logger instance. When omitted, a silent no-transport logger is created internally so the package produces no console output by default.
import { Logger, LogLevel, ConsoleTransport } from '@bernierllc/logger';
import { PluginRegistry } from '@bernierllc/onboarding-feature-plugin';
const logger = new Logger({
level: LogLevel.DEBUG,
transports: [new ConsoleTransport()],
});
const registry = new PluginRegistry(logger);Logged events:
DEBUG— plugin registered, merge summaryWARN— plugin phase order slots between base phases
NeverHub integration
This is a pure core package with no I/O, no React, and no NeverHub dependency. NeverHub integration is handled at the service layer (onboarding-agent-service) which receives the MergedConfig output and can register it with @bernierllc/neverhub-adapter. Graceful degradation applies automatically — core functionality works regardless of NeverHub availability.
Integration flow
onboarding-agent-service
↓ registry.resolve(id) + registry.validatePluginConfig(id, pluginConfig[id])
↓ mergePluginContributions(baseConfig, enabledPlugins, currentState)
→ MergedConfig
.config → onboarding-config-core.compileSystemPrompt()
.tools → agent-tool-registry (registered by service layer)
.panels → onboarding-chat-ui (host panel mapping)Writing a plugin package
- Depend on
@bernierllc/onboarding-feature-plugin - Implement
OnboardingPlugin— give your plugin a stable, namespacedid(e.g.'my-org:feature-name') - Export the plugin object or a factory function
- The consuming service registers it:
registry.register(myPlugin)
License
Copyright (c) 2025 Bernier LLC. See LICENSE for details.
