Capability SDK
Capabilities are the primary way extensions expose functionality to the platform and other extensions. They are declared via annotations and automatically discovered at runtime.
Overview
A capability is identified by a dot-separated key built from two annotations:
@CapabilityGroup(prefix = "crm.product")on the class@Capability(key = "search")on the method
→ Combined key: crm.product.search
Annotations
@CapabilityGroup
Applied to a class that implements CapabilityHandler. Sets the key prefix for all capabilities in that class.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CapabilityGroup {
String prefix() default "";
}@Capability
Applied to individual handler methods.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Capability {
String key(); // Appended to the group prefix
String description() default ""; // Human-readable description
boolean idempotent() default false; // Safe to retry?
CapabilityCategory category() default QUERY; // QUERY | COMMAND | EVENT_HANDLER
}Categories:
| Category | Description |
|---|---|
QUERY | Read-only data retrieval. Safe to cache. |
COMMAND | Mutating operation. Has side effects. |
EVENT_HANDLER | Triggered by a platform event (internal use). |
CapabilityHandler
The base interface all handler classes must implement. It extends PF4J's ExtensionPoint — annotate your class with @Extension to register it as a PF4J extension.
public interface CapabilityHandler extends ExtensionPoint { }CapabilityContext
Passed to every capability method as the second parameter. Contains request metadata and the calling principal.
public record CapabilityContext(
String workspaceId, // Current tenant workspace
String tenantId, // Same as workspaceId (legacy alias)
String requestId, // Unique ID for this request
String traceId, // Distributed trace ID
Principal principal, // Caller info
Locale locale, // User locale
Instant deadline, // Request timeout deadline
Map<String, String> headers // Incoming HTTP headers
) {
public Optional<Long> remainingMillis() // How long until deadline
record Principal(
String actorId, // User or system ID
String actorType, // "user", "system", etc.
Set<String> roles // Granted roles
) {}
}CapabilityResult<T>
The return type for capability methods. A sealed interface with Success and Failure variants.
public sealed interface CapabilityResult<T> {
record Success<T>(T data, Map<String, String> metadata) {}
record Failure<T>(String errorCode, String message, List<FieldError> fieldErrors) {}
record FieldError(String field, String message) {}
static <T> CapabilityResult<T> success(T data)
static <T> CapabilityResult<T> failure(String errorCode, String message)
}Writing a Capability Handler
import org.pf4j.Extension;
import vn.metaone.sdk.capability.*;
@Extension // PF4J extension registration
@CapabilityGroup(prefix = "crm.product")
public class ProductCapabilityHandler implements CapabilityHandler {
private final ProductService productService;
public ProductCapabilityHandler(ProductService productService) {
this.productService = productService;
}
@Capability(key = "search", description = "Search products", category = CapabilityCategory.QUERY)
public CapabilityResult<List<ProductDTO>> search(SearchRequest request, CapabilityContext ctx) {
try {
List<ProductDTO> results = productService.search(
ctx.workspaceId(),
request.query(),
request.limit()
);
return CapabilityResult.success(results);
} catch (Exception e) {
return CapabilityResult.failure("SEARCH_ERROR", e.getMessage());
}
}
@Capability(key = "create", description = "Create a product", category = CapabilityCategory.COMMAND, idempotent = false)
public CapabilityResult<ProductDTO> create(CreateProductRequest request, CapabilityContext ctx) {
ProductDTO created = productService.create(ctx.workspaceId(), request);
return CapabilityResult.success(created);
}
}Capability Key Convention
Use singular domain names, dot-separated:
{domain}.{resource}.{action}✅ Good:
crm.product.searchcrm.deal.createchat.message.send
❌ Avoid:
crm.products.search(plural)crm_product_search(underscores)search(no namespace)
Registering in the Manifest
Every capability exposed by the handler must be declared in the CapabilityManifest.provides list:
new CapabilityManifest(
List.of(
new CapabilityProvide("crm.product.search", "workspace"),
new CapabilityProvide("crm.product.create", "workspace")
),
List.of(), // consumesEvents
List.of() // publishesEvents
)Both sides must match. The platform registers capabilities from the manifest during install. If a key is in the handler but not the manifest, it will never be routable.
Throwing Errors
Throw CapabilityException for expected domain errors. The platform catches it and returns a structured error response.
import vn.metaone.sdk.capability.CapabilityException;
throw new CapabilityException("PRODUCT_NOT_FOUND", "Product with ID " + id + " not found");
// With field-level errors:
throw new CapabilityException("VALIDATION_ERROR", "Invalid input", List.of(
new CapabilityResult.FieldError("price", "Must be greater than 0")
));External Service Capabilities
For EXTERNAL_SERVICE extensions, the platform sends capabilities as HTTP POST requests:
POST {serviceBaseUrl}/capabilities/{capabilityKey}
Content-Type: application/json
{ ...request body... }Your service must respond with:
{
"data": { ... },
"errorCode": null,
"message": null
}