Extending Axiom
Axiom is designed to be extended at several key points: AI engines, actors, event sources, and notification channels. All extension points follow the same pattern — define an interface in an SPI module, implement it in a sibling module, and let CDI discover it at runtime.
The SPI Pattern
Every extension point in Axiom follows this structure:
Implementations are @ApplicationScoped CDI beans. A registry or CDI Instance<T>
discovers all implementations at startup. The active implementation is selected by
configuration.
Adding an AI Engine
An AI engine provides the ability to invoke an LLM for manager evaluations and task execution. Axiom ships with two engines: Claude Code and OpenCode.
Interface: AiEngine
Location: engine/spi/src/main/java/io/apitomy/axiom/engine/spi/AiEngine.java
| Method | Purpose |
|---|---|
String getType() |
Engine identifier (e.g. "claude-code", "opencode") |
CompletableFuture<AiEngineResult> prompt(AiEngineConfig config, String prompt) |
Invoke the AI with a text prompt |
CompletableFuture<AiEngineResult> promptWithSchema(AiEngineConfig config, String prompt, String jsonSchema) |
Invoke with structured output (JSON schema constraint) |
List<AiEngineCheckResult> healthCheck() |
Startup health checks |
Registration: AiEngineProvider
Rather than registering AiEngine directly as a CDI bean (which can cause type
conflicts), engines implement AiEngineProvider:
@ApplicationScoped
@Typed({MyEngine.class, AiEngineProvider.class})
public class MyEngine implements AiEngine, AiEngineProvider {
@Override
public String getType() {
return "my-engine";
}
@Override
public AiEngine getEngine() {
return this;
}
// ... implement prompt(), promptWithSchema(), healthCheck()
}
The @Typed annotation limits CDI visibility to avoid conflicts with other engine
implementations.
Discovery
AiEngineRegistry discovers all AiEngineProvider beans via CDI
Instance<AiEngineProvider>. The active engine is selected by the
axiom.ai-engine configuration property (default: claude-code).
Configuration Objects
AiEngineConfig— builder-based configuration passed to every engine call: model, system prompt, allowed tools, working directory, environment variables, timeout, max steps, budget, MCP config fileAiEngineResult— returned from engine calls: result text, session ID, cost, tokens, success flag, execution log
MCP Support (Optional)
If your engine supports MCP tool servers, implement AiEngineMcpManager:
| Method | Purpose |
|---|---|
Path configureMcpServers(Long taskId, Map<String, String> environment, List<String> allowedTools) |
Generate MCP config file; return path or null |
void cleanup(Long taskId) |
Clean up after task completion |
Register it via AiEngineProvider.getMcpManager().
Reference Implementations
- Claude Code:
actors/claude-code/src/main/java/.../ClaudeCodeEngine.java— subprocess-based, launchesclaudeCLI - OpenCode:
engine/opencode/src/main/java/.../OpenCodeEngine.java— HTTP client against a running OpenCode server
Adding an Actor
An actor executes tasks — typically by invoking an AI engine, but it can also send notifications or perform any other work.
Interface: Actor
Location: actors/spi/src/main/java/io/apitomy/axiom/actors/spi/Actor.java
| Method | Purpose |
|---|---|
String getType() |
Actor identifier (e.g. "claude-code", "human") |
CompletableFuture<TaskResult> execute(TaskEntity task, ActorContext context) |
Execute a task asynchronously |
void cancel(TaskEntity task) |
Cancel a running task |
Context and Results
ActorContext — provided to the actor for each task:
workingDirectory— git clone of the project's repositoryallowedTools/disallowedTools— tool access controlsystemPrompt— system-level instructionspromptTemplate— the action type's prompt (with placeholders substituted)mcpConfigFile— path to MCP configurationenvironment— environment variables (including decrypted secrets)model— model override (if set on the action type)
TaskResult — returned after execution:
success— whether the task completed successfullyoutput— result texterrorMessage— failure reason (if failed)costUsd/inputTokens/outputTokens— usage trackingexecutionLog— full transcript for debuggingsessionId— for engine session resumption
Discovery
TaskExecutionService discovers actors via CDI Instance<Actor>. The actor type
is resolved from the engine type via AiEngineRegistry.getActorType().
Implementation Example
@ApplicationScoped
public class MyActor implements Actor {
@Override
public String getType() {
return "my-engine";
}
@Override
public CompletableFuture<TaskResult> execute(TaskEntity task, ActorContext context) {
return CompletableFuture.supplyAsync(() -> {
// Use context.promptTemplate(), context.workingDirectory(), etc.
// Invoke your engine or perform work
return TaskResult.success("Task completed successfully")
.costUsd(0.05)
.inputTokens(1000L)
.outputTokens(500L)
.executionLog("Full transcript here")
.build();
});
}
@Override
public void cancel(TaskEntity task) {
// Terminate the running process/session
}
}
Reference Implementations
- Claude Code:
actors/claude-code/src/main/java/.../ClaudeCodeActor.java— launchesclaudeCLI as a subprocess - OpenCode:
engine/opencode/src/main/java/.../OpenCodeActor.java— invokes OpenCode server via HTTP - Human:
actors/human/— sends notifications, waits for human response
Adding an Event Source
An event source polls an external system for activity and creates normalized events.
Pattern
Event sources are @Scheduled pollers that use EventService to ingest events.
There is no formal SPI interface — the pattern is convention-based.
Key Service: EventService
Location: events/core/src/main/java/io/apitomy/axiom/events/core/EventService.java
| Method | Purpose |
|---|---|
ingestEvent(Long eventSourceId, String source, String eventType, String issueRef, String repository, String payload) |
Create an event and enqueue it for processing |
recordPollLog(Long eventSourceId, String status, String message, String detail, int eventsIngested) |
Log the result of a poll cycle |
Implementation Pattern
- Create a scheduled poller with
@ScheduledandconcurrentExecution = SKIP - Find enabled event sources of your type from
EventSourceEntity - Check if the poll interval has elapsed since
lastPolledAt - Fetch activity from the external API
- Normalize events and call
eventService.ingestEvent()for each - Update
lastPolledAton the event source - Call
eventService.recordPollLog()with the result
Event Type Conventions
Events use a normalized type string. Existing conventions:
| Event Type | Meaning |
|---|---|
issue-created |
A new issue was opened |
issue-updated |
An issue was modified |
issue-closed |
An issue was closed |
issue-reopened |
A closed issue was reopened |
comment-added |
A comment was added to an issue |
pr-opened |
A pull request was opened |
pr-merged |
A pull request was merged |
pr-closed |
A pull request was closed without merging |
review-comment-added |
A review comment was added to a PR |
Reference Implementations
- GitHub:
events/github/src/main/java/.../GitHubPoller.java— polls GitHub REST API for issues, comments, PRs, and review comments - Jira:
events/jira/src/main/java/.../JiraPoller.java— polls Jira REST API using JQL search
Adding a Notification Channel
Notification channels deliver alerts to humans (e.g. when a task is assigned to a human actor).
Current State
The notification SPI (notifications/spi/) is currently a stub — no formal interface
is defined yet. Slack and Telegram implementations exist but follow their own patterns.
Reference Implementations
- Slack:
notifications/slack/ - Telegram:
notifications/telegram/
Module Setup
When adding a new extension module:
-
Create the Maven module directory under the appropriate parent (e.g.
engine/my-engine/orevents/my-source/) -
Create
pom.xmlwith the parent reference and dependencies: -
Add to root
pom.xmlmodules list: -
Add Jandex plugin to your module's
pom.xml— required for CDI bean discovery in Quarkus: -
Add as dependency in
app/pom.xmlso the app module includes your implementation -
Add to
dependencyManagementin the rootpom.xmlfor version consistency