Developer Guide
Hooks: cross-platform mapping#
Hooks are the surface that varies most across hosts — every platform names the lifecycle events differently, supports a different subset of them, and signals a deny/decision in its own shape. You write one handler per canonical event; agent-connector renders it into each host's native hook. This page is the precise, visible map.
The single-wrapper hook API#
In defineConnector({ hooks }) you declare one handler per normalized event (the 13 canonical events). The framework looks at each detected host's paradigm and synthesizes the right delivery; a universal home-bin hook entrypoint dispatches the payload into your one handler and formats the reply back into the host's native control surface. The union is the cross-platform floor, not a ceiling — host-only events (Claude Code alone ships 30) are reachable per platform via the nativeHooks passthrough.
Your handler is rendered into a native JSON hook entry; the host pipes JSON to the home-bin command and reads the reply.
Your handler is rendered into a synthesized plugin module the host loads; it bridges native lifecycle functions to the home-bin entrypoint.
No hook layer — only the MCP server installs; declared hooks skip-warn (hooks unavailable here).
import { defineConnector } from "@ken-jo/agent-connector/sdk";
export default defineConnector({
// package.json name/mcpName/bin/version provide identity.
// Omit id/displayName/version unless this is a deliberate override.
hooks: {
// ONE handler per canonical event — written once.
PreToolUse: {
matcher: "Bash", // regex on tool name (tool events only)
handler: async (event) => {
if (looksDangerous(event.toolInput)) {
return { decision: "deny", reason: "blocked by policy" };
}
// returning void = allow (the universal default)
},
},
SessionStart: {
handler: async () => ({
decision: "context",
additionalContext: "Project guidelines: …",
}),
},
},
});Degradation rule — graceful skip-warn
If a host has no equivalent for a canonical event (e.g. Gemini CLI has noStop, Cursor has no Notification or PermissionRequest — its permission gate is an output field of its before* hooks, not an observable event), that event is simply never wired — the install/sync diff reports a warn and moves on. Likewise a host that can't honor a decision (no output-rewrite, no ask gate) degrades it (modify → allow, ask → deny) rather than failing. The runtime entrypoint is fail-open: a handler or framework bug can never wedge a host's tool call.The mapping matrix#
Rows are the 13 canonical events; columns are the platforms, grouped by paradigm. A cell shows the native event name the connector writes for that host, or a muted — when the host has no equivalent (graceful skip-warn). The first column is sticky; scroll horizontally for the wider groups.
| Canonical event | claude-code | codex | gemini-cli | kilo-cli | opencode |
|---|---|---|---|---|---|
SessionStart | SessionStart | SessionStart | SessionStart | experimental.chat.system.transform | experimental.chat.system.transform |
SessionEnd | SessionEnd | unsupported | SessionEnd | unsupported | unsupported |
UserPromptSubmit | UserPromptSubmit | UserPromptSubmit | BeforeAgent | chat.message | unsupported |
PreToolUse | PreToolUse | PreToolUse | BeforeTool | tool.execute.before | tool.execute.before |
PostToolUse | PostToolUse | PostToolUse | AfterTool | tool.execute.after | tool.execute.after |
PreCompact | PreCompact | PreCompact | PreCompress | unsupported | unsupported |
Stop | Stop | Stop | unsupported | session.idle | unsupported |
Notification | Notification | unsupported | Notification | unsupported | unsupported |
PermissionRequest | PermissionRequest | PermissionRequest | unsupported | permission.ask | permission.ask |
PostToolUseFailure | PostToolUseFailure | unsupported | unsupported | unsupported | unsupported |
SubagentStart | SubagentStart | SubagentStart | unsupported | unsupported | unsupported |
SubagentStop | SubagentStop | SubagentStop | unsupported | unsupported | unsupported |
PostCompact | unsupported | PostCompact | unsupported | unsupported | unsupported |
namenative event name the connector writesno host equivalent → graceful skip-warnPer-platform detail#
Each tab shows that host's paradigm, hook config path, capabilities (canModifyArgs / canModifyOutput / canInjectSessionContext), the per-event native names, and exactly how a deny/decision is signaled.
Amazon Q Developer CLI
json-stdioHook config path
~/.aws/amazonq/cli-agents/q_cli_default.json (user) / .amazonq/cli-agents/q_cli_default.json (project) — hooks merged into the built-in default agent fileCapabilities
Per-event native names
SessionStart | agentSpawn |
|---|---|
SessionEnd | unsupported |
UserPromptSubmit | userPromptSubmit |
PreToolUse | preToolUse |
PostToolUse | postToolUse |
PreCompact | unsupported |
Stop | stop |
Notification | unsupported |
PermissionRequest | unsupported |
PostToolUseFailure | unsupported |
SubagentStart | unsupported |
SubagentStop | unsupported |
PostCompact | unsupported |
How a decision is signaled
EVENT_MAP camelCase: SessionStart->agentSpawn, UserPromptSubmit->userPromptSubmit, PreToolUse->preToolUse, PostToolUse->postToolUse, Stop->stop. PreCompact/SessionEnd/Notification and all four newer events have no Amazon Q equivalent -> warn-skip (null). Hooks have NO global file; AC merges into the built-in `q_cli_default` agent file (cli-agents/q_cli_default.json) at the install scope — a bare default.json would be an inactive custom agent the user must select (mirrors kiro's default-agent selection); a project install writes a project-scoped q_cli_default that shadows the user-global one. The `hooks` field is a trigger-keyed OBJECT; each entry is FLAT { command, matcher? } (NO `type`; matcher meaningful only for preToolUse/postToolUse). EXIT-CODE protocol (identical to kiro): exit 0 = allow, exit 2 + stderr = deny (ask degrades to deny exit 2). agentSpawn context injection -> exit 0 + stdout { hookSpecificOutput:{ hookEventName:'agentSpawn', additionalContext } }. Cannot rewrite args/output (modify degrades to allow). MCP: ~/.aws/amazonq/mcp.json (user) and .amazonq/mcp.json (project), root 'mcpServers'. BARE stdio entry { command, args?, env?, timeout? } (timeout in ms, NO type/disabled keys); remote/http entry { type: "http", url } (no headers — auth is OAuth).
Amp
ts-pluginHook config path
<projectDir>/.amp/plugins/<connector-id>.ts (auto-loaded TS plugin module; project scope only)Capabilities
Per-event native names
SessionStart | session.start |
|---|---|
SessionEnd | unsupported |
UserPromptSubmit | agent.start |
PreToolUse | tool.call |
PostToolUse | tool.result |
PreCompact | unsupported |
Stop | agent.end |
Notification | unsupported |
PermissionRequest | unsupported |
PostToolUseFailure | unsupported |
SubagentStart | unsupported |
SubagentStop | unsupported |
PostCompact | unsupported |
How a decision is signaled
EVENT_TO_AMP (amp.on targets): SessionStart->session.start (session id = event.thread.id), UserPromptSubmit->agent.start (observe-only — agent.start exposes no block/context surface, so a deny/context decision degrades to a no-op; canInjectSessionContext false), PreToolUse->tool.call (deny/ask -> return amp's documented decision union { action:'reject-and-continue', message }, else { action:'allow' }; canModifyArgs false — the 'modify' input shape is undocumented), PostToolUse->tool.result (observe-only: the manual says a replacement output CAN be returned but never documents its object shape, so canModifyOutput stays false rather than ship a guessed mutation; error signal = event.status==='error'), Stop->agent.end (observe-only). Amp documents NO session.end -> SessionEnd null; PreCompact/Notification + all four newer events null too. Loads a TS plugin (.amp/plugins/<id>.ts; default export (amp)=>void registering amp.on handlers), PROJECT scope only — no user-scope plugins dir is documented, so a user install warn-skips. supportsNativeHooks: platforms.amp.nativeHooks amp.on events bridged verbatim (host-generic runNativeHook dispatch). MCP native ~/.config/amp/settings.json under the FLAT dotted key 'amp.mcpServers' (not nested mcpServers); native ${VAR} interpolation. Bridge shells to <homeBin> hook amp <event> --connector <id>; formatReply emits the NORMALIZED HookResponse.
Cline
mcp-onlyHook config path
—Capabilities
How a decision is signaled
Cline VS Code extension (saoudrizwan.claude-dev — the PARENT kilo forked). mcp-only: no hook system; installHooks 'skip'; all events null. MCP only: VS Code globalStorage <userDir>/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json (NO project MCP file), root 'mcpServers'. Content surfaces: memory → .clinerules/agent-connector.md, commands → .clinerules/workflows/, skills → .clinerules/skills/. All hook capabilities false.
Claude Code ↔ Kilo CLI: same position?#
These two hosts sit in different paradigms — claude-code is the reference json-stdio host (settings hooks); kilo-cli is a ts-plugin host (a generated @kilocode/plugin module with tool.execute.* handlers). The question: when you declare a PreToolUse hook once, do they end up in the same position? Here is each canonical event side by side.
One handler, two native renderings of the headline PreToolUse hook — toggle to flip Claude Code ↔ Kilo CLI in the same spot and compare.
~/.claude/settings.json (under "hooks", keyed by event)canModifyArgscanModifyOutputcanInjectSessionContextPreToolUse// ~/.claude/settings.json
// --connector acme-db is package-derived during install; it is not another
// defineConnector({ id }) value users enter by hand.
{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "agent-connector hook claude-code PreToolUse --connector acme-db"
}
]
}
]
}
}| Canonical event | claude-code (json-stdio) | kilo-cli (ts-plugin) | Alignment |
|---|---|---|---|
SessionStart | SessionStart | experimental.chat.system.transform | same hook, different shapeBoth support it, but via very different mechanics: Claude writes a SessionStart settings hook; Kilo synthesizes an experimental.chat.system.transform plugin handler that injects additionalContext into the system block. |
SessionEnd | SessionEnd | unsupported | claude-code onlyClaude has SessionEnd 1:1; Kilo's plugin surface has no equivalent → skip-warn. |
UserPromptSubmit | UserPromptSubmit | chat.message | same hook, different shapeBoth support it via different mechanics: Claude writes a UserPromptSubmit settings hook; Kilo maps it to the OpenCode-fork chat.message plugin handler, which pushes a {type:'text'} part onto output.parts to inject additionalContext. Kilo's chat.message has no block/abort, so a deny decision degrades to a no-op (context-injection only). |
PreToolUse | PreToolUse | tool.execute.before | same hook, different shapeThe headline pair. Claude → a PreToolUse command in hooks.json that replies with hookSpecificOutput{ permissionDecision }. Kilo → a tool.execute.before plugin handler that throws to deny and mutates output.args to modify. Same handler, two native shapes. |
PostToolUse | PostToolUse | tool.execute.after | same hook, different shapeClaude → PostToolUse command (canModifyOutput false — cannot rewrite emitted output). Kilo → tool.execute.after handler that CAN mutate output.output (canModifyOutput true). |
PreCompact | PreCompact | unsupported | claude-code onlyClaude has PreCompact 1:1; Kilo has no equivalent → skip-warn. |
Stop | Stop | session.idle | same hook, different shapeBoth wire it, differently. Claude → a Stop command in settings.json. Kilo → session.idle ('session finished responding'), dispatched through the generic event hook (event.type switch), where a deny throws to halt. session.idle is doc-listed for the Kilo CLI/VS Code extension but its runtime firing is not separately verified — if it never fires, Stop silently no-ops (an acceptable degrade, never a mis-fire). |
Notification | Notification | unsupported | claude-code onlyClaude has Notification 1:1; Kilo has no equivalent → skip-warn. |
PermissionRequest | PermissionRequest | permission.ask | same hook, different shapeBoth gate permissions. Claude → a PreToolUse command returning hookSpecificOutput{ permissionDecision }. Kilo → a decision-capable permission.ask plugin handler that MUTATES output.status (deny→"deny", ask→"ask", allow/void leave the default) — it returns no value, mirroring tool.execute.before mutating output.args. Same decision, two native shapes. |
PostToolUseFailure | PostToolUseFailure | unsupported | claude-code onlyClaude has PostToolUseFailure 1:1 (context-only — the tool already failed); Kilo has no failure event (errors only surface on the session bus) → skip-warn. |
SubagentStart | SubagentStart | unsupported | claude-code onlyClaude has SubagentStart 1:1 (context injected into the subagent's conversation); Kilo's subagents run as child sessions with no dedicated hook → skip-warn. |
SubagentStop | SubagentStop | unsupported | claude-code onlyClaude has SubagentStop 1:1 (deny = top-level block that keeps the subagent running); Kilo has no equivalent → skip-warn. |
This comparison covers the 12 events at least one of these two hosts wires. The 13th canonical event, PostCompact, is a no-op on both claude-code and kilo-cli (neither has a native equivalent), so it is omitted here rather than shown as a pair of empty cells.
The same position, two renderings
For the events both hosts support, you write one handler. The framework places it at the host's native position — aPreToolUse command in settings.json for Claude Code, a tool.execute.before handler in a synthesized @kilocode/plugin module for Kilo CLI — and both shell back to the same home-bin entrypoint (agent-connector hook <platform> PreToolUse --connector <id>). They line up on PreToolUse / PostToolUse / SessionStart; they diverge on output-rewrite (Kilo can rewrite tool output, Claude can't) and on the six lifecycle events Kilo's plugin surface simply doesn't expose.Where they line up vs differ
- Line up:
PreToolUse,PostToolUse,SessionStart— both wire all three (Claude via settings hook commands, Kilo via plugin handlers), both deny/inject-context, both dispatch your one handler over the same home-bin entrypoint. - Differ — output rewrite: Kilo's
tool.execute.aftercan mutateoutput.output(canModifyOutput: true); Claude'sPostToolUsecannot (canModifyOutput: false). - Differ — lifecycle coverage: Claude maps 12 of the 13 canonical events 1:1 (only
PostCompacthas no Claude analog); Kilo's plugin surface exposes the two tool events, a SessionStart surrogate (experimental.chat.system.transform), plusUserPromptSubmit(chat.message),Stop(session.idle, via the genericeventhook) andPermissionRequest(permission.ask, the decision-capable gate). OnlySessionEnd,PreCompact,Notificationand the three remaining newer events (PostToolUseFailure,SubagentStart,SubagentStop) skip-warn on Kilo.