This guide explains how to add new behavior to Joanium while staying inside the discovery-based architecture.
| You want to add | Best extension point |
|---|---|
| External service integration | Packages/Capabilities/<Name> with Feature.js |
| Chat tools for an existing service | Existing capability package |
| Connector setup and credentials | Capability feature connectors.services |
| Free/no-auth connector | Packages/Capabilities/FreeConnectors/Feature.js |
| Background runtime with timers/queues | Packages/Features/<Name>/Core/*Engine.js |
| Renderer-to-main API | *IPC.js under a discovered IPC root |
| Reusable main-process helper | *Service.js under Packages/Main/Services or another services root |
| New top-level UI surface | Packages/Pages/<Page>/Page.js and renderer folder |
| Feature-owned page | pages contribution from a feature |
| New AI provider | Config/Models, setup provider UI, and Packages/Features/AI/index.js |
| Automation source/output | Automation built-ins or feature automation contributions |
Every package contribution starts in a workspace package package.json.
Use only the discovery roots that the package actually needs:
{
"name": "@Joanium/example",
"private": true,
"type": "module",
"joanium": {
"discovery": {
"features": ["./Core"],
"engines": ["./Core"],
"ipc": ["./IPC"],
"pages": ["."],
"services": ["./Services"]
}
}
}After adding or moving discovery roots, run:
npm run packages:auditUse a capability feature when integrating a third-party service or contributing cross-surface behavior such as connectors, tools, prompt context, and automation hooks.
Minimal shape:
import defineFeature from '../../Core/DefineFeature.js';
export default defineFeature({
id: 'acme',
name: 'Acme',
connectors: {
services: [
{
id: 'acme',
name: 'Acme',
description: 'Connect your Acme workspace',
fields: [{ key: 'apiKey', label: 'API Key', type: 'password', required: true }],
},
],
},
renderer: {
chatTools: [
{
name: 'acme_search',
description: 'Search Acme',
category: 'acme',
connectorId: 'acme',
parameters: {
query: { type: 'string', required: true, description: 'Search query' },
},
},
],
},
main: {
async executeChatTool(ctx, { toolName, params }) {
if (toolName !== 'acme_search') return null;
return `Result for ${params.query}`;
},
},
prompt: {
async getContext(ctx) {
if (!ctx.connectorEngine?.isConnected?.('acme')) return null;
return {
connectedServices: ['Acme'],
sections: [{ title: 'Acme', body: 'Acme is connected.' }],
};
},
},
});Rules:
- Feature IDs must be globally unique.
- Tool names must be globally unique.
- Connector IDs must be globally unique.
- Use
dependsOnwhen extending a root feature, such as Google Workspace. - Keep integration API logic close to the feature package.
Service connectors appear in setup/settings and persist credentials through the connector engine.
Connector definitions should include:
idnamedescriptioniconfields- Optional
setupSteps - Optional
capabilities - Optional
validate
Credential state is owned by ConnectorEngine. Do not store connector tokens in
renderer-only state.
Google Workspace is a root feature with ID google-workspace. Sub-services such
as Calendar and Gmail depend on it and extend the root connector.
Pattern:
export default defineFeature({
id: 'my-google-service',
name: 'Google Example',
dependsOn: ['google-workspace'],
connectors: {
serviceExtensions: [
{
target: 'google',
subServices: [{ key: 'example', label: 'Example' }],
capabilities: ['Example service support'],
},
],
},
});Keep service-specific API helpers under the relevant Google subfolder.
Features can contribute data sources, output types, and instruction templates.
automation: {
dataSources: [
{ value: 'acme_updates', label: 'Acme - Updates', group: 'Acme' },
],
dataSourceCollectors: {
async acme_updates(ctx, dataSource) {
return 'Collected Acme data';
},
},
outputTypes: [
{ value: 'acme_create_item', label: 'Create Acme item', group: 'Acme' },
],
outputHandlers: {
async acme_create_item(ctx, payload) {
return { ok: true };
},
},
instructionTemplates: {
acme_updates: 'Review these Acme updates and summarize anything actionable.',
},
}Use feature-contributed automation pieces for service-specific behavior. Use
Packages/Features/Automation/DataSources and Actions for generic platform
behavior.
Use an engine for long-lived main-process behavior: scheduling, polling, queues, runtime state, or persistent background systems.
import defineEngine from '../../../System/Contracts/DefineEngine.js';
export const engineMeta = defineEngine({
id: 'acme',
provides: 'acmeEngine',
needs: ['featureStorage'],
storage: { key: 'acme', featureKey: 'acme', fileName: 'Acme.json' },
create({ featureStorage }) {
const storage = featureStorage.get('acme');
let timer = null;
return {
start() {
timer = setInterval(() => {}, 60_000);
},
stop() {
clearInterval(timer);
},
getAll() {
return storage.load(() => ({ items: [] }));
},
};
},
});Rules:
providesmust be unique.- Declare every dependency in
needs. - Declare storage descriptors when persisting state.
- Implement
stop()for timers, polling loops, queues, and pending promises.
Use IPC when renderer code needs main-process behavior.
import { ipcMain } from 'electron';
export const ipcMeta = { needs: ['acmeEngine'] };
export function register(acmeEngine) {
ipcMain.handle('acme:list', () => acmeEngine.getAll());
}DiscoverIPC.js injects dependencies listed in ipcMeta.needs. Dependencies
can be engines, services, paths, feature registry, feature storage, and other
boot context values.
Rules:
- Keep channel names stable and specific.
- Return JSON-serializable data.
- Do validation in the main process for filesystem, shell, network, and credential operations.
- Use
wrapHandlerfrom main IPC modules when you want consistent{ ok, error }shapes.
Services are reusable main-process helpers loaded from discovered service roots.
Files ending in Service.js are imported and exposed to IPC injection by
camel-cased filename.
Example: ProjectService.js becomes projectService.
Use services for:
- File-backed data stores
- Shared API helpers
- Prompt or content library helpers
- App-wide state helpers
Avoid using services as renderer UI containers.
Top-level app pages are discovered from Page.js files.
import definePage from '../../System/Contracts/DefinePage.js';
export default definePage({
id: 'acme',
label: 'Acme',
icon: '<svg viewBox="0 0 24 24"></svg>',
css: new URL('./UI/Styles/AcmePage.css', import.meta.url).href,
order: 80,
section: 'top',
moduleUrl: new URL('./UI/Render/index.js', import.meta.url).href,
});Expected renderer module shape:
export function mount(outlet, context) {
outlet.innerHTML = '<section>Acme</section>';
return () => {};
}Use showInSidebar: false for setup-like pages that should not appear in
navigation.
Provider support touches three areas:
| Area | Files |
|---|---|
| Catalog | Config/Models/index.json and Config/Models/<Provider>.json |
| Setup UI | Packages/Pages/Setup/UI/Render/Providers/SetupProviders.js |
| Request adapter | Packages/Features/AI/index.js |
If the provider is OpenAI-compatible, add a catalog and setup entry first. If it needs custom message, tool, streaming, or usage parsing behavior, extend the AI adapter.
Local providers should set requires_api_key: false and use provider settings
for endpoint/model values.
Built-in chat tools live under:
Packages/Pages/Chat/Features/Capabilities/<Group>/
Tools.js
Executor.js
Trigger.js
ToolsList.js
Use this for generic, non-connector-specific tool groups. For service-specific tools, prefer feature-contributed tools inside the capability package.
After structural changes:
[ ] npm run packages:audit
[ ] npm run lint
[ ] Start the app
[ ] Confirm boot logs have no duplicate feature/page/storage/engine IDs
[ ] Confirm the page/connector/tool/engine appears where expected
[ ] Verify packaged resources if new seed/config files were added
| Problem | Likely fix |
|---|---|
| Feature not loading | Check workspace coverage and joanium.discovery.features |
| Engine not instantiating | Check needs, provides, and storage descriptors |
| IPC dependency undefined | Check ipcMeta.needs and boot context key name |
| Page missing from sidebar | Check Page.js, showInSidebar, and page discovery root |
| Tool not available | Check connector enabled state, workspace-scoped filtering, and tool name uniqueness |
| Prompt context stale | Call system prompt invalidation after connector/user state changes |
| State path wrong in packaged builds | Use Paths, not hardcoded repo paths |
| Build changed source formatting | npm run build minifies in place; inspect Git diff before committing |