Skip to content

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.

java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CapabilityGroup {
    String prefix() default "";
}

@Capability

Applied to individual handler methods.

java
@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:

CategoryDescription
QUERYRead-only data retrieval. Safe to cache.
COMMANDMutating operation. Has side effects.
EVENT_HANDLERTriggered 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.

java
public interface CapabilityHandler extends ExtensionPoint { }

CapabilityContext

Passed to every capability method as the second parameter. Contains request metadata and the calling principal.

java
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.

java
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

java
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.search
  • crm.deal.create
  • chat.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:

java
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.

java
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:

json
{
  "data": { ... },
  "errorCode": null,
  "message": null
}

MetaOne Platform Documentation