Copy-paste recipe
The whole API is a features map handed to createOracleApp. Each key is a bundled plugin name, each value is true / false / 'auto'.
import { createOracleApp } from '@ixo/oracle-runtime';
import { config } from './config.js';
const app = await createOracleApp({
config,
features: {
composio: true, // force on (set COMPOSIO_API_KEY)
slack: false, // force off
firecrawl: 'auto', // same as omitting — runs autoDetect
},
plugins: [], // your own plugins go here
});
await app.listen();That's the whole surface. The runtime pre-loads every bundled plugin instance from BUNDLED_PLUGINS; features only controls which ones survive resolution. Reference oracle: apps/qiforge-example/src/main.ts.
How resolution works
The runtime starts with the bundled list
BUNDLED_PLUGINS is a fixed 16-plugin tuple — memory, portal, firecrawl, domain-indexer, composio, sandbox, skills, editor, agui, slack, tasks, credits, calls, user-preferences, matrix-group-chats, vfs.
You do not import or instantiate the plugins you want at defaults — they are already there.
Each plugin gets a feature decision
For every bundled plugin, resolvePlugins reads features[plugin.name]:
type FeatureToggle = boolean | 'auto';
features?: Partial<Record<string, FeatureToggle>>;| Value | Behaviour |
|---|---|
true | Force the plugin on. If its autoDetect(env) returns false, boot fails with boot.plugin.env_missing. |
false | Force the plugin off. Skip autoDetect entirely. |
'auto' (or omitted) | Run plugin.autoDetect(env). Include if true, exclude otherwise. |
Your own plugins are added next
Plugins from the plugins: [] array are always loaded — they're not gated by features. If a name collides with a bundled plugin, your instance wins (the loader dedupes by name).
Hard-dep cascades resolve transitively
If a loaded plugin has dependsOn: ['removed-plugin'] and that dep ended up excluded, the dependent cascades off too. Soft deps (softDependsOn) only log a warning.
What every bundled plugin does by default
Each plugin's autoDetect predicate decides whether to opt in when you leave it on 'auto'.
| Plugin | Auto-detects when | Notes |
|---|---|---|
memory | MEMORY_MCP_URL set | Visibility always |
portal | always on | Visibility on-demand |
firecrawl | FIRECRAWL_MCP_URL set | Visibility on-demand |
domain-indexer | always on | Visibility always |
composio | COMPOSIO_API_KEY set | Visibility on-demand |
sandbox | SANDBOX_MCP_URL set | Visibility always |
skills | always on | Visibility always; depends on sandbox |
editor | always on | Needs matrixClient — instantiate explicitly |
agui | always on | Visibility on-demand |
slack | SLACK_BOT_OAUTH_TOKEN set | Visibility silent (transport) |
tasks | REDIS_URL set | Visibility on-demand; BullMQ-backed async tasks (needs REDIS_URL) |
credits | always on | Visibility silent; pass redis for production |
calls | always on | Visibility silent; placeholder stub (no tools yet) |
user-preferences | always on | Visibility always |
matrix-group-chats | always on | Visibility on-demand; gating middleware + tools fire only in Matrix group rooms (memberCount > 2) |
vfs | always on | Visibility always; worker URLs derived from NETWORK. Contributes tools only when the oracle has a UCAN signing key and the user granted filesystem access |
Full per-plugin env vars: plugin catalog and environment variables reference.
Opt out of a plugin
Set the feature flag to false
const app = await createOracleApp({
config,
features: {
composio: false,
'domain-indexer': false,
},
});The plugin is dropped before autoDetect runs. Its configSchema is also removed from the merged env schema, so its env vars become optional.
Check for dependents
If another loaded plugin lists the disabled plugin in its dependsOn, boot fails with a boot.plugin.dep_missing error naming both. Disable the dependent too, or keep the dependency loaded.
Force a plugin on
Set the feature flag to true
const app = await createOracleApp({
config,
features: {
composio: true,
},
});The plugin loads even if autoDetect would skip it.
Set every env var the plugin needs
With features: { composio: true } and no COMPOSIO_API_KEY, the runtime throws at boot:
boot.plugin.env_missing: plugin 'composio' enabled via features but precondition failed (COMPOSIO_API_KEY).
Set the required env or disable: features: { composio: false }See the plugin's page in the plugin catalog for its full env requirements.
Plugins that need constructor args
Two bundled plugins take a live runtime object you provide — instantiate explicitly and pass them via plugins:
import { createOracleApp, EditorPlugin, CreditsPlugin } from '@ixo/oracle-runtime';
import * as sdk from 'matrix-js-sdk';
import Redis from 'ioredis';
const matrixClient = sdk.createClient({
baseUrl: process.env.MATRIX_BASE_URL!,
userId: process.env.MATRIX_ORACLE_ADMIN_USER_ID!,
accessToken: process.env.MATRIX_ORACLE_ADMIN_ACCESS_TOKEN!,
});
const redis = process.env.REDIS_URL ? new Redis(process.env.REDIS_URL) : null;
const app = await createOracleApp({
config,
plugins: [
new EditorPlugin({ matrixClient }),
...(redis ? [new CreditsPlugin({ redis, network: 'devnet' })] : []),
],
});Live example: apps/qiforge-example/src/main.ts.
The bundled editorPlugin and creditsPlugin instances boot in stub form (for testing). For production behaviour, instantiate them yourself and pass the live objects in.
Retune a bundled plugin's manifest
features decides whether a bundled plugin loads. To change how it's advertised — most usefully its visibility tier, but also summary, tags, or whenToUse — use manifestOverrides instead of forking the plugin. The override is shallow-merged onto the plugin's own manifest at boot, validated like any authored manifest, and seen by every downstream reader (Tier-1 prompt, list_capabilities / load_capability, visibility index).
const app = await createOracleApp({
config,
manifestOverrides: {
// Take a noisy `always` bundled plugin out of the Tier-1 prompt.
'domain-indexer': { visibility: 'on-demand' },
// Hide a transport plugin entirely; its tools still bind.
portal: { visibility: 'silent' },
},
});Override keys that don't match a loaded plugin are logged (boot.plugin.manifest_override_unknown) and ignored — so disabling a plugin via features and leaving its override entry behind is safe.
Inspect what loaded
Read app.plugins.status() after boot
const status = app.plugins.status();
// {
// loaded: ['memory', 'domain-indexer', 'editor', 'user-preferences', 'weather'],
// excluded: [
// { plugin: 'composio', reason: 'auto-detect precondition not met (COMPOSIO_API_KEY)' },
// { plugin: 'slack', reason: 'feature flag set to false' },
// ],
// softDepGaps: [],
// }Each excluded entry is { plugin, reason } — surface the reason in your boot logs so operators see why a plugin came up missing. (softDepGaps entries are { plugin, missing }.)