Skip to main content
A middleware wraps the agent’s LLM call. Return one from getMiddlewares(ctx) and it runs on every model turn.
1

Build the middleware with createMiddleware

Import createMiddleware from langchain. Set a name (appears in error messages) and any of the six hooks.
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)`);
    },
  });
}
Canonical source: weather-middleware.ts.
2

Pick the right hook

createMiddleware accepts exactly six hooks. Pick the one whose timing matches your work — there is no onError, no beforeToolCall, no afterToolCall.
HookFiresUse for
beforeAgentOnce, before the agent loop startsOne-time setup per turn
beforeModelBefore every LLM callLogging, timers, observation
wrapModelCallAround every LLM call (you call handler)Error handling, fallbacks, retries
wrapToolCallAround every tool call (you call handler)Tool-level interception
afterModelAfter every LLM callLogging, emitting events
afterAgentOnce, after the agent loop endsTeardown, final accounting
createMiddleware({
  name: 'MyMiddleware',
  beforeModel: async () => {
    // Loading context, observation
  },
  afterModel: async () => {
    // Logging, emitting events
  },
});
Error handling lives in wrapModelCall: wrap the handler(request) call in a try/catch and retry with a fallback model. The hook owns the call, so you decide what happens on failure.
import { ChatOpenAI } from '@langchain/openai';

createMiddleware({
  name: 'ModelFallbackMiddleware',
  wrapModelCall: async (request, handler) => {
    try {
      return await handler(request);
    } catch (err) {
      ctx.logger.warn(`primary model failed: ${String(err)} — falling back`);
      return handler({ ...request, model: new ChatOpenAI({ model: 'gpt-4o-mini' }) });
    }
  },
});
3

Return undefined or jumpTo — never a state object

The flow-control hooks (beforeAgent, beforeModel, afterModel, afterAgent) must return undefined (pass through) or { jumpTo: 'end' as const } (short-circuit the turn). Never return { messages: ... } or any other state channel. (The wrap* hooks are different — they return the result of calling handler, as in the fallback example above.)
Returning a partial state object from a plugin middleware breaks LangGraph checkpointer thread continuity — the observed symptom is a brand-new thread spawned on every message. Message and state rewrites belong in the transport layer (the messages controller), not in a middleware hook.
beforeModel: async (state) => {
  if (shouldStop(state)) {
    return { jumpTo: 'end' as const }; // stop the loop early
  }
  return undefined; // otherwise pass through
},
4

Return it from getMiddlewares(ctx)

The runtime appends your middleware after the framework’s built-in middleware (see the list below).
import { OraclePlugin, type AgentMiddleware, type PluginContext } from '@ixo/oracle-runtime';

export class WeatherPlugin extends OraclePlugin {
  override getMiddlewares(ctx: PluginContext): AgentMiddleware[] {
    return [buildWeatherMiddleware(ctx)];
  }
}
See getMiddlewares in weather.plugin.ts.
5

Attach a middleware to a sub-agent (optional)

A sub-agent can carry its own middleware — it only runs inside that sub-agent’s loop.
import { createSummarizationMiddleware } from '@ixo/oracle-runtime';

const subAgent: PluginSubAgent = {
  name: 'long_research_agent',
  // ...
  middlewares: [createSummarizationMiddleware()],
};

What to know before shipping

  • Four always-on middleware run first and are not removable, in this order: capability-gate, tool-validation, tool-repetition-guard, tool-retry. Two more run only when the matching hook is set on createOracleApp: page-context (when hooks.getRoomTitle is provided) and safety-guardrail (when hooks.safetyModel is provided). Your plugin middleware is appended after all of these.
  • Plugin middleware runs in topological dependency order — encode ordering via dependsOn if it matters.
  • Middleware fires per LLM turn, not per individual tool invocation. Wrap the tool handler directly for per-tool behaviour.
  • Closure-scoped timers interleave across concurrent model calls. For accurate timing, push start times onto a per-call ID.
  • Don’t reimplement auth in a middleware — auth runs at the HTTP layer (AuthHeaderMiddleware), not in the agent loop.

Add a sub-agent

Sub-agent-scoped middleware.

State schema

What’s in state when your hooks fire.