How do I write my own middleware?¶
When you'd want this¶
Every cross-cutting concern in agentkit is a middleware — tracing, metering, retry, caching, egress checks, audit. When you have your own — redact a secret before it goes on the wire, tag every request with a header, cache on a custom key, add a stopwatch — write one too.
There are two shapes, and picking the right one keeps the code straight.
Assumes ANTHROPIC_API_KEY in the environment
The demo wires providers.claude(...) through an Invoker so
you see the real shape you'll ship. The middlewares themselves
are LLM-agnostic — swap providers.claude for providers.openai
(and set OPENAI_API_KEY) or any other LLMPort without
touching the middleware code.
Working code¶
"""Requires ANTHROPIC_API_KEY in the environment."""
import asyncio
import os
import time
from collections.abc import AsyncIterator
from typing import Any
from agentkit import (
BaseMiddleware,
Call,
ChatRequest,
Handler,
Message,
MiddlewareContext,
Scope,
)
from agentkit.adapters.llm import providers
from agentkit.middlewares import tracing
from agentkit.runtime import Invoker, RunContext, Services
# ── Style 1: BaseMiddleware — for transform / guard / observe.
class Redact(BaseMiddleware):
"""Rewrite outgoing user messages to redact a secret string."""
async def on_request(self, ctx: MiddlewareContext) -> None:
req = ctx.request
redacted = [
Message(role=m.role, content=(m.content or "").replace("SECRET", "[REDACTED]"))
for m in req.messages
]
# `ctx.request = ...` is the writable seam — mutating the list
# in place would not change the unit of work.
ctx.request = ChatRequest(
messages=redacted,
model=req.model,
tools=req.tools,
response_format=req.response_format,
temperature=req.temperature,
max_tokens=req.max_tokens,
)
# ── Style 2: raw (call, next) — for resilience / caching / instrumentation.
async def stopwatch(call: Call, nxt: Handler) -> AsyncIterator[Any]:
"""Time the whole call. Only raw middleware can wrap `next` in a context
manager or re-invoke it — retry/fallback/memoize all live at this layer."""
started = time.perf_counter()
async for item in nxt(call):
yield item
elapsed_ms = (time.perf_counter() - started) * 1000
print(f"[stopwatch] {call.kind} took {elapsed_ms:.2f}ms")
async def main() -> None:
llm = providers.claude(
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-sonnet-4-6",
)
services = Services(
invoker=Invoker(llm=llm, chat_middleware=[tracing(), stopwatch, Redact()]),
)
ctx = RunContext(correlation_id="run-1", scope=Scope(), services=services)
req = ChatRequest(
messages=[Message("user", "the code is SECRET, please echo the redacted form")],
model="claude-sonnet-4-6",
)
result = await ctx.invoker.chat(req, ctx)
print(f"[result] {result.content!r}")
if __name__ == "__main__":
asyncio.run(main())
How it works¶
Every call — a chat turn, a tool execution — is wrapped in a Call
envelope (kind, request, ctx, meta). The Invoker folds a
list of middlewares over a terminal handler with chain(...): the
first entry is outermost, the last sits closest to the LLM. Each
middleware is an async generator over a single streaming contract —
chat calls yield Deltas, tool calls yield one item.
Style 1 — BaseMiddleware¶
Override only the phases you need. Each is async and can be a plain
coroutine OR an async generator:
on_request(ctx)— before: mutatectx.request; raiseBlockedto refuse.on_response(ctx, result)— after success: return / yield a transformed result; default passes through.on_error(ctx, exc)— on failure: return / yield a recovered value, else raise.
buffers = False (the default) streams Deltas through and lets you
observe the assembled result; on_response return is ignored.
buffers = True collects the stream first so on_response can
transform the result — pick this only when you have to; buffering
loses incremental streaming.
Use BaseMiddleware for anything that transforms, guards, or
observes. It cannot re-invoke or wrap next in a context manager.
Style 2 — raw (call, next)¶
A plain async generator taking the Call and the next handler.
Because you drive next(call) yourself, you can:
- Re-invoke it (retry, fallback with a rewritten request).
- Skip it (memoize on a cache hit).
- Wrap it in a context manager (
tracingholds a span open across the whole call).
This is where every resilience, caching, and instrumentation middleware in agentkit lives.
Ordering¶
chain(middlewares, terminal) folds right, so middlewares[0] is
outermost. A canonical chat chain:
chat_middleware = [
tracing(), # outermost — one span covers everything below
compaction(...), # transform: shrink the prompt before meter sees tokens
meter(), # guard/charge every attempt
fallback([...]), # rewrite + re-invoke on hard failures
retry(...), # re-invoke on transient failures
]
Reorder or swap by editing the list. There is no hidden default chain you have to override — the app owns the list.
Gotchas¶
- Mutating
ctx.messagesin place does nothing.MiddlewareContext.messagesreturns a copy. To rewrite the transcript, assign a newChatRequesttoctx.request. buffers=Truedisables incremental streaming. The whole stream is collected,on_responseruns, then the transformed result is re-emitted as one terminal delta. If your user watches token-by-token output, don't buffer.- The two styles compose freely.
chain([tracing(), stopwatch, Redact()])mixes raw andBaseMiddlewarein one list — the fold adaptsBaseMiddlewarevia.as_middleware()internally. - Tool chain vs chat chain.
Invokertakes both (chat_middleware=,tool_middleware=). Retry on the chat chain recovers from provider blips; on the tool chain it recovers from tool crashes — pick where each concern belongs.
Related¶
- Concepts · Middlewares — the mental model of the chain, and the shipped middlewares.
- Example 03
— the canonical
tracing → retry → output_coercecomposition in a runnable script. agentkit.kernel.middlewaremodule docstring — the contract, in code.