Skip to content

Building Extensions

How to create a PF4J plugin extension for MetaOne Platform.

Overview

Extensions are PF4J plugin JARs that run inside the metaone-core host JVM. Each extension provides capabilities (API endpoints), consumes/produces events, and optionally mounts UI components.

Project Structure

my-extension/
├── pom.xml                          # PF4J plugin POM
└── src/main/java/com/example/
    ├── MyPlugin.java                # Plugin entry point
    ├── config/
    │   └── MyPluginConfig.java      # Spring beans + JPA config
    ├── entity/                      # JPA entities (optional)
    ├── repository/                  # Spring Data repos (optional)
    ├── service/                     # Business logic
    └── handler/
        └── MyCapabilityHandler.java # Capability handlers

Step 1: POM Configuration

All dependencies must be <scope>provided</scope> — they come from the host classloader.

xml
<project>
    <parent>
        <groupId>vn.metaone</groupId>
        <artifactId>metaone-platform</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>my-extension</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>vn.metaone</groupId>
            <artifactId>metaone-sdk</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.pf4j</groupId>
            <artifactId>pf4j</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.pf4j</groupId>
            <artifactId>pf4j-spring</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Plugin-Id>my-extension</Plugin-Id>
                            <Plugin-Class>com.example.MyPlugin</Plugin-Class>
                            <Plugin-Version>0.1.0</Plugin-Version>
                            <Plugin-Provider>YourOrg</Plugin-Provider>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Step 2: Plugin Entry Point

java
public class MyPlugin extends MetaonePlugin {
    public MyPlugin(PluginWrapper wrapper) {
        super(wrapper);
    }

    @Override
    protected Class<?>[] getPluginConfigClasses() {
        return new Class<?>[]{ MyPluginConfig.class };
    }
}

MetaonePlugin.createApplicationContext() sets the host's ApplicationContext as the parent context. This gives your plugin access to:

  • DataSource (host database)
  • EventBusGateway (publish events)
  • Environment (read host config properties)

Step 3: Plugin Configuration

java
@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.repository",
    entityManagerFactoryRef = "myEntityManagerFactory",
    transactionManagerRef = "myTransactionManager")
@EnableTransactionManagement
@ComponentScan(basePackages = {"com.example.service", "com.example.handler"})
public class MyPluginConfig {

    @Bean(name = "myEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean myEntityManagerFactory(
            DataSource dataSource,
            @Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) {
        var em = new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.entity");
        var adapter = new HibernateJpaVendorAdapter();
        adapter.setGenerateDdl(!"none".equals(ddlAuto));
        em.setJpaVendorAdapter(adapter);
        em.setJpaPropertyMap(Map.of("hibernate.hbm2ddl.auto", ddlAuto));
        return em;
    }

    @Bean(name = "myTransactionManager")
    public PlatformTransactionManager myTransactionManager(
            @Qualifier("myEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

Important: @ComponentScan must include BOTH the service AND handler packages. Handlers are not auto-discovered by PF4J if they're not in the component scan.

Step 4: Capability Handlers

java
@Extension
@CapabilityGroup(prefix = "my.domain")
public class MyHandler implements CapabilityHandler {

    private final MyService myService;

    public MyHandler(MyService myService) {
        this.myService = myService;
    }

    @Capability(key = "search")
    public CapabilityResult<List<MyDto>> search(Map<String, Object> request, CapabilityContext ctx) {
        String query = (String) request.getOrDefault("query", "");
        var results = myService.search(ctx.workspaceId(), query);
        return CapabilityResult.success(results);
    }

    @Capability(key = "create")
    public CapabilityResult<MyDto> create(Map<String, Object> request, CapabilityContext ctx) {
        try {
            var dto = myService.create(ctx.workspaceId(), request);
            return CapabilityResult.success(dto);
        } catch (ValidationException e) {
            return CapabilityResult.failure("VALIDATION_ERROR", e.getMessage());
        }
    }
}

Return type must be CapabilityResult<T>. The InProcessCapabilityGateway casts the return value to this type. Returning a raw Map will cause ClassCastException.

Capability keys are prefix-based: @CapabilityGroup(prefix = "my.domain") + @Capability(key = "search") = my.domain.search. Use singular domain names (my.domain, not my.domains).

Step 5: Event Handlers (Optional)

java
@Extension
public class MyEventHandler implements PlatformEventHandler {

    @EventHandler(eventKey = "platform.notification", async = true)
    public void onNotification(PlatformEvent event, CapabilityContext ctx) {
        String channel = (String) event.payload().get("channel");
        String message = (String) event.payload().get("message");
        // Handle the event
    }
}

async = true runs the handler on a dedicated thread pool. Use this for handlers that make HTTP calls or other I/O.

Step 6: Register the Manifest

This is the biggest footgun. Your capabilities will not route unless the manifest lists them.

In MarketplaceDataInitializer (or future marketplace), add your extension's manifest with every capability key, event subscription, and permission:

java
new CapabilityManifest(
    List.of(
        new CapabilityProvide("my.domain.search", "Search my domain"),
        new CapabilityProvide("my.domain.create", "Create in my domain")
    ),
    List.of("platform.notification"),     // consumesEvents
    List.of("my.domain.entity.created")   // producesEvents
)

Step 7: Configuration Manifest (Optional)

Extensions that need runtime configuration (API keys, URLs, feature flags) should declare a ConfigManifest in their extension manifest. This lets admins configure the extension per-workspace through the platform API, with no restart required.

Declaring Config Fields

java
new ConfigManifest(List.of(
    new ConfigFieldDef("my.api-url",    "API URL",       "Base URL of the external service",
                       FieldType.URL,    true,  "http://localhost:3000"),
    new ConfigFieldDef("my.api-key",    "API Key",       "Authentication key for the service",
                       FieldType.SECRET, true,  null),
    new ConfigFieldDef("my.max-items",  "Max Items",     "Maximum items per page",
                       FieldType.INTEGER,false, "50"),
    new ConfigFieldDef("my.debug-mode", "Debug Mode",    "Enable verbose logging",
                       FieldType.BOOLEAN,false, "false")
))

Field types: STRING, SECRET, URL, INTEGER, BOOLEAN. SECRET values are encrypted at rest (AES-256-GCM) and masked in API responses.

Required fields with no default trigger PENDING_CONFIG status on install. The extension won't be enabled until an admin provides values.

Reading Config at Runtime

Inject ExtensionConfigProvider from the host context. Config is resolved per-request using workspaceId from CapabilityContext, so changes take effect immediately.

java
@Extension
@CapabilityGroup(prefix = "my.domain")
public class MyHandler implements CapabilityHandler {

    private final ExtensionConfigProvider configProvider;
    private static final String EXT_KEY = "my-extension";

    @Capability(key = "search")
    public CapabilityResult<MyDto> search(Map<String, Object> request, CapabilityContext ctx) {
        var config = configProvider.getAll(ctx.workspaceId(), EXT_KEY);
        String apiUrl = config.getOrDefault("my.api-url", "http://localhost:3000");
        String apiKey = config.get("my.api-key");
        // Use config values to call external service
    }
}

Config Admin API

After installing an extension, admins configure it per-workspace:

bash
# Check config schema and current values
curl "http://localhost:8080/api/admin/workspaces/$WS/extensions/my-extension/config"

# Save config values
curl -X PUT "http://localhost:8080/api/admin/workspaces/$WS/extensions/my-extension/config" \
  -H "Content-Type: application/json" \
  -d '{"my.api-url":"https://api.example.com","my.api-key":"sk-abc123"}'

# Validate without saving
curl -X POST "http://localhost:8080/api/admin/workspaces/$WS/extensions/my-extension/config/validate"

Install Flow with Config

  1. Install → extension gets PENDING_CONFIG status (if required fields have no defaults)
  2. Admin saves config via PUT endpoint
  3. Admin enables extension → platform validates all required fields are set
  4. Extension is active and capabilities route normally

See chatwoot-extension for a real-world example with 4 config fields.

Step 8: Build and Deploy

bash
# Build the plugin JAR
mvn -pl my-extension package

# Copy to plugins directory
mkdir -p metaone-core/plugins/my-extension/0.1.0
cp my-extension/target/my-extension-*.jar metaone-core/plugins/my-extension/0.1.0/

# Restart metaone-core, then install via API
curl -X POST "http://localhost:8080/api/admin/workspaces/$WORKSPACE_ID/extensions/install" \
  -H "Content-Type: application/json" \
  -d '{"extensionKey":"my-extension","version":"0.1.0"}'

Reference Implementations

  • crm-extension — Full CRM plugin with products, opportunities, contacts. Has CrmPlugin, CrmJpaConfig, and multiple capability handlers. The canonical reference.
  • chatwoot-extension — SaaS adapter plugin wrapping Chatwoot's REST API. Shows webhook handling, external API client, event publishing, and widget integration.

Common Pitfalls

ProblemCauseFix
Capability returns 404Missing manifest entry in MarketplaceDataInitializerAdd CapabilityProvide to manifest
ClassCastException on capability callHandler returns Map instead of CapabilityResultChange return type to CapabilityResult<T>
Handler beans not createdHandler package not in @ComponentScanAdd handler package to scan list
Plugin fails to startMissing provided scope dependencyAdd dependency with <scope>provided</scope>
Events not receivedMissing consumesEvents in manifestAdd event key to manifest's consumesEvents
WorkspaceContext null in custom threadThreadLocal not propagatedUse platform's async executor or propagate manually
Extension stuck in PENDING_CONFIGRequired config fields with no defaultConfigure via admin API, then enable
Config values not updatingReading config at bean init instead of per-requestUse configProvider.getAll(ctx.workspaceId(), extKey) in handler methods

MetaOne Platform Documentation