Plugins let you customize Meridian's request/response behavior without modifying core code. Drop a compiled .js file in ~/.config/meridian/plugins/ and restart Meridian, or point plugins.json at a file anywhere on disk (useful for plugins installed as their own npm packages or cloned repos).
Runtime note. The plugin loader uses dynamic
import(). If you run meridian viabun,.tsplugin files work directly; if you run vianode(the default for npm installs), plugins must be compiled to.js. When in doubt, ship.js.
The fastest path: author the plugin in its own repo, compile to JavaScript, and reference the built file from plugins.json.
-
Scaffold a plugin package (TypeScript recommended):
mkdir my-meridian-plugin && cd my-meridian-plugin npm init -y npm install --save-peer @rynfar/meridian npm install --save-dev typescript npx tsc --init
-
Write your plugin (
src/index.ts):import type { Transform, RequestContext } from "@rynfar/meridian" const plugin: Transform = { name: "my-plugin", version: "1.0.0", description: "What this plugin does", onRequest(ctx: RequestContext): RequestContext { return { ...ctx, model: "custom-model" } }, } export default plugin
-
Build it:
npx tsc
-
Tell meridian about it via
~/.config/meridian/plugins.json:{ "plugins": [ { "path": "/absolute/path/to/my-meridian-plugin/dist/index.js", "enabled": true } ] } -
Restart meridian or call
POST /plugins/reload. Visithttp://localhost:3456/pluginsto confirm it loaded.
Plugins export a Transform object. Import the types from @rynfar/meridian:
import type { Transform, RequestContext, ResponseContext, TelemetryContext } from "@rynfar/meridian"
interface Transform {
name: string // Required: unique plugin name
description?: string // Shown in /plugins UI
version?: string // Semver version string
adapters?: string[] // Restrict to specific adapters (omit = all)
// v1 hooks
onRequest?(ctx: RequestContext): RequestContext
onResponse?(ctx: ResponseContext): ResponseContext
onTelemetry?(ctx: TelemetryContext): void
}Called before the request is sent to the Claude SDK. Receives the full request context and returns a modified copy.
Key fields you can modify:
| Field | Type | Description |
|---|---|---|
model |
string |
Claude model name |
messages |
any[] |
Conversation messages |
systemContext |
string? |
System prompt text |
tools |
any[]? |
Client tool definitions |
stream |
boolean |
Streaming preference |
blockedTools |
string[] |
SDK tools to block |
passthrough |
boolean? |
Enable passthrough mode |
supportsThinking |
boolean |
Forward thinking blocks |
metadata |
Record<string, unknown> |
Plugin-to-plugin state |
Example — add a system prompt addendum:
onRequest(ctx) {
return {
...ctx,
systemContext: (ctx.systemContext || "") + "\nAlways respond in Spanish.",
}
}Called after the SDK responds. Modify response content before it's sent to the client.
onResponse(ctx) {
return {
...ctx,
content: ctx.content.filter(block => block.type !== "thinking"),
}
}Observe-only hook for logging/metrics. Return value is ignored.
onTelemetry(ctx) {
console.log(`Request ${ctx.requestId}: ${ctx.inputTokens}in/${ctx.outputTokens}out`)
}Restrict a plugin to specific adapters:
export default {
name: "opencode-only",
adapters: ["opencode"],
onRequest(ctx) { /* only runs for OpenCode requests */ },
}Available adapters: opencode, crush, droid, pi, forgecode, passthrough
Control which plugins load, their order, and enable/disable via ~/.config/meridian/plugins.json:
{
"plugins": [
{ "path": "/Users/me/repos/my-plugin/dist/index.js", "enabled": true },
{ "path": "other-plugin.js", "enabled": false }
]
}Path resolution:
- Absolute paths (e.g.
/Users/me/repos/my-plugin/dist/index.js) are loaded directly. Use this for plugins installed in their own repos or vianpm install. - Relative paths (e.g.
other-plugin.js) are resolved against~/.config/meridian/plugins/and auto-discovered from that directory alongside any files dropped in.
Behavior:
- Array order = execution order in the pipeline
enabled: falsedisables a plugin without deleting the file- Plugins not listed in
plugins.jsonbut present in~/.config/meridian/plugins/are appended at the end, enabled by default - Absolute paths work even when
~/.config/meridian/plugins/doesn't exist — no need to create the auto-scan directory if you only use external plugins
Pass state between hooks using the metadata field:
onRequest(ctx) {
return { ...ctx, metadata: { ...ctx.metadata, startTime: Date.now() } }
},
onResponse(ctx) {
const elapsed = Date.now() - (ctx.metadata.startTime as number)
console.log(`Request took ${elapsed}ms`)
return ctx
}If a plugin throws, it is skipped and the next plugin runs. The proxy never crashes due to a plugin error. Check the /plugins UI for error details.
Transforms are pure functions — hand them a context and assert on the return value. You don't need meridian's pipeline runner for unit tests:
import type { Transform, RequestContext } from "@rynfar/meridian"
import plugin from "./src/index.js"
const baseCtx: RequestContext = {
adapter: "opencode",
body: {},
headers: new Headers(),
model: "sonnet",
messages: [],
stream: false,
workingDirectory: "/tmp",
blockedTools: [],
incompatibleTools: [],
allowedMcpTools: [],
sdkAgents: {},
supportsThinking: false,
shouldTrackFileChanges: true,
leaksCwdViaSystemReminder: false,
metadata: {},
}
// Straight unit test — call your hook directly
const result = plugin.onRequest!({ ...baseCtx, systemContext: "hello" })
console.assert(result.systemContext === "HELLO")For integration-style tests (multiple plugins chained, adapter scoping, error isolation), you can import the runtime helpers:
import { runTransformHook, createRequestContext } from "@rynfar/meridian"
const ctx = createRequestContext({
adapter: "opencode",
body: {},
headers: new Headers(),
model: "sonnet",
messages: [],
stream: false,
workingDirectory: "/tmp",
})
const result = runTransformHook([plugin], "onRequest", ctx, "opencode")Visit http://localhost:3456/plugins to:
- See all discovered plugins and their status
- View which hooks each plugin registers
- View adapter scope
- Reload plugins without restarting
Planned hooks:
onSession— override session resume/undo/diverged decisionsonToolUse— intercept, block, or modify tool calls before SDK executiononToolResult— observe or transform tool results after executiononError— custom error handling, logging, retry decisions
Planned capabilities:
- Plugin npm packages — install via
npm install meridian-plugin-* - Plugin templates —
meridian plugin initscaffolding - Hot reload — pick up changes without restart
- Plugin marketplace — community-curated directory