Skip to main content

Custom Hooks

Hook support varies by platform and by event:
  • SessionStart hook ships on Claude Code, Cursor, OpenCode, Gemini CLI, Qoder, CodeBuddy, Copilot, Droid (and Codex with codex_hooks = true in ~/.codex/config.toml). Kiro’s Agent Hooks are user-configured — Trellis does not install any out of the box.
  • PreToolUse (sub-agent context injection) ships on Claude Code, Cursor, OpenCode, CodeBuddy, Droid. The other hook-capable platforms rely on a pull-based prelude inside each sub-agent instead.
  • UserPromptSubmit (workflow-state nudge) ships on the same platforms as SessionStart.
  • Kilo, Antigravity, Windsurf have no hook primitive at all; behavior is delivered via workflow files + skills.

Hook types

HookTriggerPurpose
SessionStartA new session startsLoad context, initialize environment
UserPromptSubmitUser submits a promptNudge the AI toward the current task state
PreToolUseBefore a tool invocationIntercept, modify parameters, inject context
PostToolUseAfter a tool invocationLog activity, trigger follow-up actions
Claude Code, Cursor, CodeBuddy, and Droid share a Python-hook layout compatible with CC’s event model (settings.json or hooks.json referencing Python scripts). OpenCode uses JS plugins (factory functions in .opencode/plugins/) with the same event semantics. The rest of the hook-capable platforms (Codex, Gemini, Qoder, Copilot) run a session-start.py only — no PreToolUse — and configure it via a platform-native config file.

settings.json configuration (Claude Code)

Configure hooks in .claude/settings.json:
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.py\"",
            "timeout": 10
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/inject-workflow-state.py\"",
            "timeout": 5
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Task",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/inject-subagent-context.py\"",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
Notes:
  • Each event type is an array of { matcher, hooks } blocks.
  • matcher: pattern to match ("startup" matches session start, "Task" matches Task tool calls, "*" matches everything).
  • hooks: array of commands that run when matched, in order.
  • $CLAUDE_PROJECT_DIR: expanded by Claude Code to the project root.
  • timeout: seconds; if exceeded, the hook is skipped.

Shipped hooks

session-start.py: context loading

Trigger: SessionStart. What it does:
  • Reads .trellis/.developer for developer identity.
  • Reads .trellis/workflow.md for the workflow contract.
  • Reads .trellis/workspace/{name}/index.md for session history.
  • Reads git log for recent commits.
  • Reads active tasks.
Output: emits all the context as a system message at the start of the session.

inject-workflow-state.py: workflow-state nudge

Trigger: UserPromptSubmit. What it does: when a task is active, appends a short breadcrumb to the user message so the AI is reminded of the current task’s phase (planning / in_progress / finishing). Parsed from .trellis/workflow.md.

inject-subagent-context.py: spec injection engine

Trigger: PreToolUse, matching Task tool calls. What it does (see §4.3 for details):
  • Intercepts Task tool calls.
  • Reads the JSONL matching the subagent_type (implement.jsonl, check.jsonl, research.jsonl).
  • Reads all files referenced in the JSONL.
  • Assembles the sub-agent prompt (specs + requirements + original instructions).
Design decisions:
  • Each sub-agent receives its full context at launch; there is no resume.
  • Only trellis-* sub-agents are hooked; custom sub-agents must opt in by editing this file or using their own injection.

Writing a custom hook

Hooks receive JSON input on stdin and emit JSON results on stdout. Input format (PreToolUse example):
{
  "hook_event_name": "PreToolUse",
  "tool_name": "Task",
  "tool_input": {
    "subagent_type": "trellis-implement",
    "prompt": "..."
  },
  "cwd": "/path/to/project"
}
Output format:
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "subagent_type": "trellis-implement",
      "prompt": "modified prompt..."
    }
  }
}

Example: an auto-test hook

.claude/hooks/auto-test.py:
#!/usr/bin/env python3
"""Run tests automatically after an Edit tool call."""

import json
import subprocess
import sys

def main():
    input_data = json.load(sys.stdin)

    hook_event = input_data.get("hook_event_name", "")
    tool_name = input_data.get("tool_name", "")

    if hook_event != "PostToolUse" or tool_name != "Edit":
        sys.exit(0)

    file_path = input_data.get("tool_input", {}).get("file_path", "")

    if not file_path.endswith((".ts", ".tsx")):
        sys.exit(0)

    result = subprocess.run(
        ["pnpm", "typecheck"],
        capture_output=True,
        timeout=30,
    )

    if result.returncode != 0:
        output = {
            "hookSpecificOutput": {
                "message": f"TypeCheck failed after editing {file_path}:\n{result.stderr.decode()}"
            }
        }
        print(json.dumps(output))

    sys.exit(0)


if __name__ == "__main__":
    main()
Register it in settings.json:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "python3 .claude/hooks/auto-test.py"
          }
        ]
      }
    ]
  }
}