.agents/skills/host-pattern/SKILL.md
Every feature area follows the same three-layer structure:
Plugins call methods on an API class (e.g. api.Queue.addToQueue()), which holds a reference to the host and delegates to it. Plugins never touch a host directly.
packages/plugin-sdk/)| Action | File | What |
|---|---|---|
| Create | src/types/yourDomain.ts | YourDomainHost interface + related types |
| Create | src/api/yourDomain.ts | YourDomainAPI class |
| Modify | src/api/index.ts | Add yourDomainHost option + YourDomain field to NuclearAPI |
| Modify | src/index.ts | Export types and API class |
packages/player/)| Action | File | What |
|---|---|---|
| Create | src/services/yourDomainHost.ts | Host implementation + singleton export |
| Modify | src/services/plugins/createPluginAPI.ts | Pass singleton to NuclearPluginAPI |
| Modify | src/services/logger.ts | Add domain to LOG_SCOPES (needed for reportError) |
If the domain needs shared model types: create packages/model/src/yourDomain.ts and re-export from packages/model/src/index.ts.
Every API class follows this structure.
// packages/plugin-sdk/src/api/yourDomain.ts
export class YourDomainAPI {
#host?: YourDomainHost;
constructor(host?: YourDomainHost) {
this.#host = host;
}
#withHost<T>(fn: (host: YourDomainHost) => T): T {
const host = this.#host;
if (!host) {
throw new Error('YourDomain host not available');
}
return fn(host);
}
yourMethod(arg: SomeType) {
return this.#withHost((host) => host.yourMethod(arg));
}
}
Reference: packages/plugin-sdk/src/api/queue.ts, packages/plugin-sdk/src/api/dashboard.ts
// packages/plugin-sdk/src/api/index.ts
import type { YourDomainHost } from '../types/yourDomain';
import { YourDomainAPI } from './yourDomain';
// Add to class:
readonly YourDomain: YourDomainAPI;
// Add to constructor opts type:
yourDomainHost?: YourDomainHost;
// Add to constructor body:
this.YourDomain = new YourDomainAPI(opts?.yourDomainHost);
// packages/player/src/services/plugins/createPluginAPI.ts
import { yourDomainHost } from '../../services/yourDomainHost';
// Add to NuclearPluginAPI constructor call:
yourDomainHost,
Hosts bridge the SDK contract to whatever backs the domain. Most commonly a Zustand store:
// packages/player/src/services/yourDomainHost.ts
import type { YourDomainHost } from '@nuclearplayer/plugin-sdk';
import { useYourDomainStore } from '../stores/yourDomainStore';
export const createYourDomainHost = (): YourDomainHost => ({
yourMethod: (arg) => useYourDomainStore.getState().doThing(arg),
getState: () => useYourDomainStore.getState().value,
});
export const yourDomainHost = createYourDomainHost();
A host can also have access to the provider registry (metadata, streaming, dashboard), resolving which registered provider to call. Two patterns:
Single provider (user picks one in Sources):
const getProvider = (providerId?: string) =>
providersHost.get<YourProvider>(
providerId ?? providersHost.getActive('yourkind'), 'yourkind',
);
export const createYourDomainHost = (): YourDomainHost => ({
async fetch(query, providerId?) {
const provider = getProvider(providerId);
if (!provider) throw new Error('No provider available');
return provider.fetch(query);
},
});
Fan-out (aggregate across all providers - dashboard):
export const createYourDomainHost = (): YourDomainHost => ({
async fetchAll() {
const providers = providersHost.list('yourkind') as YourProvider[];
const results = await Promise.allSettled(providers.map(async (provider) => {
const items = await provider.fetch();
return { providerId: provider.id, providerName: provider.name, items };
}));
return results.filter(isFulfilled).map((r) => r.value);
},
});
Reference: packages/player/src/services/metadataHost.ts (single), packages/player/src/services/dashboardHost.ts (fan-out)
reportError, return partial results.MissingCapabilityError: always a plugin bug. Single-provider domains throw it; fan-out domains log it and skip the offending provider.