Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.ixo.world/llms.txt

Use this file to discover all available pages before exploring further.

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.

Prerequisites

Step-by-step

1

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.
2

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.
3

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.
4

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.
5

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.
6

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 (tool validation, retry, page context, safety guardrail), in topological dependency order across plugins.
7

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 }];
}
8

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.
9

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.
10

Verify every hook works end-to-end

Boot the app (pnpm dev) and exercise each hook.
HookHow to verify
getNestModules + getAuthExcludedRoutescurl 'http://localhost:5678/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.
getToolsAfter weather is loaded, chat what's the temperature in Tokyo?get_current_weather fires.
getRequestToolsChat forecast for São Paulo this week with an x-timezone header — get_weather_forecast fires; handler reads rtCtx.user.timezone.
getSubAgents + forwardToolsChat 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.
getMiddlewaresServer logs show model call started / model call complete (Xms) for every weather turn.
getSharedStateInside another plugin’s tool: rtCtx.shared.lastWeatherQuery returns the latest record for this session.
configSchemaSet 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.

Test your plugin

Tier A direct-invoke and Tier B agent-loop tests.

Plugin recipes

Per-hook deep dives (tool, sub-agent, middleware, HTTP, state).

Plugin API reference

Every hook signature, exhaustively.

Plugin anatomy

How the runtime turns a class into a registered plugin.