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

# Write a plugin

> Build the Weather plugin end-to-end. Every OraclePlugin hook against Open-Meteo (no API key required).

## 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](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/weather.plugin.ts).

```ts theme={"system"}
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.

<Tabs>
  <Tab title="Class (extends OraclePlugin)">
    Subclass and `override` each hook. Per-session state lives on `private` fields. Register with `new WeatherPlugin()`.

    ```ts theme={"system"}
    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
    }
    ```

    ```ts theme={"system"}
    plugins: [new WeatherPlugin()],
    ```
  </Tab>

  <Tab title="POJO (defineOraclePlugin)">
    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`.

    ```ts theme={"system"}
    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),
      }),
    });
    ```

    ```ts theme={"system"}
    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`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/packages/oracle-runtime/src/plugin-api/define-plugin.ts).
  </Tab>
</Tabs>

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.ts` calling `createOracleApp` — see [Create your oracle](/build-an-oracle/develop/create-oracle-app).
* The oracle scaffolded by the CLI (`qiforge-cli new`) — see the [CLI reference](/build-an-oracle/reference/cli).
* Files live under `src/plugins/weather/` — same layout as the reference [apps/qiforge-example/src/plugins/weather/](https://github.com/ixoworld/ixo-oracles-boilerplate/tree/main/apps/qiforge-example/src/plugins/weather).

## Step-by-step

<Steps>
  <Step title="Create the skeleton with a manifest and config schema">
    File: [`weather.plugin.ts`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/weather.plugin.ts)

    ```ts theme={"system"}
    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.
  </Step>

  <Step title="Write the upstream client">
    File: [`weather-client.ts`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/weather-client.ts). Open-Meteo doesn't need auth.

    ```ts theme={"system"}
    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.
  </Step>

  <Step title="Add a boot-time tool with getTools">
    File: [`weather-tools.ts`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/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.

    ```ts theme={"system"}
    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:

    ```ts theme={"system"}
    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.
  </Step>

  <Step title="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:

    ```ts theme={"system"}
    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(),
          }),
        },
      );
    }
    ```

    ```ts theme={"system"}
    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.
  </Step>

  <Step title="Add a sub-agent with getSubAgents">
    File: [`weather-sub-agent.ts`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/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.

    ```ts theme={"system"}
    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,
      };
    }
    ```

    ```ts theme={"system"}
    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.
  </Step>

  <Step title="Add a middleware with getMiddlewares">
    File: [`weather-middleware.ts`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/weather-middleware.ts).

    Middleware runs around every LLM call. Hooks come from LangChain's `createMiddleware`.

    ```ts theme={"system"}
    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)`);
        },
      });
    }
    ```

    ```ts theme={"system"}
    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](/build-an-oracle/develop/plugin-recipes/add-a-middleware).
  </Step>

  <Step title="Add an HTTP route with getNestModules + getAuthExcludedRoutes">
    File: [`weather.module.ts`](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/plugins/weather/weather.module.ts).

    To expose a public `GET /weather/now?city=X`, ship a NestJS module:

    ```ts theme={"system"}
    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:

    ```ts theme={"system"}
    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 }];
    }
    ```
  </Step>

  <Step title="Expose shared state with getSharedState">
    Other plugins can now read the last weather query for the current session:

    ```ts theme={"system"}
    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`.
  </Step>

  <Step title="Register the plugin in main.ts">
    ```ts theme={"system"}
    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](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/src/main.ts).
  </Step>

  <Step title="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](https://github.com/ixoworld/ixo-oracles-boilerplate/blob/main/apps/qiforge-example/WEATHER-PLUGIN.md).
  </Step>
</Steps>

## Where to read next

<CardGroup cols={2}>
  <Card title="Test your plugin" icon="vial" href="/build-an-oracle/develop/test-your-oracle">
    Tier A direct-invoke and Tier B agent-loop tests.
  </Card>

  <Card title="Plugin recipes" icon="screwdriver-wrench" href="/build-an-oracle/develop/plugin-recipes/add-a-tool">
    Per-hook deep dives (tool, sub-agent, middleware, HTTP, state).
  </Card>

  <Card title="Plugin API reference" icon="code" href="/build-an-oracle/reference/plugin-api">
    Every hook signature, exhaustively.
  </Card>

  <Card title="Plugin anatomy" icon="diagram-project" href="/build-an-oracle/understand/plugin-anatomy">
    How the runtime turns a class into a registered plugin.
  </Card>
</CardGroup>
