Composable middleware pipeline for OpenCode subagent tasks
npm install opencode-middlewareAn OpenCode plugin that wraps subagent task execution with composable middleware. Middleware is declared on the agent being executed, not the caller. It shapes input before the task runs and output after it completes.
The plugin registers two hooks with OpenCode:
1. tool.execute.before — fires before a task tool executes
- Reads the agent name from args.subagent_type
- Looks up which middleware the agent declares
- Runs before hooks sequentially (outside-in: A → B → C)
- Before hooks can mutate args to shape the task's input
2. tool.execute.after — fires after a task tool completes
- Looks up the middleware established during the before phase (correlated by callID)
- Runs after hooks sequentially in reverse order (inside-out: C → B → A)
- After hooks can mutate title, output, and metadata
For middleware declared [A, B, C] on an agent:
```
A.before → B.before → C.before
← task executes →
C.after → B.after → A.after
Callers never know middleware ran. They see the final output.
Each middleware is a .ts file with a default export containing optional before and after hooks:
`typescript`
export default {
after: async (ctx, result) => {
const checks = (ctx.config.checks ?? []) as string[]
const findings = checks.map((check) => ({
check,
mentioned: result.output.toLowerCase().includes(check.toLowerCase()),
}))
const passed = findings.every((finding) => finding.mentioned)
const summary = passed ? "All checks passed." : "Some checks missing."
result.output = summary + "\n\n" + result.output
},
}
The filename becomes the registry key. auditor.ts registers as "auditor".
`typescript`
interface MiddlewareModule {
before?: (ctx: MiddlewareContext, args: { args: unknown }) => Promise
after?: (ctx: MiddlewareContext, result: { title: string; output: string; metadata: unknown }) => Promise
}
Both hooks mutate their second argument in place.
ctx carries:
- client — the OpenCode SDK clientsession.id
- — the OpenCode session IDsubtask.agent
- — the agent being executed (e.g., "red")subtask.prompt
- — what the agent was asked to doconfig
- — per-entry config from agent frontmatter
Before hooks shape input:
- Inject additional instructions into args
- Throw to abort execution
After hooks shape output:
- Append audit results or summaries to output
- Rewrite the title
- Attach metadata
Middleware is declared in agent frontmatter under options.middleware:
`yaml`red.md
---
description: Write one failing test
mode: subagent
options:
middleware:
- run: auditor
config:
checks:
- aaa
- single_test
---
- run — middleware name, resolved from the registry by filename
- config — arbitrary config injected as ctx.config
- Order matters — determines before/after execution order
Middleware files live on disk. By default, the plugin scans ~/.config/opencode/middleware/.
Directories are scanned recursively — organize middleware into subdirectories as you see fit:
``
~/.config/opencode/middleware/
auditing/
auditor.ts → registered as "auditor"
compliance.ts → registered as "compliance"
distilling/
distiller.ts → registered as "distiller"
learner.ts → registered as "learner"
The filename (without .ts) becomes the registry key regardless of subdirectory depth. Duplicate basenames across subdirectories are an error.
To scan additional directories, create ~/.config/opencode/middleware.json:
`json`
{
"directories": [
"~/.config/opencode/middleware",
"/path/to/project/middleware"
]
}
directories replaces the default entirely. Include the default path if you still want it scanned.
`bash`
npm install
npm test # unit tests
npm run test:integration # unit + integration tests
npm run build # compile to dist/
Add the plugin to your project's opencode.json:
`json`
{
"plugin": ["/absolute/path/to/middleware"]
}
OpenCode runs on Bun, which imports TypeScript directly — no build step needed for local use.
The examples/ directory contains working samples:
- examples/middleware/auditor.ts — after hook that checks output against configurable criteria and prepends an audit summaryexamples/middleware/tag.ts
- — after hook that appends a configurable label to outputexamples/agents/red.md
- — agent frontmatter declaring middleware with per-entry configexamples/opencode.json
- — plugin registrationexamples/middleware.json
- — config override pointing at the examples directory
``
src/
index.ts — entry point (default export, wires production deps)
chain/
runner.ts — BeforeHook, AfterHook, MiddlewareModule, runBefore, runAfter
builder.ts — ChainEntry, buildChain (binds config + context to hooks)
registry/
registry.ts — Registry, createRegistry (name → MiddlewareModule lookup)
loader/
loader.ts — FileSystem, ModuleLoader, loadMiddleware (directory scanning)
config.ts — ConfigFile, MiddlewareConfig, readConfig
plugin/
hook.ts — createMiddlewareHooks (before + after handlers, callID correlation)
agent-provider.ts — createAgentProvider (agent name → middleware config)
plugin.ts — createPlugin (top-level wiring)
platform/
node.ts — Node.js implementations of FileSystem, ModuleLoader, ConfigFile
PluginDependencies (FileSystem, ModuleLoader, ConfigFile) are injected interfaces. Production implementations in src/platform/node.ts wrap Node's fs.readdir, import(), and fs.readFile`. Tests substitute fakes.