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.
Where your code starts
Section titled “Where your code starts”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;}Registering a capability
Section titled “Registering a capability”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> |
The invocation context
Section titled “The invocation context”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.
Who is calling — context.caller
Section titled “Who is calling — context.caller”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.
appUserIdis the tenant-localapp_user.id, deliberately not the identity provider’ssub.nullmeans “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.
A conflict is not a server fault
Section titled “A conflict is not a server fault”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.
How a call arrives
Section titled “How a call arrives”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:
- Signed in? Otherwise
401. - Plugin enabled for this tenant? Otherwise
404 plugin not enabled. - RBAC right, if the capability declares one. Otherwise
403 permission denied. - Platform permission of the caller against
permission. Otherwise403as well. - Input against your Zod schema. Otherwise
400 invalid input. - 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 |
A complete example in the repository
Section titled “A complete example in the repository”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.
What does not work today
Section titled “What does not work today”- 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, throughcapability/invokeon the reverse channel. - A capability cannot subscribe to an event. There is no
onEnablehook and no lifecycle callback beyondregisterandmigrate. - An uploaded package does not execute
migrate(). Its tables come exclusively from the manifest description.