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.

json-stdio
24

Your handler is rendered into a native JSON hook entry; the host pipes JSON to the home-bin command and reads the reply.

ts-plugin
8

Your handler is rendered into a synthesized plugin module the host loads; it bridges native lifecycle functions to the home-bin entrypoint.

mcp-only
10

No hook layer — only the MCP server installs; declared hooks skip-warn (hooks unavailable here).

agent-connector.config.mjs
ts
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: …",
      }),
    },
  },
});
agent-connector hook <platform> <event> --connector <id>

Degradation rule — graceful skip-warn

If a host has no equivalent for a canonical event (e.g. Gemini CLI has no Stop, 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 eventclaude-codecodexgemini-clikilo-cliopencode
SessionStartSessionStartSessionStartSessionStartexperimental.chat.system.transformexperimental.chat.system.transform
SessionEndSessionEndunsupportedSessionEndunsupportedunsupported
UserPromptSubmitUserPromptSubmitUserPromptSubmitBeforeAgentchat.messageunsupported
PreToolUsePreToolUsePreToolUseBeforeTooltool.execute.beforetool.execute.before
PostToolUsePostToolUsePostToolUseAfterTooltool.execute.aftertool.execute.after
PreCompactPreCompactPreCompactPreCompressunsupportedunsupported
StopStopStopunsupportedsession.idleunsupported
NotificationNotificationunsupportedNotificationunsupportedunsupported
PermissionRequestPermissionRequestPermissionRequestunsupportedpermission.askpermission.ask
PostToolUseFailurePostToolUseFailureunsupportedunsupportedunsupportedunsupported
SubagentStartSubagentStartSubagentStartunsupportedunsupportedunsupported
SubagentStopSubagentStopSubagentStopunsupportedunsupportedunsupported
PostCompactunsupportedPostCompactunsupportedunsupportedunsupported
namenative event name the connector writesno host equivalent → graceful skip-warn

Per-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.

json-stdio
24

Amazon Q Developer CLI

json-stdio

Hook 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 file

Capabilities

canModifyArgscanModifyOutputcanInjectSessionContext

Per-event native names

SessionStartagentSpawn
SessionEndunsupported
UserPromptSubmituserPromptSubmit
PreToolUsepreToolUse
PostToolUsepostToolUse
PreCompactunsupported
Stopstop
Notificationunsupported
PermissionRequestunsupported
PostToolUseFailureunsupported
SubagentStartunsupported
SubagentStopunsupported
PostCompactunsupported

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).

ts-plugin
8

Amp

ts-plugin

Hook config path

<projectDir>/.amp/plugins/<connector-id>.ts (auto-loaded TS plugin module; project scope only)

Capabilities

canModifyArgscanModifyOutputcanInjectSessionContext

Per-event native names

SessionStartsession.start
SessionEndunsupported
UserPromptSubmitagent.start
PreToolUsetool.call
PostToolUsetool.result
PreCompactunsupported
Stopagent.end
Notificationunsupported
PermissionRequestunsupported
PostToolUseFailureunsupported
SubagentStartunsupported
SubagentStopunsupported
PostCompactunsupported

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.

mcp-only
10

Cline

mcp-only
no hook layer

Hook config path

—

Capabilities

canModifyArgscanModifyOutputcanInjectSessionContext

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 Codejson-stdio~/.claude/settings.json (under "hooks", keyed by event)canModifyArgscanModifyOutputcanInjectSessionContext
claude-code · PreToolUse
json-stdio: settings.json hook
json
// ~/.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 eventclaude-code (json-stdio)kilo-cli (ts-plugin)Alignment
SessionStartSessionStartexperimental.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.
SessionEndSessionEndunsupported
claude-code onlyClaude has SessionEnd 1:1; Kilo's plugin surface has no equivalent → skip-warn.
UserPromptSubmitUserPromptSubmitchat.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).
PreToolUsePreToolUsetool.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.
PostToolUsePostToolUsetool.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).
PreCompactPreCompactunsupported
claude-code onlyClaude has PreCompact 1:1; Kilo has no equivalent → skip-warn.
StopStopsession.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).
NotificationNotificationunsupported
claude-code onlyClaude has Notification 1:1; Kilo has no equivalent → skip-warn.
PermissionRequestPermissionRequestpermission.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.
PostToolUseFailurePostToolUseFailureunsupported
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.
SubagentStartSubagentStartunsupported
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.
SubagentStopSubagentStopunsupported
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 — a PreToolUse 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.after can mutate output.output (canModifyOutput: true); Claude's PostToolUse cannot (canModifyOutput: false).
  • Differ — lifecycle coverage: Claude maps 12 of the 13 canonical events 1:1 (only PostCompact has no Claude analog); Kilo's plugin surface exposes the two tool events, a SessionStart surrogate (experimental.chat.system.transform), plus UserPromptSubmit (chat.message), Stop (session.idle, via the generic event hook) and PermissionRequest (permission.ask, the decision-capable gate). Only SessionEnd, PreCompact, Notification and the three remaining newer events (PostToolUseFailure, SubagentStart, SubagentStop) skip-warn on Kilo.