Skip to main content

Overview

createOracleApp(opts: CreateOracleAppOptions): Promise<OracleApp> resolves the plugin set, validates env, populates registries, builds the dynamic RuntimeAppModule, bootstraps NestJS, and returns an OracleApp whose listen() runs beforeListen callbacks then starts the HTTP server. Matrix init runs in the background — the returned promise resolves as soon as Nest is built. Status flips via onPluginStatusChange.
import { createOracleApp } from '@ixo/oracle-runtime';

CreateOracleAppOptions

export interface CreateOracleAppOptions {
  config: OracleConfig;
  features?: Partial<Record<BundledFeatureName | (string & {}), FeatureToggle>>;
  manifestOverrides?: Partial<Record<BundledFeatureName | (string & {}), PluginManifestOverride>>;
  plugins?: OraclePlugin[];
  nestModules?: Array<Type | DynamicModule>;
  authExcludedRoutes?: AuthExcludedRoute[];
  bundledPlugins?: OraclePlugin[];
  env?: NodeJS.ProcessEnv;
  skipMatrixInit?: boolean;
  skipGracefulShutdown?: boolean;
  logger?: Logger;
  hooks?: MainAgentHooks;
}
  • Type: OracleConfig
  • Required: yes
Inline oracle config — name, optional org, optional description, optional prompt. The runtime fills in entityDid from the ORACLE_ENTITY_DID env var, so don’t put it here.
config: {
  name: 'My Oracle',
  org: 'My Org',
  description: 'What this oracle is for',
  prompt: {
    opening: '...',
    communicationStyle: '...',
    capabilities: '...',
    customInstructions: '...',
  },
}
prompt sub-fields — all four are optional. Omit any you don’t need.openingUsed verbatim as the identity preamble — the very first section of the assembled system prompt. Write it as a complete paragraph describing who the oracle is and what it does.If opening is absent, the runtime generates a fallback from the other top-level fields:
  • name + org + description present → "You are {name}, an AI agent operated by {org}. {description}."
  • org missing → "You are {name}. {description}."
  • Both org and description missing → "You are {name}."
Providing opening gives you full control over tone, persona, and framing — use it whenever the generated fallback is too generic.communicationStyleInjected inside the hardcoded ”## Operating principles” section of the prompt. If set, it appears as a paragraph within that section immediately after the 7 standard operating-principle bullets. If empty or absent, the field is omitted entirely — the operating-principles section still appears, just without the custom style block.Use this to steer the agent’s tone: formal, concise, empathetic, domain-specific jargon preferences, and so on.capabilitiesRendered above the Tier-1 plugin capability block (the auto-generated list of visibility='always' plugins). No header is added by the runtime around this text — write your own heading if you want one. If empty or absent, the field is omitted entirely.Use this to describe high-level skills the oracle has that aren’t obvious from the plugin manifest alone — things like domain expertise, supported workflows, or what the agent should proactively offer.customInstructionsFree-form standing guidance injected verbatim into a dedicated ## Custom Instructions section of the system prompt. Use it for house style, domain rules, compliance guardrails, or any directive that doesn’t fit communicationStyle (tone) or capabilities (the elevator pitch). The runtime renders the section only when there is content.Operating guides contributed by on-demand capabilities the agent loads mid-thread (e.g. the Flow Builder guide, which appears once flows is in loadedPlugins) are appended after your text in the same section — so they cost no tokens on turns where the capability is never loaded.
prompt: {
  customInstructions: `Always cite the registry document ID when quoting issuance data.
Never promise a delivery date you can't verify against the indexer.
Decline any request to move funds — you advise, you do not transact.`,
}
entityDidDo not set this in code. The runtime reads it from the ORACLE_ENTITY_DID environment variable and populates it automatically during boot. Setting it inline has no effect.Full example
config: {
  name: 'Aria',
  org: 'Acme Climate',
  description: 'A carbon-project advisory oracle for Acme Climate portfolio managers.',
  prompt: {
    opening: `You are Aria, the carbon-project advisory oracle operated by Acme Climate.
You help portfolio managers track project status, assess methodology compliance,
and draft stakeholder communications. You have deep knowledge of Verra VCS,
Gold Standard, and REDD+ frameworks.`,
    communicationStyle: `Be precise and data-driven. Lead with numbers and deadlines.
Use plain English — avoid jargon unless the user demonstrates familiarity.
When something is uncertain, say so explicitly rather than hedging with filler phrases.`,
    capabilities: `## What Aria can do
- Retrieve live project status and registry issuance data via the IXO domain indexer.
- Draft verification reports, CORSIAs letters, and board summaries.
- Search and cite methodology documents from the oracle knowledge base.
- Schedule follow-up reminders and track open action items across conversations.`,
    customInstructions: `Always cite the registry document ID when quoting issuance data.
Treat any methodology version older than 18 months as "needs review" and flag it.
You advise on carbon projects — you never authorise transactions or move funds.`,
  },
}
  • Type: Partial<Record<BundledFeatureName | (string & {}), FeatureToggle>> where FeatureToggle = boolean | 'auto'
  • Required: no
Override the default opt-in state per plugin. Keys are plugin names — the kebab-case name field of each plugin (e.g. 'domain-indexer', 'user-preferences'), not a camelCase alias. Values: true (force on), false (force off), 'auto' (run autoDetect(env)). Omitted keys are treated as 'auto'.
features: {
  slack: false,
  composio: true,
  'domain-indexer': 'auto',
}
  • Type: Partial<Record<BundledFeatureName | (string & {}), PluginManifestOverride>> where PluginManifestOverride = Partial<PluginManifest>
  • Required: no
  • Added in: @ixo/oracle-runtime 1.2.0
Per-plugin overrides for fields on a loaded plugin’s manifest. Use this to retune a bundled plugin’s discovery — most commonly its visibilitywithout forking the plugin source. Typical uses: flip a noisy always plugin to on-demand, hide one behind silent, or relabel its summary / tags / whenToUse.Keys are plugin names (the same kebab-case identifiers used by features, e.g. 'domain-indexer'). Values are a Partial<PluginManifest> — set only the fields you want to change.
import { createOracleApp } from '@ixo/oracle-runtime';

const app = await createOracleApp({
  config,
  manifestOverrides: {
    // Drop a noisy 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' },
    // Sharpen the trigger phrases the agent sees for memory.
    memory: {
      summary: 'Long-term user memory store.',
      whenToUse: ['When the user refers to a past conversation or stored preference.'],
    },
  },
});
Merge semanticsEach override is shallow-merged onto the plugin’s own manifest before validation and registration:
  • Keys you set win; keys you omit keep the plugin default.
  • undefined values are ignored — a sparse override never blanks out a field.
  • Arrays and nested objects are replaced wholesale (not deep-merged). For example, supplying whenToUse: [...] replaces the plugin’s full list rather than appending to it.
The merged manifest is what the runtime validates and what every downstream reader sees — the Tier-1 prompt block, the list_capabilities / load_capability meta-tools, and the agent’s visibility index — so an override is the single source of truth from boot onwards.ValidationBecause the merged manifest is run through the same validateManifest rules as an authored one (see Manifest schema), an override is held to the same constraints. For example, an override that empties whenToUse on a non-silent plugin fails boot with a clear Plugin manifest validation failed error.Unknown keysOverride keys that don’t match a loaded plugin (e.g. the plugin is excluded by features or simply misspelt) are logged and ignored — they do not fail boot:
[boot] manifestOverrides references 'composio', which is not a loaded plugin — ignored (event: boot.plugin.manifest_override_unknown)
Watch for that event when a retune doesn’t appear to take effect.
The same option is available on createTestRuntime with identical shape and semantics, so a test can exercise a plugin under a retuned visibility (e.g. forcing silent) without a bespoke fixture.
  • Type: OraclePlugin[]
  • Required: no
Your plugin instances. Plus any bundled plugins you want to instantiate with constructor args (new EditorPlugin({ matrixClient }), new CreditsPlugin({ redis, network })). The plugin loader dedupes by name, so an explicit instance overrides the bundled default of the same name.
import { EditorPlugin, FlowsPlugin } from '@ixo/oracle-runtime';
import { WeatherPlugin } from './plugins/weather/index.js';

plugins: [
  new EditorPlugin({ matrixClient }), // overrides the bundled editor
  new FlowsPlugin({ matrixClient }),  // opt-in, not bundled
  new WeatherPlugin(),                // your own plugin
]
  • Type: Array<Type | DynamicModule>
  • Required: no
Your own NestJS modules. Spread into RuntimeAppModule.imports. They get full DI access to the Tier-0 services (Sessions, Messages, Secrets, UCAN, …) and can declare controllers, providers, OnModuleInit, OnModuleDestroy.
@Module({ controllers: [VersionController] })
class VersionModule {}

nestModules: [VersionModule]
Routes these modules expose still pass through AuthHeaderMiddleware unless you list them in authExcludedRoutes.
  • Type: AuthExcludedRoute[]
  • Required: no
Host-declared routes that must not pass through AuthHeaderMiddleware. Use for routes contributed by nestModules (webhooks, OAuth callbacks, public probes). Symmetric with each plugin’s getAuthExcludedRoutes() hook — both lists merge onto the runtime’s built-in exclusions (/health, /docs).
import { RequestMethod } from '@nestjs/common';

authExcludedRoutes: [
  { path: 'version', method: RequestMethod.GET },
]
  • Type: OraclePlugin[]
  • Required: no
Override the bundled plugin set. Provided primarily for tests so the harness can spin up createOracleApp without dragging in the full bundled catalog. Production callers should leave this unset.
// test only — boot with a single plugin instead of the full catalog
bundledPlugins: [new MemoryPlugin()]
  • Type: NodeJS.ProcessEnv
  • Required: no
Override process.env. Tests use this to inject a clean, controlled env so the boot doesn’t depend on the machine’s environment. Production code should leave it unset.
// test only — inject a controlled env instead of process.env
env: { ...baseTestEnv, LLM_PROVIDER: 'nebius' }
  • Type: boolean
  • Required: no
Skip starting the Matrix background init. Tests set this so the factory resolves without touching real Matrix. Do not use in production.
// test only
skipMatrixInit: true
  • Type: boolean
  • Required: no
Skip registering the SIGTERM/SIGINT shutdown handler. Tests set this so the harness does not leak process-level listeners. Do not use in production.
// test only
skipGracefulShutdown: true
  • Type: Logger
  • Required: no
Override the bootstrap logger — any object implementing the Logger interface (log, error, warn, optional debug/verbose/child). Falls back to NestJS Logger.
import { Logger } from '@nestjs/common';

logger: new Logger('MyOracle')
  • Type: MainAgentHooks
  • Required: no
Overrides for the agent build — the chat model (resolveModel), the per-user checkpointer (checkpointerForUser), and the prompt/middleware toggles. Merged on top of the runtime’s defaults, and host hooks win.This is where you change the AI model. See MainAgentHooks below for all ten fields, the resolveModel example, and the 'main'-only nuance.
import { getProviderChatModel } from '@ixo/oracle-runtime';

hooks: {
  resolveModel: (role, params) =>
    getProviderChatModel(role, {
      ...params,
      ...(role === 'main' && { model: 'google/gemini-3.1-flash-lite' }),
    }),
}

MainAgentHooks

hooks lets you override how the runtime builds the main agent on every request, without forking the runtime. The runtime merges your hooks over its own defaults — your hooks win ({ ...defaultHooks, ...opts.hooks }). The only default the runtime sets is checkpointerForUser (a per-user SQLite store); everything else is unset unless you provide it.
import type { MainAgentHooks } from '@ixo/oracle-runtime';

Change the AI model

This is the answer to “how do I change the model?”. resolveModel is the model resolver. Return any LangChain BaseChatModel for the given role:
import { createOracleApp, getProviderChatModel } from '@ixo/oracle-runtime';

const app = await createOracleApp({
  config,
  plugins,
  hooks: {
    // role is 'main' | 'subagent' | 'utility' | (string & {}).
    // Spreading `params` preserves the provider's fallback models + latency sort.
    resolveModel: (role, params) =>
      getProviderChatModel(role, {
        ...params,
        ...(role === 'main' && { model: 'google/gemini-3.1-flash-lite' }),
      }),
  },
});
The runtime only ever calls resolveModel('main'). Sub-agent, utility, vision and guard models are resolved separately, straight through the provider config (ambient.llm.get(role)getProviderChatModel). So a resolveModel hook changes only the main agent’s model unless your own plugin code also routes its roles through the hook. Branch on role === 'main' (as above) so you don’t accidentally rewrite a role you never see.
Where model IDs come from. Each role (main, subagent, vision, guard, …) maps to a hardcoded model ID per provider. The provider is chosen by env: LLM_PROVIDER = openrouter (default) or nebius, with the matching key (OPEN_ROUTER_API_KEY / NEBIUS_API_KEY). There is no env var to change the main model ID — overriding it is exactly what resolveModel is for. Building the model with getProviderChatModel (rather than new ChatOpenAI(...)) keeps the OpenRouter fallback-models list and latency sort the 'main' role ships with.
Prefer a different SDK model? Return your own LangChain model instead — but you lose the provider’s fallback/latency wiring, so you own retries and outages:
import { ChatOpenAI } from '@langchain/openai'; // add this dependency yourself

hooks: {
  resolveModel: () => new ChatOpenAI({ model: 'gpt-4o' }),
}

All ten fields

FieldTypeWhat it does
resolveModel(role: ModelRole, params?: ChatOpenAIFields) => BaseChatModelResolves the chat model. Called once, with 'main'. Default: ambient.llm.get('main').
checkpointerForUser(userDid: string) => Promise<BaseCheckpointSaver>Per-user conversation store. Default: per-user SQLite synced to Matrix — override to swap the backend.
getRoomTitle(roomId: string) => Promise<string | undefined>Page-title lookup. Providing it adds the page-context middleware to the stack; omitting it leaves that middleware out.
safetyModelBaseChatModelCheap classifier model. Providing it adds the safety-guardrail middleware; omitting it leaves it out.
validationSkipToolNamesstring[]Tool names whose dangling ToolMessage outputs are stripped before the next model call — typically sub-agent tools whose output the caller summarises, not the model.
operationalModestringReplaces the default operational-mode prompt block. (The editor plugin’s per-page mode still overrides this when a page is open.)
editorSectionstringEditor prompt block — normally populated by the editor plugin; set it only if you compose the section yourself.
composioContextstringComposio guidance prompt block — normally populated by the composio plugin.
userSecretsContextstringPer-key secret bullet list rendered into the prompt (e.g. - _USER_SECRET_FOO).
degradedServicesBlockstringA “some services are degraded” notice appended to the system prompt body.
ModelRole is 'main' | 'subagent' | 'utility' | (string & {}). ChatOpenAIFields is Record<string, unknown> — the optional fields forwarded to the underlying chat model. Swap the checkpointer — supply your own LangGraph saver per user:
import type { BaseCheckpointSaver } from '@langchain/langgraph';

hooks: {
  checkpointerForUser: async (userDid: string): Promise<BaseCheckpointSaver> => {
    return myStore.saverFor(userDid);
  },
}
Turn on the conditional middlewares — both are added to the stack only when their hook is present:
hooks: {
  // adds the page-context middleware
  getRoomTitle: async (roomId) => myRoomTitles.get(roomId),
  // adds the safety-guardrail middleware
  safetyModel: getProviderChatModel('guard'),
}
Prompt blocks — the string fields are appended to dedicated prompt sections. Most are populated by their owning plugin; set them only when you’re driving the section yourself:
hooks: {
  operationalMode: 'You are operating in read-only audit mode. Never call mutating tools.',
  validationSkipToolNames: ['call_research_agent'],
}

OracleApp

The resolved OracleApp exposes:
export interface OracleApp {
  getNestApp(): INestApplication;
  ambient: AmbientServices;
  plugins: { status(): PluginStatusReport };
  beforeListen(fn: (nestApp: INestApplication) => Promise<void> | void): void;
  onError(handler: (err: Error, source: string) => void): void;
  onPluginStatusChange(handler: (event: PluginStatusChangeEvent) => void): void;
  listen(port?: number): Promise<void>;
}
Returns the underlying INestApplication. Use it to add Express middleware, register global filters, or do anything else NestJS exposes. Mainly an escape hatch — nestModules and beforeListen cover most cases.
The production AmbientServices bag — used by MessagesController to build per-request RuntimeContexts before invoking createMainAgent. Forks normally don’t touch this directly.
Returns a snapshot of loader/exclusion/soft-dep state.
{
  loaded: string[];
  excluded: Array<{ plugin: string; reason: string }>;
  softDepGaps: Array<{ plugin: string; missing: string }>;
}
Register a callback that runs after Nest is built but before HTTP starts accepting. Multiple registrations run in order, awaiting each.
Subscribe to background errors. The runtime currently surfaces Matrix init failures here (with source: 'matrix-init'). If no handler is registered, errors log to the bootstrap logger.
Subscribe to plugin lifecycle changes. The current emitter is the Matrix init flow:
{ plugin: 'matrix', from: 'pending', to: 'loaded' | 'failed', reason?: string }
Multiple handlers may register; each is invoked for every event.
Start the HTTP server. Honours beforeListen callbacks first. Port resolution: explicit port arg → PORT env (validated, defaults to 3000). Throws if called twice.

Behaviour

The function executes these steps in order:
  1. Validates config (name required).
  2. Resolves plugin set: bundled + your plugins, deduped by name, with features toggles and autoDetect predicates applied. Excluded plugins surface in plugins.status().excluded with their reason.
  3. Topologically sorts by dependsOn. Cycles or missing hard deps fail boot.
  4. Shallow-merges any manifestOverrides onto each loaded plugin’s manifest, then validates the merged manifest. Hard violations fail boot. Override keys that don’t match a loaded plugin are logged (boot.plugin.manifest_override_unknown) and ignored.
  5. Composes env schema (base + all loaded plugins’ configSchema) and validates process.env. Missing required vars fail boot with [boot-error] Plugin '<name>' env validation failed for '<field>'.
  6. Cross-field check: the API key for the selected LLM_PROVIDER is present. (Schema-merge can’t express this.)
  7. Builds the internal OracleIdentity from config + validated env.
  8. Populates the six registries (tools, sub-agents, middleware, manifests, configSchema, sharedState).
  9. Collects getNestModules from every loaded plugin.
  10. Builds the dynamic RuntimeAppModule and bootstraps NestJS (NestFactory.create). Installs a global ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), enables CORS with the framework’s allowed headers (authorization, x-auth-type, x-ucan-delegation, …), and mounts Swagger UI at /docs.
The global ValidationPipe applies to every controller — including ones you add via nestModules or a plugin’s getNestModules. Annotate request DTOs with class-validator decorators: unknown body fields are rejected (forbidNonWhitelisted), unannotated props are stripped (whitelist), and nested DTOs are instantiated (transform). A controller that silently 400s on an extra field is hitting this pipe.
  1. Builds AmbientServices.
  2. Warms the boot caches (each registry runs its boot-time hooks once).
  3. Wires the default checkpointer (UserMatrixSqliteSyncService + SqliteSaver), then merges host hooks over it.
  4. Populates the OracleRuntimeBundleHolder so MessagesController can read it on each request.
  5. Schedules background Matrix init (unless skipMatrixInit: true). Registers a graceful-shutdown handler (unless skipGracefulShutdown: true).
  6. Returns the OracleApp.
Step 15 (Matrix init) runs asynchronously and does not block the returned promise. listen() blocks on beforeListen callbacks before starting HTTP.

Throws

  • Error('createOracleApp: \'config\' is required.')config missing.
  • Error('createOracleApp: \'config.name\' is required.')config.name empty.
  • Plugin resolution errors (cycle, missing hard dep, manifest invalid, env invalid). Each error is reported via logger.error with [boot-error] … prefix and a remediation hint.
  • Error('createOracleApp: ORACLE_ENTITY_DID env is required and was empty after validation.') — when the validated env’s ORACLE_ENTITY_DID is empty.
  • Error('OracleApp.listen called twice.') — when listen() is called more than once.