Skip to content

Capabilities — the command layer

A capability is an action described once and then taken the same way from everywhere: the button in the surface, the AI and another plugin all invoke the same capability. It has a name, an input schema, a permission, a description and a handler.

That is the whole reason the layer exists: the AI tool descriptors are derived from the same registrations. What you register once is at the same time the tool description — nothing is authored twice.

Read this first, it saves you a detour. The seam described here — Plugin, register(host), registerCapability — is served today by the build-time load path. A package an operator uploads brings its backend as a module with routes; see “Backend and sidecar”. Both paths are described in the SDK, but only this one registers capabilities. If you are building an uploadable package, the capability layer is the reverse direction for you: you call host capabilities instead of offering your own.

A plugin is an object with a manifest and a register function:

import type { Plugin, PluginHost } from '@prodlog/sdk';
export const plugin: Plugin = {
manifest,
// Optional: one-off, idempotent schema setup. Runs ONLY for build-time plugins at
// instance start - an uploaded package never executes this, its tables come from
// the manifest description.
async migrate(context) {
await context.createTenantTable('acme_tcp_log', 'telegram text not null');
},
register(host: PluginHost) {
// register contributions and capabilities here
}
};
export default plugin;

register is called exactly once per process, at plugin load time, before the first request is served. That is your boot spot. A throw here aborts this plugin’s load and leaves the host and the other plugins running; a half-finished registration is rolled back so nothing orphaned stays invokable.

What host offers:

interface PluginHost {
readonly log: Logger;
registerContribution(contribution: Contribution): void;
registerCapability<Input, Output>(definition: CapabilityDefinition<Input, Output>): void;
}
import { z } from 'zod';
import type { PluginHost, InvocationContext } from '@prodlog/sdk';
const resetInput = z.object({ connectionId: z.string().min(1) });
export function registerCapabilities(host: PluginHost): void {
host.registerCapability({
name: 'acme.tcp.resetCounter',
description: 'Reset the telegram counter of one connection.',
permission: 'data:write',
rbacPermission: 'acme.tcp:configure',
input: resetInput,
handler: async (context: InvocationContext, input): Promise<{ ok: true }> => {
await context.data.query('UPDATE acme_tcp_log SET counter = 0 WHERE id = $1', [input.connectionId]);
await context.audit.record({ action: 'acme.tcp.resetCounter', target: input.connectionId });
return { ok: true };
}
});
}

The fields of CapabilityDefinition:

Field Meaning
name Must be your plugin id, or <id>.<verb>. A foreign name is refused, and so is a duplicate.
description One sentence. It becomes the tool description for the AI.
permission The platform permission from the closed enumeration — must be in the manifest.
rbacPermission Optional. The person’s right the host checks in addition. Absent means the capability is RBAC-free.
input A Zod schema. Invalid input throws before your handler runs.
handler (context, input) => Promise<Output>

Every call gets its own context, bound to one tenant:

interface InvocationContext {
readonly tenant: { readonly id: string };
readonly caller: CallerIdentity;
readonly log: Logger;
readonly data: DataApi; // query<T>(sql, params) - under the request's RLS context
readonly audit: AuditApi; // record({ action, target?, detail? })
readonly events: EventsApi; // publish(type, payload?) - through the mediated bus
}

data.query is the only way to data. No raw database handle is ever handed over; the query runs parameterised under the tenant session. A plugin also never emits to another plugin directly — events go through the mediated bus.

interface CallerIdentity {
readonly appUserId: string | null;
readonly roles: readonly string[];
readonly kind: 'user' | 'operator' | 'ai' | 'machine';
}

Three things matter here:

  • The host sets this from the session, never from the request body. A plugin cannot set, widen or spoof any field.
  • appUserId is the tenant-local app_user.id, deliberately not the identity provider’s sub. null means “no human behind this” or “could not be resolved” — never a fallback identity.
  • kind: 'machine' is a host-supervised process, such as a connector holding a socket. A handler that gates on a human must read it as “nobody is signed in” — never as a privileged internal caller.

roles are the coarse session roles (tenant-admin, user, display). The fine-grained check stays with the host through rbacPermission. Your own role check always sits on top, never instead.

When the tenant’s data is in a state that forbids the change — “this code is already taken”, “the last active entry may not be deactivated” — the server did its job. Throw a CapabilityConflictError for it:

import { CapabilityConflictError } from '@prodlog/sdk';
if (existing) {
throw new CapabilityConflictError('A connection with this name already exists.', 'duplicate_name');
}

The host maps it to HTTP 409, and the message is passed through to the caller — that is the point of a 409. So put a business sentence in it, never a database error and never an internal identifier. Anything else a handler throws stays a 500 with a closed text.

The surface invokes a capability through a host route:

POST /api/plugins/<pluginId>/capabilities/<name>

The body is the input. The host works in this order:

  1. Signed in? Otherwise 401.
  2. Plugin enabled for this tenant? Otherwise 404 plugin not enabled.
  3. RBAC right, if the capability declares one. Otherwise 403 permission denied.
  4. Platform permission of the caller against permission. Otherwise 403 as well.
  5. Input against your Zod schema. Otherwise 400 invalid input.
  6. Your handler.

Error mapping, so you do not have to guess while building:

Situation Status
Unknown capability name 404 unknown capability
Permission missing (platform or RBAC) 403 permission denied
Input does not fit the schema 400 invalid input
CapabilityConflictError 409 with your message and optional code
Anything else 500 internal error

plugins/tenant-user registers eleven capabilities and is the best template:

Name Permission RBAC right
tenant-user.list data:read tenant-user:read
tenant-user.get data:read tenant-user:read
tenant-user.listRoles data:read tenant-user:read
tenant-user.directoryMode data:read tenant-user:read
tenant-user.create data:write tenant-user:create
tenant-user.deactivate data:write tenant-user:deactivate
tenant-user.reactivate data:write tenant-user:deactivate
tenant-user.assignRole data:write tenant-user:assign-role
tenant-user.unassignRole data:write tenant-user:assign-role
tenant-user.setSecurityRole data:write tenant-user:assign-role
tenant-user.delete data:write tenant-user:delete

Two patterns there are worth copying: the input schemas live together in one file, and the registration is split into read, mutate, roles and delete parts — four small functions rather than one long register.

  • Only a build-time plugin registers capabilities today. The load path that calls register(host) runs at instance start for the plugins shipped with the product. An uploaded package takes the backend route path instead. A sidecar reaches the capability layer only in the reverse direction, through capability/invoke on the reverse channel.
  • A capability cannot subscribe to an event. There is no onEnable hook and no lifecycle callback beyond register and migrate.
  • An uploaded package does not execute migrate(). Its tables come exclusively from the manifest description.