Plugin Capability Services

Status: implementation-ready design

Summary

AppWeaver plugins should be able to provide versioned services to other plugins without importing each other or depending on local command aliases. Core acts as the orchestrator: it registers installed providers, resolves providers for consumers, validates capability-operation calls, opens provider selection when necessary, and uses the plugin catalog when no compatible provider is installed.

The design has three layers:

  1. Public declaration: Kind 32107 plugin events advertise provided, used, and required capabilities for discovery.
  2. Runtime registration: Installed plugins register executable implementations of capability operations.
  3. Core orchestration: Consumers invoke versioned operations through core instead of calling provider plugins directly.

Catalog metadata is for discovery. Runtime registration is the authority for execution.

Goals

Non-Goals

Terminology

Capability

A named, versioned service contract such as scheduler:v1 or translation:v1.

A capability can expose multiple operations. Every operation belongs to a versioned capability contract and has a canonical invocation ID. For example, scheduler:v1 may expose:

The complete operation set and schemas are defined in one core capability contract file.

Provider

An installed plugin implementation of one capability version. A single plugin may provide multiple capabilities and multiple major versions of the same capability.

Consumer

A plugin that uses or requires a capability. The consumer invokes versioned capability operations through core and does not import the provider.

Capability Operation

One callable service inside a capability contract. Its canonical ID is:

capability:v<major>:<capability-name>.<operation-name>

Examples:

capability:v1:scheduler.create
capability:v1:scheduler.show
capability:v2:scheduler.show
capability:v1:translation.translate

The version is always present in the operation ID. It identifies the operation schema and behavior from that major of the capability contract. capability:v1:scheduler.show and capability:v2:scheduler.show are separate operations even though both use the short name show.

In the initial design, the version in an operation ID is the capability-contract major exported by files such as scheduler.v1.ts. If show needs a breaking change, scheduler.v2.ts defines capability:v2:scheduler.show. A provider can continue registering capability:v1:scheduler.show alongside it, and is expected to do so while the old behavior remains supportable. Unchanged operations may also be exposed under both contract majors when both complete contracts are supported.

type CapabilityOperationRef = {
  capability: CapabilityRef;
  operation: string;
};

type CapabilityOperationId =
  `capability:v${number}:${string}.${string}`;

Capability Resource

An entity owned by a provider and returned by a capability operation, such as a scheduled job. Resource identifiers are provider-scoped, not globally meaningful by themselves.

type CapabilityResourceRef = {
  capability: CapabilityRef;
  providerId: string;
  resourceType: string;
  resourceId: string;
};

A consumer may store this reference and later pass it to another operation of the same provider. For example, NR can store the schedule reference returned by capability:v1:scheduler.create, then invoke capability:v1:scheduler.show with the same provider and resource ID.

Capability Identifiers And Versions

The canonical text form is:

<name>:v<major>

Examples:

scheduler:v1
translation:v1

Runtime structures keep the name and version separate:

type CapabilityRef = {
  name: string;
  version: number;
};

Version matching is exact by major. A provider of scheduler:v2 does not automatically satisfy scheduler:v1.

Providers are expected to continue registering older major versions when they can still honor those contracts. A new implementation may therefore register all of these simultaneously:

scheduler:v1
scheduler:v2
translation:v1

Each registered major uses its own contract file and handlers. A provider must not silently route a v1 request through v2 unless the v1 contract is still fully honored.

Published metadata must list every major currently supported by that release. Repeated capability names with different versions are valid. Repeated exact declarations should be accepted by parsers and deduplicated when displayed or republished.

Capability Relations

Plugin metadata uses three explicit relations:

In the initial implementation, a missing requirement does not prevent plugin code from loading. Core logs missing requires declarations during registration and logs failed invocations. The consumer or executor remains responsible for deciding whether to hide functionality, show installation UI, degrade gracefully, retry, or surface an error.

Missing uses declarations may be logged at debug or informational level but are not errors.

Public Plugin Metadata

package.json

Plugins declare relations under appweaver.capabilities:

{
  "name": "appweaver-example-plugin",
  "version": "2.0.0",
  "appweaver": {
    "title": "Example",
    "coreApiVersion": "^11.0.0",
    "description": "Example AppWeaver plugin.",
    "capabilities": {
      "provides": [
        { "name": "scheduler", "version": 1 },
        { "name": "scheduler", "version": 2 }
      ],
      "uses": [
        { "name": "translation", "version": 1 }
      ],
      "requires": [
        { "name": "notification.send", "version": 1 }
      ]
    }
  }
}

All three values are arrays. The same capability name may appear more than once when different major versions are supported.

Kind 32107 Tags

Published plugin events use NIP-32 labels under the com.getappweaver.capability namespace:

[
  ["L", "com.getappweaver.capability"],
  ["l", "com.getappweaver.capability:provides:scheduler:v1", "com.getappweaver.capability"],
  ["l", "com.getappweaver.capability:provides:scheduler:v2", "com.getappweaver.capability"],
  ["l", "com.getappweaver.capability:uses:translation:v1", "com.getappweaver.capability"],
  ["l", "com.getappweaver.capability:requires:notification.send:v1", "com.getappweaver.capability"]
]

The indexed label value contains:

Capability labels are parsed only from kind 32107 events that declare the matching L namespace. Legacy p, u, and r capability tags are not supported.

Multiple l tags are valid. Events may contain:

The fully qualified label value avoids collisions when relays index only the first tag value.

Plugin Manager Filters

The canonical search expression remains relation-neutral for normal provider discovery:

capability:scheduler:v1

By default this searches kind 32107 events with the indexed label com.getappweaver.capability:provides:scheduler:v1.

The plugin manager may also support explicit relation filters:

provides:scheduler:v1
uses:scheduler:v1
requires:scheduler:v1

This lets users and developers see the ecosystem around a capability, not only providers.

Search results must still pass normal publisher, repository, release, signature, and core API compatibility checks. A catalog declaration never makes a plugin executable. After installation and restart, runtime registration must provide the corresponding operation implementation.

Release management compares local package capability declarations with the latest catalog event. A plugin whose version and refs are already published but whose capability labels are missing or stale is shown as metadata publish needed and may republish the same version with corrected labels.

Core Capability Contracts

Capability contracts live in a dedicated, easy-to-discover core directory:

src/capabilities/
  types.ts
  scheduler.v1.ts
  scheduler.v2.ts
  translation.v1.ts

Each contract file begins with a comment documenting when it became available:

/**
 * Capability: scheduler:v1
 * Added in AppWeaver core: 11.2.0
 *
 * Plugin authors that import or declare this capability must set
 * appweaver.coreApiVersion to a range including ^11.2.0 or newer.
 */

This gives plugin authors a direct answer to two questions:

  1. Which operations and schemas does this capability contain?
  2. Which minimum AppWeaver core version can be declared safely?

Contracts contain only shared types, schemas, operation names, canonical operation IDs, and behavior documentation. They do not import provider plugins or contain provider-specific logic.

Multi-Operation Contract Shape

type CapabilityOperationDefinition<TInput, TOutput> = {
  id: CapabilityOperationId;
  inputSchema: z.ZodType<TInput>;
  outputSchema: z.ZodType<TOutput>;
};

type CapabilityContract<TOperations extends CapabilityOperationMap> = {
  capability: CapabilityRef;
  addedInCoreVersion: string;
  operations: TOperations;
};

A scheduler contract can export several operations:

export const SchedulerV1 = defineCapability({
  capability: { name: 'scheduler', version: 1 },
  addedInCoreVersion: '11.2.0',
  operations: {
    create: {
      id: 'capability:v1:scheduler.create',
      inputSchema: SchedulerCreateInputSchema,
      outputSchema: SchedulerCreateOutputSchema,
    },
    list: {
      id: 'capability:v1:scheduler.list',
      inputSchema: SchedulerListInputSchema,
      outputSchema: SchedulerListOutputSchema,
    },
    show: {
      id: 'capability:v1:scheduler.show',
      inputSchema: SchedulerShowInputSchema,
      outputSchema: SchedulerShowOutputSchema,
    },
  },
});

Operation IDs are stable and versioned. Adding an optional operation may be backward compatible if existing providers are not required to implement it. Adding a required operation to an existing major is breaking unless the contract has an explicit optional-operation mechanism.

For v1, capability files should distinguish required and optional operations explicitly rather than assuming every later addition is mandatory.

Runtime Provider Registration

Plugin Contract

Providers are declared on BotPlugin so core binds them to the actual plugin identity:

type BotPlugin = {
  identity: PluginIdentity;
  onInit: (ctx: PluginContext) => void;
  handler: PluginHandler;
  helpText: PluginHelpText;
  commandDefinition: PluginCommandDefinition;
  capabilityProviders?: CapabilityProviderDefinition[];
};

Core calls onInit, then validates and registers the plugin's providers. Provider handlers can use plugin module state initialized by onInit, including the plugin database.

This is preferred over an unrestricted shared ctx.registerCapability(...) method because core can authoritatively attach:

Provider Definition

type CapabilityProviderDefinition = {
  contract: CapabilityContract;
  operations: Record<string, CapabilityOperationHandler>;
};

Provider operation maps use the canonical IDs exported by the corresponding core contract, such as [SchedulerV1.operations.show.id]. Core validates that all required operations are implemented and that no unknown operations are registered. Registration, invocation, and logging therefore use the same canonical ID capability:v1:scheduler.show.

One plugin may register at most one provider for each capability major. Internal implementation choices such as model, API service, account, or endpoint belong in provider settings or in contract-defined operation input. They do not create additional provider registrations.

Core derives the stable provider ID:

<plugin-package-name>/<capability-name>/v<major>

For example:

appweaver-job-plugin/scheduler/v1
appweaver-translation-plugin/translation/v1

Provider IDs do not depend only on local install aliases because aliases are user-selected and may differ between installations.

Registered Provider Source

Core attaches source metadata while registering the provider. Plugins do not submit or override their own source identity:

type RegisteredCapabilityProvider = {
  contract: CapabilityContract;
  providerId: string;
  source: CapabilityProviderSource;
  operations: Record<CapabilityOperationId, CapabilityOperationHandler>;
};

type CapabilityProviderSource = {
  type: 'plugin';
  pluginName: string;
  alias: string;
  version: string;
  title: string;
  description: string | null;
  iconUrl: string | null;
};

The source comes from the registered plugin identity and installed package metadata. Core already knows the local alias and can resolve the plugin icon route. This prevents a provider from impersonating another plugin or publishing stale display metadata at runtime.

providerId is used for consumer-owned preferences and persisted resource references. source.alias, source.title, and source.iconUrl are used for display and local navigation.

Core Capability Registry

The registry is generic and contains no provider-specific behavior.

Recommended modules:

src/core/capabilities/
  registry.ts
  errors.ts
  selection.ts

Shared public contracts remain in src/capabilities/; runtime orchestration lives under src/core/capabilities/.

Core registry interface:

interface CapabilityRegistry {
  listProviders(capability: CapabilityRef): CapabilityProviderSummary[];
  getProvider(providerId: string): CapabilityProviderSummary | null;
  invoke<TInput, TOutput>(request: InvokeCapabilityOperationRequest<TInput>): Promise<TOutput>;
}

type InvokeCapabilityOperationRequest<TInput> = {
  operation: CapabilityOperationId;
  providerId: string;
  input: TInput;
  initiatedBy: CapabilityCaller;
};

Core parses the canonical operation ID to resolve its capability name, major version, and short operation name. The registry must:

  1. Resolve the exact versioned capability operation.
  2. Validate input using the core contract.
  3. Record consumer and provider identity for logs.
  4. Invoke the provider operation.
  5. Validate output using the core contract.
  6. Return typed failures without leaking secrets.

Core orchestrates registration, discovery, provider selection, operation routing, validation, and result delivery. The provider remains responsible for executing and applying its work after registration.

Suggested errors:

Core logs missing requirements at plugin registration and logs invocation failures. The invoking consumer or executing provider determines user-visible handling.

Provider Resolution And User Choice

Provider selection is a core concern so every consumer behaves consistently.

Resolution order:

  1. An explicit provider ID, especially when accessing an existing provider-owned resource.
  2. The only compatible installed provider.
  3. A chooser when multiple compatible providers are installed.
  4. A missing-provider installation prompt when none are installed.

Core does not persist provider defaults. A consumer that wants a stable preference stores the chosen provider ID in its own state and passes it explicitly on later calls. This allows two consumers of the same capability contract to choose different providers. A consumer may instead request selection each time.

The chooser supports:

Each chooser entry displays source metadata supplied by core:

If two installed plugins provide translation:v1, the chooser presents those two plugin sources. A consumer that wants to remember the choice persists the selected provider ID itself. Internal engines exposed by one translation plugin remain that plugin's responsibility and do not create additional core provider entries.

Selecting the only provider means routing to that provider. It does not bypass provider confirmation, review, or draft behavior.

When a consumer stores a CapabilityResourceRef, later operations for that resource always use its stored providerId. Core must not route show, update, or delete for an existing resource to a different provider.

Generic Web Capability Action

WebNode gains one reusable capability action:

type WebCapabilityAction = {
  type: 'capability';
  operation: CapabilityOperationId;
  input: Record<string, unknown>;
  providerId?: string;
  selection: 'auto' | 'always-choose';
  missingProvider: {
    mode: 'offer-install';
  };
  surface?: 'timeline' | 'modal';
  modalTitle?: string;
};

Core handles the action:

Example missing-provider UI:

This action requires an installed scheduler:v1 service.

[Find compatible apps]

The button opens plugin manager discovery with:

capability:scheduler:v1

The initial implementation can use the existing generic client action mechanism to open the plugin manager with a filter. A later version may add a dedicated generic WebAction.

Example: Scheduler Capability V1

This section is an example of the generic capability system, not a core goal or special case.

Responsibility Boundary

The consumer invokes scheduler operations to create and inspect schedules. After creation, the scheduler provider owns the schedule and is responsible for invoking and applying the registered work when it becomes due.

For the current Job implementation, scheduled work may remain an AI prompt. No new operation registry is required. NR can register a prompt that instructs the scheduled agent to use NR's existing generated CLI tool:

Run `bun src/cli.ts nr fetch_evaluate '{}'` to fetch and evaluate the user's Nostr posts.

The existing path remains authoritative:

This avoids inventing fetch-evaluate:v1 as a second operation system in this project.

Scheduler Operations

An initial scheduler:v1 contract can expose:

Later versions or optional operations may add:

Create Request

type SchedulerCreateInput = {
  name: string;
  schedule: SchedulerSchedule;
  task: {
    type: 'agent-prompt';
    prompt: string;
  };
  enabled: boolean;
};

type SchedulerSchedule =
  | {
      type: 'cron';
      expression: string;
      timezone: string;
      maxRuns: number | null;
    }
  | {
      type: 'one-time';
      runAt: string;
    };

The scheduler contract owns the supported task variants. agent-prompt is sufficient for the first Job integration. A future scheduler contract may add other task types without requiring a generic core operation registry.

The previous draft's reference to JavaScript callbacks and command aliases meant avoiding persisted in-memory function references or local strings such as /nr fetch-latest, which are not portable across restarts or alias changes. That restriction is removed as a top-level design concern. The scheduler contract now explicitly defines its accepted task payloads, and agent-prompt uses the existing stable generated CLI tool path in this example.

Create Result

type SchedulerCreateOutput = {
  resource: CapabilityResourceRef;
  status: 'draft' | 'created';
  review: WebNodeRoot | null;
};

Job may return a draft or provider-owned review form before creating the schedule. The resource reference is returned once an addressable provider resource exists.

Show Request And Result

type SchedulerShowInput = {
  resourceId: string;
};

type SchedulerShowOutput = {
  resource: CapabilityResourceRef;
  name: string;
  enabled: boolean;
  scheduleDescription: string;
  nextRunAt: number | null;
  view: WebNodeRoot | null;
};

NR stores the returned resource reference in its own settings or database. It can then display:

Hourly fetch is scheduled.

[Show scheduled job]

The button invokes capability:v1:scheduler.show with the stored provider ID and resource ID. Core routes the call back to Job. Job may return its list or detail UI with that task highlighted or expanded.

NR Scheduling Flow

  1. The user clicks Schedule fetch and evaluate in NR.
  2. NR invokes capability:v1:scheduler.create through core.
  3. If no provider is installed, core shows Find compatible apps.
  4. The plugin manager opens with capability:scheduler:v1.
  5. The catalog finds kind 32107 events with the indexed scheduler provider label, including Job.
  6. The user reviews and installs Job through the existing installation flow.
  7. Installation regenerates plugin registration and restarts AppWeaver as required.
  8. The user clicks the NR action again.
  9. Core resolves Job as the only or default scheduler provider.
  10. NR sends schedule defaults and the CLI-oriented agent prompt to capability:v1:scheduler.create.
  11. Job displays its schedule review form or draft.
  12. After user confirmation, Job creates the cron job and returns its provider-scoped resource reference.
  13. NR stores the reference and shows a Show scheduled job action.
  14. When due, Job executes and applies the registered task using its own runner.
  15. Clicking Show scheduled job invokes Job's capability:v1:scheduler.show operation through core.

The initial implementation does not need to resume the pre-install action automatically. A future continuation token may preserve and replay the pending capability request after installation, but only after explicit user confirmation.

Example: Translation Capability V1

This section is another example of the same generic mechanism.

translation:v1 initially exposes:

Language listing or provider-status operations can be added later if consumers need them.

Example translation input:

type TranslationInputV1 = {
  content: string;
  format: 'plain-text' | 'markdown';
  sourceLanguage: string | null; // null means automatic detection
  targetLanguage: string;
  context: string | null;
};

type TranslationOutputV1 = {
  content: string;
  sourceLanguage: string | null;
  targetLanguage: string;
};

Language values use BCP 47 tags such as en, de, or pt-BR. The optional context is provider-neutral supporting information and is not itself translated.

Possible provider plugins include AppWeaver AI Translate, a DeepL integration, and a LibreTranslate integration. With one provider plugin, core routes directly to it. With several provider plugins, core opens the generic icon-aware chooser unless the consumer passes its own stored provider ID. A single translation plugin may also support several engines internally through its own settings.

The translation provider owns external service credentials, model settings, caching, billing, and execution. AI Translate stores optional backend and model overrides and otherwise uses current AppWeaver defaults. AI-specific settings do not appear in the generic capability input. NR owns source-content retrieval, language defaults, provider preference, and presentation of original versus translated content.

Example: Monitoring Capability V1

monitoring:v1 exposes capability:v1:monitoring.record. The input is a bounded batch of completed spans containing trace and parent span IDs, source, wall-clock start, monotonic duration, status, and scalar attributes.

Monitoring differs from interactive request/response capabilities:

The initial Performance Monitor provider stores spans in its own SQLite database and renders recent traces as expandable waterfalls. NR's per-post Read action is the first end-to-end example, covering its mark query, list refresh query, hydration, profile lookup, WebNode build, browser state update, and paint.

Security And Trust

Catalog Trust

Runtime Trust

User Intent

Current Process Boundary

The registry provides contract isolation, not process isolation. Installed plugins can currently import core modules and execute in the same Bun process. Capability validation reduces accidental coupling but does not sandbox malicious code.

Persistence And Provider Resources

Providers persist their own resources. Consumers persist provider preferences and CapabilityResourceRef values when they need to reconnect to provider-owned resources. Core may record invocation audit metadata.

Consumers must store the complete reference, not only resourceId, because two providers may both return 42.

When a provider is upgraded:

When a provider is uninstalled:

Observability And Error Handling

Core logs:

Inputs and outputs are not logged by default because prompts, translated content, credentials, and personal data may be sensitive.

After core routes a valid call, execution errors belong to the provider operation implementation. The provider decides how to record partial work, retries, and provider-specific diagnostics. The consumer decides how failures affect its own UI or workflow.

Proposed Implementation Phases

Phase 1: Contracts And Runtime Registry

Phase 2: Published Relations And Discovery

Phase 3: Generic Web Routing

Phase 4: Scheduler Example

Phase 5: Translation Example

Acceptance Criteria For The Scheduler Example

Open Questions

  1. Should provider operation outputs be domain JSON only, or may contracts explicitly include WebNodeRoot provider views?
  2. Which scheduler operations are required in v1 beyond create, list, and show?
  3. Should installing a provider preserve a pending capability request for continuation after restart?
  4. What generic callback or continuation mechanism should consumers use when they want a WebAction chooser result persisted automatically?
  5. Should missing requires declarations remain log-only, or should plugins be able to mark individual actions disabled through a generic status operation?
  6. How should plugin manager rank multiple providers beyond compatibility, publisher identity, and installed status?