Proposal: Portable Hooks Component Type #54
Replies: 1 comment
|
Supporting implementation signal from another use case: I maintain a personal control plane for six isolated Codex agents. Each workspace currently has a near-duplicate This is independent evidence for the portable event + command model proposed here. It also exposes a capability that the observe-only scope does not cover: context-producing hooks. For this use case, the hook is non-blocking, but its bounded output is intentionally contributed to the agent context. Declaring that hook observational and requiring its output not to affect the run would remove the reason the hook exists. Could the specification either:
This does not require standardizing every native payload. A narrow result envelope could carry only UTF-8 context text plus optional metadata, while clients retain authority over limits, trust, display, and rejection. Clients without context-contribution support could skip that entry using the same failure-isolation model proposed here. I would adopt a portable hooks declaration for the shared-plugin/per-workspace-manifest pattern and can contribute a conformance fixture for this multi-workspace |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
At Atomic, the
atomic agent enablecommand installs a plugin for each agent you use so we can capture the provenance of the session. It does that by tapping into the lifecycle hooks exposed by each agent.When a session starts, we record its tool calls, turn starts, turn ends, and the changes the agent produces. We store that activity alongside the change, so you can look back later and see what the agent did and how it got there.
Agent Plugins 1.0 excluded hooks because the formats had not converged. Our data suggests the semantics have, even if the syntax has not. Across 12 clients, we found the same five or six lifecycle events hiding behind different names, config formats, and delivery mechanisms. One event, "the agent finished a turn," is called
Stop,stop,session.idle,TaskComplete,post_cascade_response,turn_end, oragent-stop, depending on the client.The events are already consistent enough to standardize the declaration. We do not need to standardize execution, payloads, permissions, or blocking behavior yet.
The problem
Skills and MCP servers travel. Lifecycle behavior does not.
A plugin that needs to run a command at session start, turn end, or after a tool call has to be repackaged for every client. That includes plugins used for recording, provenance, telemetry, cache warming, and formatting. Every client has its own way to say, "run this when that happens."
We maintain exactly this kind of plugin for Atomic VCS. Supporting it required 12 separate integration packages, one per client. The skills were portable and are now shared. The hooks account for nearly all of the remaining client-specific work.
What we had to build
Every integration reduces to the same declaration: "on event X, run command Y." The only difference is how each client wants that declaration delivered.
~/.claude/settings.json~/.codex/hooks.json~/.cursor/hooks.jsonbashkey andtimeoutSec~/.copilot/hooks/atomic.json~/.grok/hooks/atomic.json~/.config/opencode/plugins/*.ts~/.config/kilo/plugin/*.ts~/.pi/agent/extensions/*.ts~/Documents/Cline/Hooks/<Event>,~/.cline/hooks/<Event>~/.codeium/windsurf/~/.gemini/config/plugins/.kiro/hooks/There are three delivery mechanisms here: declarative JSON, executable files, and code plugins. All 12 still reduce to the same declaration. Even the TypeScript plugins for OpenCode, Kilo, and Pi are thin adapters that invoke a CLI command when an event fires.
The delivery mechanism changes, but the declaration does not.
Clients already expose the same events
Actual event names used by our production integrations:
session-startturn-startpre-tool-usepost-tool-useturn-endsession-endNo client supports all six events, but every client supports at least two. Unsupported events can be skipped the same way the spec already skips unsupported MCP transports.
The timing is not identical everywhere. OpenCode's
session.idlefires when the session becomes idle rather than on an explicit stop, and Devin's tool events only cover write and run operations. That should not block the contract. Each event can be defined in plain terms, and clients can map it to the closest event they expose.What we learned
These are the parts that mattered in production:
Start with observe-only hooks. Some clients let hooks block or gate actions, but most do not, and the behavior differs between clients. Our hooks only observe. Keeping the first version non-blocking avoids adding a permission model and leaves execution, sandboxing, and policy to the client.
Hooks need to be easy to skip. Hooks may be installed globally but only matter in certain projects. Every command we install starts with a guard such as
test -d .atomic && ... || true. Doing nothing should be normal, and a failed hook should never break the session.One broken hook should not break the plugin. If a client does not support an event, or a command fails, the rest of the plugin should keep working. The spec already uses this pattern elsewhere.
Payloads can come later. Our commands already handle each client's native stdin payload. It is messy, but it works. The payload format can be standardized later without changing
hooks.json.Install and uninstall should be safe to repeat. Installers should merge without duplicating entries and remove only what they added. That belongs in installer guidance, not the hook contract.
Proposed scope
A new optional component next to
mcp.json:Fixed location:
hooks.jsonat the plugin root.{ "$schema": "https://agent-plugins.org/schemas/1.1.0/hooks.schema.json", "hooks": { "session-start": [ { "command": "./scripts/on-session-start.sh" } ], "turn-end": [ { "command": "./scripts/on-turn-end.sh", "args": ["--data", "${PLUGIN_DATA}/state"] } ], "post-tool-use": [ { "command": "atomic", "args": ["agent", "hooks", "record", "post-tool"] } ] } }The rules reuse what the spec already defines:
Schema. Closed,
$schema-versioned, and structured likemcpServersin §7.2.1.Events. Six names:
session-start,turn-start,pre-tool-use,post-tool-use,turn-end, andsession-end. Clients map each one to the closest event they support.Commands. Use the same rules as stdio MCP servers: one executable token, bare or
./-relative, withargs,env,cwd,${PLUGIN_ROOT}, and${PLUGIN_DATA}. The containment rules from §4.1 still apply.Non-blocking. The command's exit status, output, or absence must not alter agent behavior. Blocking and gating are out of scope.
Failures. Unsupported events and failed commands are skipped without affecting other entries.
Payloads. Whatever the client passes through stdin or environment variables remains client-defined for now.
What about clients without command hooks?
Three of our twelve clients, OpenCode, Kilo, and Pi, have no shell-hook mechanism. Lifecycle behavior requires a TypeScript plugin that subscribes to an event bus.
That is not a blocker. Those clients just need an adapter from their event bus to the command in
hooks.json.There are three ways that can work:
Native adoption. The client reads
hooks.json, maps portable events to its own event bus, and invokes the declared command. It already has the event system and knows how to spawn subprocesses for stdio MCP.A generic bridge plugin. A single plugin for that client can read installed plugins'
hooks.jsonfiles, subscribe to native events, and run the declared commands. Our OpenCode, Kilo, and Pi integrations already work this way, but they are currently hardcoded to Atomic's commands.Compile at install time. An installer can generate the TypeScript plugin, executable files, or merged JSON that the client expects. This is what our installer already does across all twelve clients.
The adapter is per client, not per plugin. Without
hooks.json, every plugin author has to rebuild the same integration. With it, the translation only needs to be written once.What is out of scope
Adoption
Atomic can adopt the format in our installer, contribute the portability matrix, publish bridge plugins for clients that need them, and add test fixtures for supported, partially supported, and unsupported cases.
Three tools already use this model: Atomic's installer across 12 clients, Hookbridge for Claude Code and Codex, and BitFun's support for the Codex hook contract. Each takes one declaration and turns it into the format the client expects.
Reference implementation
Open source and in production:
atomic-agent/src/integrations/)atomic-{claude,codex,opencode,copilot,cursor,cline,devin,grok,kilo,kiro,pi,agy}under https://github.com/atomicdotdevIf maintainers are interested, I can follow this with a formal spec patch.
Disclosure: AI helped organize the data and draft this proposal.
All reactions