What changed: the Sessions page lists sessions seen only through the gateway (from the usage ledger, read-only, a native session of the same id wins), and shows in gateway mode. A Sessions switch, off by default and confirmed before it turns on, records each gateway exchange's text under the config dir (scrubbed, at most 7 days and 256 MiB, cleaned hourly). Settings sync and restore can't turn it on; POST /api/sessions/recording turns it on or off and can clear what was kept. Why: users of magpie as a gateway (gateway mode, LAN clients) had no way to see their conversations; the owner chose this design on the PR. Squashed from PR head 6a343ba1 onto origin/main. Conflicts: gui-shell.md (kept main's placeMenu text, took the PR's Sessions in gateway mode) and settings_keep_test.go (main's fields plus GatewayConversations). Run: go build ./..., go vet on gateway, gui, sessions, settings, usage; go test -race -timeout 900s on those five packages: all ok.
5.9 KiB
Gateway middleware
A plugin can be gateway middleware: JavaScript that magpie runs inside its gateway on the requests agents send and the replies they get back, whatever provider serves them. It is separate from the provider plugins in Provider and plugin ownership, which run on Bun in the plugin host. Middleware runs in the gateway's own process on moejs, a JavaScript engine written in Go, because it sits on every request and every streamed event.
Responsibilities and sources of truth
| Part | Responsibility | Source |
|---|---|---|
| Discovery | Find a plugin's middleware file: magpie.middleware in its package.json, or a path to a *.middleware.js / .mjs file |
plugin.Middleware in internal/plugin/middleware.go |
| Bun host | Skip a plugin that is middleware only (no main, no exports) |
Middleware(...) check in host.go |
| Load and run | Compile, pool runtimes, call hooks with limits, count calls and failures | internal/middleware: middleware.go, run.go |
| Gateway | Run the chain on agents' requests only | serveAgent in internal/gateway/middleware.go, called from gateway.go and codex_backend.go |
| GUI and CLI | Show each middleware's hooks, calls, µs per call, failures and load error | middleware.States(); pluginEntryJSON.Middleware in internal/gui/plugins.go, mwLine in plugins.js, listPlugins in plugins_cli.go |
| Options | A middleware's options in plugins.json, as ctx.options; set by name, spec or short name; a package's magpie.options is only the editor's example |
SetOptions, ShortName in store.go; pluginOptions in plugins_cli.go; optionsEditor in plugins.js |
| Kind | Whether a plugin is a provider, middleware or both, shown on every card and row, also for one that is off | isMiddleware, middlewareOnly in pluginEntryJSON; kind: "middleware" market entries (market.json, the community registry.json); kindChip in plugins.js |
| Community middleware | Ready-made packages (@magpie-community/middleware-<name>), mostly New API's channel settings |
packages/{param-override,model-map,system-prompt,word-guard,think-tags} in magpie-community/plugins, tested with scripts/middleware.mjs |
| User docs | The hooks, ctx and an example for plugin authors |
usemagpie.ai/docs/plugins#middleware (site/public/docs/*plugins.html) |
plugins.json is the source of truth for which middleware is installed and on, in its order; a middleware that is off isn't loaded.
Runtime path
serveAgentestablishes the gateway session identity and optional recording, then callsmiddleware.Beginwith the request's protocol, model, stream flag, path and agent. With no middleware it callsservedirectly.Run.Requestpasses the agent's body, in its own API, to eachonRequestin turn. An object returned is the body the next middleware and the gateway get;undefinedkeeps the bytes as they were.ctx.reject(status, message)stops the chain, and the agent gets that error in its API's shape (writeError).Run.Wrapwraps the response writer when some middleware hasonEventoronResponse. An SSE reply is split into events. Each event whose data is a JSON object goes to everyonEventthat wants its name (export const events): an object replaces it,nulldrops it, andundefinedwrites the original bytes.data: [DONE]and other non-object data pass by. A whole JSON reply is buffered foronResponse, withctx.statusset. A reply with aContent-Encodingpasses as-is.Run.Endreturns the runtimes to their pools.
Each middleware gets one runtime for the whole request, so its hooks share ctx.state. Runtimes come from a per-middleware sync.Pool and modules are compiled once per load.
Only requests agents send go through serveAgent. magpie's own requests (thread titles, the router's classifier, the search and vision stand-ins) call serve directly. Middleware sees bodies before redaction, which still applies upstream.
Constraints and failure behavior
- Limits: 250ms for
onRequestandonResponse, 50ms for eachonEvent, 1s to load. A hook past its limit is interrupted. - Fail open: a hook that throws, returns something that is neither an object,
nullnorundefined, or is interrupted leaves the request or event as it was. The failure is counted and kept as the last error, and logged at most 60 lines a minute. - A middleware imports only relative files; packages must be bundled into it.
- Reload: a change to plugins.json or to a middleware file is picked up by the next request at most a second later (
stamp). - Stats count the whole call, including parsing JSON into the runtime and writing it back. Calls are counted in the process that runs the gateway, so
magpie pluginshows hooks and load errors only.
Measured with a real account and real Claude Code: about 1–1.6µs an event, 0.2–0.7ms an onRequest that parses and rewrites Claude Code's ~80KB body. BenchmarkEvent is about 0.9µs, 11 allocations.
Verification
go test -tags nogui ./internal/middleware
go test -tags nogui -run TestMiddlewareThroughGateway ./internal/gateway
go test -tags nogui -run TestPluginListSaysMiddleware .
go test -tags nogui -run "TestSetOptionsShortName|TestBuiltinMarket" ./internal/plugin
go test -tags nogui -bench Event ./internal/middleware
node --test internal/gui/tests/plugin-middleware.test.cjs
# in magpie-community/plugins: each package runs its cases.json through the hooks
bun test packages/think-tags packages/model-map packages/param-override packages/system-prompt packages/word-guard