This document describes the planned plugin/hook surface. Phase 0 —
the typed event catalog and per-agent event bus — has landed in
apps/agent/src/events.ts. Subsequent phases below are not yet
implemented.
OpenHermit has several extension points already (skills, MCP servers,
channel adapters, web providers, exec backends), but each is its own
shape. There is no way to inject in-process code that runs at well-defined
points in the agent lifecycle — for example, a fast local tool that
shouldn't pay the MCP round-trip, a policy gate that blocks specific
tool calls beyond what security.json expresses, or a side-effect that
fires after every turn (metrics, integrations, audit).
A plugin is the missing piece: a small, named bundle of code that runs inside the agent runtime and subscribes to lifecycle events.
AgentEventBus is a per-agent typed event bus. Every running
AgentRunner will own one instance. Events are versioned (e.g.
prompt.assemble@v1); future schema changes ship as @v2 so existing
plugins keep working.
Three hook shapes:
| Shape | Return type | Use case |
|---|---|---|
listener |
void |
Logs, metrics, fire-and-forget side effects |
transform |
payload |
Mutate prompt sections, rewrite tool args, edit channel messages |
veto |
{allow, reason?} |
Block a tool call; finer than security.json policy |
Events ordered by priority (lower runs first). Throwing handlers are
dropped from the chain by default and surface as plugin.error@v1;
manifests can opt into failureMode: 'fail' to abort the turn.
Current event catalog (apps/agent/src/events.ts):
agent.started@v1 listener
agent.stopped@v1 listener
session.opened@v1 listener
session.closed@v1 listener
session.message.received@v1 transform
prompt.assemble@v1 transform
model.before@v1 transform
model.after@v1 listener
tool.before@v1 veto
tool.after@v1 listener
channel.message.in@v1 transform
channel.message.out@v1 transform
schedule.fired@v1 transform
memory.upsert@v1 listener
plugin.error@v1 listener
Refactor existing in-runner code (compaction, channel outbound, schedule trigger, prompt assembly) to emit/transform on the bus. This is a pure refactor — no new feature, no observable behavior change. Goal: prove the event payloads are right under real load before committing to them as a public API.
A plugins global table + agent_plugins per-agent assignment table
(mirroring agent_skills / agent_mcp_servers, including wildcard
agent_id = '*' support). Each plugin is a directory:
plugins/my-policy/
manifest.json
index.ts # export register(ctx): void
register(ctx) receives a scoped API:
ctx.bus.on('tool.before@v1', handler, { priority })ctx.tools.add({ name, schema, handler })— register an in-process toolctx.memory,ctx.session,ctx.logger— read-only slices of the runner state- No direct DB access — mediated through scoped interfaces so schema upgrades don't break plugins.
CLI mirrors skills:
hermit plugins list
hermit plugins register <id> --name ... --description ... --path ...
hermit plugins enable <id> --agent <id>
hermit plugins enable <id> --all
hermit plugins disable <id> --all
hermit plugins delete <id>Reuse the existing channel webhook ingress
(POST /api/agents/:id/channels/:namespace/webhook). A plugin
subscription becomes "POST event payload to this URL; expect a
mutation/decision back." This makes plugins language-agnostic and
sandboxable, at the cost of HTTP latency — so it's restricted to
non-hot-path events (tool.after, schedule.fired,
session.closed, memory.upsert); hot-path transforms
(prompt.assemble, tool.before) stay in-process only.
- Per-agent instance, never singleton. Plugin state must not leak across agents in the same gateway.
- Versioned payloads.
event@v1is forever; new shapes areevent@v2. Both are dispatched in parallel during the deprecation window. - Default failure mode = skip + log. Plugins must not be able to bring down a turn unless they explicitly ask to.
- No plugin → DB shortcuts. All persistence goes through the
scoped
ctx.*interfaces. Direct Drizzle access from a plugin is a review red flag. - Skills, MCP, plugins are orthogonal. A skill is prompt content; an MCP server is a remote tool catalog; a plugin is in-process reactive code. Don't try to unify them — the cost outweighs the tidiness.
- Sandboxing. Phase 2 runs plugin code in the gateway's Node process. Worker threads or vm-isolation can come later if we accept user-supplied (vs. admin-vetted) code.
- Cross-plugin ordering. Priority ties are broken by registration order; explicit dependency declarations may be needed if plugin ecosystems develop.
- Event payload immutability. Transform handlers currently get raw mutable payloads. Frozen payloads (with required clone-on-write) would prevent accidental cross-handler mutation but cost a clone per hook.