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 handlersStep 1: POM Configuration
All dependencies must be <scope>provided</scope> — they come from the host classloader.
<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
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
@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
@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)
@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:
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
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.
@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:
# 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
- Install → extension gets
PENDING_CONFIGstatus (if required fields have no defaults) - Admin saves config via PUT endpoint
- Admin enables extension → platform validates all required fields are set
- Extension is active and capabilities route normally
See chatwoot-extension for a real-world example with 4 config fields.
Step 8: Build and Deploy
# 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
| Problem | Cause | Fix |
|---|---|---|
| Capability returns 404 | Missing manifest entry in MarketplaceDataInitializer | Add CapabilityProvide to manifest |
ClassCastException on capability call | Handler returns Map instead of CapabilityResult | Change return type to CapabilityResult<T> |
| Handler beans not created | Handler package not in @ComponentScan | Add handler package to scan list |
| Plugin fails to start | Missing provided scope dependency | Add dependency with <scope>provided</scope> |
| Events not received | Missing consumesEvents in manifest | Add event key to manifest's consumesEvents |
WorkspaceContext null in custom thread | ThreadLocal not propagated | Use platform's async executor or propagate manually |
Extension stuck in PENDING_CONFIG | Required config fields with no default | Configure via admin API, then enable |
| Config values not updating | Reading config at bean init instead of per-request | Use configProvider.getAll(ctx.workspaceId(), extKey) in handler methods |