The finished plugin in one snippet
This is what you are about to build — every OraclePlugin hook wired against Open-Meteo. The canonical source: apps/qiforge-example/src/plugins/weather/weather.plugin.ts.
export class WeatherPlugin extends OraclePlugin {
readonly name = 'weather';
readonly version = '0.1.0';
readonly manifest = manifest;
override readonly configSchema = configSchema;
override readonly autoDetectHint = 'always on (set WEATHER_DEFAULT_UNITS to celsius|fahrenheit)';
private readonly lastBySession: LastQueryStore = new Map();
override autoDetect() { return true; }
override getTools(ctx) { return [buildCurrentWeatherTool(this.units(ctx.config), this.lastBySession)]; }
override getRequestTools(rt) { return [buildForecastTool(this.units(rt.config), this.lastBySession)]; }
override getSubAgents(ctx) { return [buildWeatherPlannerSubAgent(this.units(ctx.config), this.lastBySession)]; }
override getMiddlewares(ctx) { return [buildWeatherMiddleware(ctx)]; }
override getNestModules(ctx) { return [WeatherHttpModule.register(this.units(ctx.config))]; }
override getAuthExcludedRoutes() {
return [{ path: 'weather/now', method: RequestMethod.GET }];
}
override getSharedState() {
return { lastWeatherQuery: (_s, rt) => this.lastBySession.get(rt.session.id) };
}
}Each step below builds one of those lines.
Two ways to author a plugin
Every hook in this guide works identically whether you subclass OraclePlugin or use the defineOraclePlugin helper. Pick whichever you prefer — the runtime treats the resulting object exactly the same way.
Subclass and override each hook. Per-session state lives on private fields. Register with new WeatherPlugin().
export class WeatherPlugin extends OraclePlugin {
readonly name = 'weather';
readonly version = '0.1.0';
readonly manifest = manifest;
override readonly configSchema = configSchema;
private readonly lastBySession: LastQueryStore = new Map();
override autoDetect(): boolean { return true; }
override getTools(ctx: PluginContext): PluginTool[] {
return [buildCurrentWeatherTool(this.units(ctx.config), this.lastBySession)];
}
// …every other hook as an override
}plugins: [new WeatherPlugin()],Pass a plain object — name, version, and manifest are required; every hook is optional. Per-session state lives in a module-scoped closure. defineOraclePlugin returns a ready OraclePlugin, so register it without new.
import {
defineOraclePlugin,
type PluginContext,
type RuntimeContext,
} from '@ixo/oracle-runtime';
import { RequestMethod } from '@nestjs/common';
const lastBySession: LastQueryStore = new Map();
const units = (config: unknown) => configSchema.parse(config).WEATHER_DEFAULT_UNITS;
export const weatherPlugin = defineOraclePlugin({
name: 'weather',
version: '0.1.0',
manifest,
configSchema,
autoDetectHint: 'always on (set WEATHER_DEFAULT_UNITS to celsius|fahrenheit)',
autoDetect: () => true,
getTools: (ctx) => [buildCurrentWeatherTool(units(ctx.config), lastBySession)],
getRequestTools: (rtCtx) => [buildForecastTool(units(rtCtx.config), lastBySession)],
getSubAgents: (ctx) => [buildWeatherPlannerSubAgent(units(ctx.config), lastBySession)],
getMiddlewares: (ctx) => [buildWeatherMiddleware(ctx)],
getNestModules: (ctx) => [WeatherHttpModule.register(units(ctx.config))],
getAuthExcludedRoutes: () => [{ path: 'weather/now', method: RequestMethod.GET }],
getSharedState: () => ({
lastWeatherQuery: (_state, runCtx: RuntimeContext) =>
lastBySession.get(runCtx.session.id),
}),
});plugins: [weatherPlugin], // already an instance — no `new`defineOraclePlugin throws a TypeError at authoring time if name, version, or manifest is missing. Source: define-plugin.ts.
The rest of this guide uses the class form (matching the reference oracle), but every snippet maps one-to-one onto a defineOraclePlugin field.
Prerequisites
- A working
main.tscallingcreateOracleApp— see Create your oracle. - The oracle scaffolded by the CLI (
qiforge-cli new) — see the CLI reference. - Files live under
src/plugins/weather/— same layout as the reference apps/qiforge-example/src/plugins/weather/.
Step-by-step
Create the skeleton with a manifest and config schema
File: weather.plugin.ts
import {
OraclePlugin,
type PluginContext,
type PluginManifest,
type RuntimeContext,
z,
} from '@ixo/oracle-runtime';
const configSchema = z.object({
WEATHER_DEFAULT_UNITS: z.enum(['celsius', 'fahrenheit']).default('celsius'),
});
const manifest: PluginManifest = {
title: 'Weather',
summary: 'Weather lookups via Open-Meteo (no API key required).',
whenToUse: ['User asks about current weather or forecasts for any city.'],
whenNotToUse: ['Historical or long-term climate data.'],
tags: ['weather', 'forecast'],
category: 'data',
visibility: 'on-demand',
stability: 'experimental',
};
export class WeatherPlugin extends OraclePlugin {
readonly name = 'weather';
readonly version = '0.1.0';
readonly manifest = manifest;
override readonly configSchema = configSchema;
override readonly autoDetectHint =
'always on (set WEATHER_DEFAULT_UNITS to celsius|fahrenheit)';
override autoDetect(): boolean {
return true;
}
}That's a valid plugin — it loads, validates WEATHER_DEFAULT_UNITS, and contributes nothing yet. Everything below adds one hook at a time.
Write the upstream client
File: weather-client.ts. Open-Meteo doesn't need auth.
export type Units = 'celsius' | 'fahrenheit';
export async function getCurrentWeather(
city: string,
units: Units,
signal?: AbortSignal,
) {
// geocode → fetch /v1/forecast → parse with Zod → return null on miss
}
export async function getForecast(
city: string,
days: number,
units: Units,
timezone: string,
signal?: AbortSignal,
) {
// similar, returns `{ city, days: [{ date, tempMax, tempMin, conditions }] }`
}Keep it tiny — this guide is about the plugin contract, not weather APIs.
Add a boot-time tool with getTools
File: weather-tools.ts.
getTools(ctx) is called once per agent build. Use it when the tool's behaviour depends only on plugin config — not on per-request data.
import { type PluginTool, type RuntimeContext, tool, z } from '@ixo/oracle-runtime';
import { getCurrentWeather, type Units } from './weather-client.js';
export function buildCurrentWeatherTool(
defaultUnits: Units,
store: LastQueryStore,
): PluginTool {
return tool(
async (rawArgs, ctx: RuntimeContext) => {
const { city } = z.object({ city: z.string().min(1) }).parse(rawArgs);
const result = await getCurrentWeather(city, defaultUnits, ctx.abortSignal);
if (!result) return `Could not find weather for "${city}".`;
store.set(ctx.session.id, { ...result, queriedAt: new Date().toISOString() });
return JSON.stringify(result);
},
{
name: 'get_current_weather',
description:
'Get the current weather for a city. Returns temperature, wind speed (km/h), conditions, and coordinates.',
schema: z.object({
city: z.string().min(1).describe('City name, e.g. "Berlin".'),
}),
},
);
}Wire it on the plugin:
override getTools(ctx: PluginContext): PluginTool[] {
return [buildCurrentWeatherTool(this.units(ctx.config), this.lastBySession)];
}The handler still receives a full RuntimeContext at call time — ctx.user, ctx.session, ctx.abortSignal are all live.
Add a request-time tool with getRequestTools
When a tool depends on per-request data (e.g. the user's timezone), register it via getRequestTools(rtCtx) instead:
export function buildForecastTool(
defaultUnits: Units,
store: LastQueryStore,
): PluginTool {
return tool(
async (rawArgs, ctx: RuntimeContext) => {
const { city, days } = z
.object({
city: z.string().min(1),
days: z.number().int().min(1).max(7).optional(),
})
.parse(rawArgs);
const tz =
ctx.user.timezone && ctx.user.timezone.length > 0
? ctx.user.timezone
: 'auto';
const result = await getForecast(city, days ?? 3, defaultUnits, tz, ctx.abortSignal);
if (!result) return `Could not find a forecast for "${city}".`;
return JSON.stringify(result);
},
{
name: 'get_weather_forecast',
description:
'Get a daily weather forecast for a city (up to 7 days). Uses the user timezone when available.',
schema: z.object({
city: z.string().min(1),
days: z.number().int().min(1).max(7).optional(),
}),
},
);
}override getRequestTools(rtCtx: RuntimeContext): PluginTool[] {
return [buildForecastTool(this.units(rtCtx.config), this.lastBySession)];
}Boot-time and request-time outputs are merged — both tools end up in the agent's tool list.
Add a sub-agent with getSubAgents
File: weather-sub-agent.ts.
Sub-agents have their own prompt and tool list, and the runtime exposes each as a single tool to the main agent (call_weather_planner_agent). Use them for compound tasks that benefit from focused context.
import { type PluginSubAgent } from '@ixo/oracle-runtime';
const PROMPT = [
'You are the Weather Planner Agent. You decide whether the user needs a',
'jacket / umbrella / etc. for a place and time.',
'',
'Workflow:',
'1. Call get_weather_forecast with the city.',
"2. Pick the most relevant day.",
"3. Call recommend_outfit with that day's max temp + conditions.",
'4. Reply with ONE sentence combining the forecast and the outfit advice.',
].join('\n');
export function buildWeatherPlannerSubAgent(
defaultUnits: Units,
store: LastQueryStore,
): PluginSubAgent {
return {
name: 'weather_planner_agent',
description:
'Combines a forecast lookup with an outfit recommendation. Use for "should I bring a jacket to X tomorrow?".',
systemPrompt: PROMPT,
tools: [buildForecastTool(defaultUnits, store), buildRecommendOutfitTool()],
model: 'subagent',
forwardTools: true,
};
}override getSubAgents(ctx: PluginContext): PluginSubAgent[] {
return [buildWeatherPlannerSubAgent(this.units(ctx.config), this.lastBySession)];
}forwardTools: true surfaces the sub-agent's inner tool calls in the parent chat's UI events so users see the chain.
Add a middleware with getMiddlewares
File: weather-middleware.ts.
Middleware runs around every LLM call. Hooks come from LangChain's createMiddleware.
import { type AgentMiddleware, type PluginContext } from '@ixo/oracle-runtime';
import { createMiddleware } from 'langchain';
export function buildWeatherMiddleware(ctx: PluginContext): AgentMiddleware {
let startedAt = 0;
return createMiddleware({
name: 'WeatherLoggingMiddleware',
beforeModel: async () => {
startedAt = Date.now();
ctx.logger.log('model call started');
},
afterModel: async () => {
const elapsed = startedAt > 0 ? Date.now() - startedAt : -1;
ctx.logger.log(`model call complete (${elapsed}ms)`);
},
});
}override getMiddlewares(ctx: PluginContext): AgentMiddleware[] {
return [buildWeatherMiddleware(ctx)];
}Plugin middleware runs after the framework's always-on middleware (capability-gate, tool-validation, tool-repetition-guard, tool-retry) — plus the conditional page-context / safety-guardrail when their hooks are configured — in topological dependency order across plugins. A plugin middleware hook must return undefined or { jumpTo: 'end' as const }, never a partial state object — see Add a middleware.
Add an HTTP route with getNestModules + getAuthExcludedRoutes
File: weather.module.ts.
To expose a public GET /weather/now?city=X, ship a NestJS module:
import { Controller, type DynamicModule, Get, Inject, Module, Query } from '@nestjs/common';
import { getCurrentWeather, type Units } from './weather-client.js';
export const WEATHER_DEFAULT_UNITS = 'WEATHER_DEFAULT_UNITS';
@Controller('weather')
export class WeatherController {
constructor(@Inject(WEATHER_DEFAULT_UNITS) private readonly units: Units) {}
@Get('now')
async now(@Query('city') city?: string) {
if (!city) return { ok: false, error: 'Missing required query param: city' };
const result = await getCurrentWeather(city, this.units);
if (!result) return { ok: false, error: `Could not find weather for "${city}".` };
return { ok: true, ...result };
}
}
@Module({})
export class WeatherHttpModule {
static register(units: Units): DynamicModule {
return {
module: WeatherHttpModule,
controllers: [WeatherController],
providers: [{ provide: WEATHER_DEFAULT_UNITS, useValue: units }],
};
}
}Register it from the plugin and opt the route out of UCAN auth:
import { RequestMethod, type DynamicModule } from '@nestjs/common';
import { type AuthExcludedRoute } from '@ixo/oracle-runtime';
override getNestModules(ctx: PluginContext): DynamicModule[] {
return [WeatherHttpModule.register(this.units(ctx.config))];
}
override getAuthExcludedRoutes(): AuthExcludedRoute[] {
return [{ path: 'weather/now', method: RequestMethod.GET }];
}Expose shared state with getSharedState
Other plugins can now read the last weather query for the current session:
private readonly lastBySession = new Map<string, LastWeatherQuery>();
override getSharedState(): Record<string, (state: unknown, runCtx: RuntimeContext) => unknown> {
return {
lastWeatherQuery: (_state, runCtx) =>
this.lastBySession.get(runCtx.session.id),
};
}Your tool/sub-agent handlers write into this.lastBySession after every successful lookup (see step 3). Consumers read it as rtCtx.shared.lastWeatherQuery.
Register the plugin in main.ts
import { WeatherPlugin } from './plugins/weather/index.js';
const app = await createOracleApp({
config,
plugins: [new WeatherPlugin()],
});
await app.listen();Reference: apps/qiforge-example/src/main.ts.
Verify every hook works end-to-end
Boot the app (pnpm dev) and exercise each hook.
| Hook | How to verify |
|---|---|
getNestModules + getAuthExcludedRoutes | curl 'http://localhost:3000/weather/now?city=Berlin' returns JSON without a UCAN header. |
manifest.visibility: 'on-demand' | Chat what's the weather in Berlin? — the agent calls list_capabilities and/or load_capability before any weather tool. |
getTools | After weather is loaded, chat what's the temperature in Tokyo? — get_current_weather fires. |
getRequestTools | Chat forecast for São Paulo this week with an x-timezone header — get_weather_forecast fires; handler reads rtCtx.user.timezone. |
getSubAgents + forwardTools | Chat should I bring a jacket to Berlin tomorrow? — main agent calls call_weather_planner_agent, which chains get_weather_forecast then recommend_outfit. Both inner calls show in the UI. |
getMiddlewares | Server logs show model call started / model call complete (Xms) for every weather turn. |
getSharedState | Inside another plugin's tool: rtCtx.shared.lastWeatherQuery returns the latest record for this session. |
configSchema | Set WEATHER_DEFAULT_UNITS=kelvin and boot — fails with a Zod error pointing at the weather plugin. |
Full manual walkthrough: apps/qiforge-example/WEATHER-PLUGIN.md.