A middleware wraps the agent's LLM call. Return one from getMiddlewares(ctx) and it runs on every model turn.
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.
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.
| Hook | Fires | Use for |
|---|---|---|
beforeAgent | Once, before the agent loop starts | One-time setup per turn |
beforeModel | Before every LLM call | Logging, timers, observation |
wrapModelCall | Around every LLM call (you call handler) | Error handling, fallbacks, retries |
wrapToolCall | Around every tool call (you call handler) | Tool-level interception |
afterModel | After every LLM call | Logging, emitting events |
afterAgent | Once, after the agent loop ends | Teardown, 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' }) });
}
},
});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
},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.
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 oncreateOracleApp:page-context(whenhooks.getRoomTitleis provided) andsafety-guardrail(whenhooks.safetyModelis provided). Your plugin middleware is appended after all of these. - Plugin middleware runs in topological dependency order — encode ordering via
dependsOnif 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.