본문으로 건너뛰기

이벤트 훅

anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias
anchor alias

이벤트 훅

Hermes에는 주요 라이프사이클 지점에서 사용자 지정 코드를 실행하는 세 가지 훅 시스템이 있습니다:

시스템등록 방법실행 환경사용 사례
게이트웨이 훅HOOK.yaml + handler.py in ~/.hermes/hooks/게이트웨이 전용로깅, 알림, 웹후크
플러그인 훅ctx.register_hook() in a 플러그인CLI + 게이트웨이도구 차단, 지표, 안전 장치
쉘 훅hooks: 블록이 ~/.hermes/config.yaml에서 셸 스크립트를 가리킵니다CLI + 게이트웨이차단, 자동 형식 지정, 컨텍스트 주입을 위한 드롭인 스크립트

세 가지 시스템 모두 논블로킹이며, 어떤 훅에서 오류가 발생하더라도 잡아서 기록하기 때문에 에이전트가 절대 충돌하지 않습니다.

게이트웨이 이벤트 훅

게이트웨이 훅은 게이트웨이 작동 중에 자동으로 실행되며(Telegram, Discord, Slack, WhatsApp, Teams), 메인 에이전트 파이프라인을 차단하지 않습니다.

후크 만들기

각 후크는 두 개의 파일을 포함하는 ~/.hermes/hooks/ 하위 디렉토리입니다:

~/.hermes/hooks/
└── my-hook/
├── HOOK.yaml # Declares which events to listen for
└── handler.py # Python handler function

HOOK.yaml

name: my-hook
description: Log all agent activity to a file
events:
- agent:start
- agent:end
- agent:step
``events` 목록은 어떤 이벤트가 핸들러를 호출할지를 결정합니다. 와일드카드인 `command:*`과 같은 이벤트를 포함하여 모든 이벤트 조합에 구독할 수 있습니다.

#### 핸들러.py \{#handlerpy}

```python
import json
from datetime import datetime
from pathlib import Path

LOG_FILE = Path.home() / ".hermes" / "hooks" / "my-hook" / "activity.log"

async def handle(event_type: str, 컨텍스트: dict):
"""Called for each subscribed event. Must be named 'handle'."""
entry = {
"timestamp": datetime.now().isoformat(),
"event": event_type,
**컨텍스트,
}
with open(LOG_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")

핸들러 규칙:

  • handle로 이름 지어야 합니다
  • event_type (문자열)과 context (딕셔너리)을 받습니다
  • async def일 수도 있고 일반 def일 수도 있습니다 — 둘 다 작동합니다
  • 오류가 포착되어 기록되며, 에이전트가 절대 충돌하지 않습니다

사용 가능한 이벤트

이벤트발사될 때문맥 키
gateway:startup게이트웨이 프로세스 시작platforms (활성 플랫폼 이름 목록)
session:start새 메시징 세션이 생성되었습니다platform, user_id, session_id, session_key
session:end세션 종료(리셋 전)platform, user_id, session_key
session:reset사용자가 /new 또는 /reset을 실행했습니다platform, user_id, session_key
agent:start에이전트가 메시지 처리 시작platform, user_id, session_id, message
agent:step툴 호출 루프의 각 반복platform, user_id, session_id, iteration, tool_names
agent:end에이전트가 처리를 마칩니다platform, user_id, session_id, message, response
command:*실행된 모든 슬래시 명령어platform, user_id, command, args

와일드카드 매칭

command:*에 등록된 핸들러는 모든 command: 이벤트(command:model, command:reset 등)에서 작동합니다. 단일 구독으로 모든 슬래시 명령을 모니터링하세요.

예시

장기 작업에 대한 텔레그램 알림

에이전트가 10단계 이상 진행할 때 자신에게 메시지를 보내세요:

# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Alert when agent is taking many steps
events:
- agent:step
````python
# ~/.hermes/hooks/long-task-alert/handler.py
import os
import httpx

THRESHOLD = 10
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_HOME_CHANNEL")

async def handle(event_type: str, context: dict):
iteration = context.get("iteration", 0)
if iteration == THRESHOLD and BOT_TOKEN and CHAT_ID:
tools = ", ".join(context.get("tool_names", ))
text = f"⚠️ Agent has been running for {iteration} steps. Last tools: {tools}"
async with httpx.AsyncClient() as client:
await client.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
json={"chat_id": CHAT_ID, "text": text},
)

명령 사용 기록기

어떤 슬래시 명령이 사용되는지 추적하기:

# ~/.hermes/hooks/command-logger/HOOK.yaml
name: command-logger
description: Log slash command usage
events:
- command:*
````python
# ~/.hermes/hooks/command-logger/handler.py
import json
from datetime import datetime
from pathlib import Path

LOG = Path.home() / ".hermes" / "logs" / "command_usage.jsonl"

def handle(event_type: str, 컨텍스트: dict):
LOG.parent.mkdir(parents=True, exist_ok=True)
entry = {
"ts": datetime.now().isoformat(),
"command": 컨텍스트.get("command"),
"args": 컨텍스트.get("args"),
"platform": 컨텍스트.get("platform"),
"user": 컨텍스트.get("user_id"),
}
with open(LOG, "a") as f:
f.write(json.dumps(entry) + "\n")

세션 시작 웹후크

새 세션 시 외부 서비스로 POST:

# ~/.hermes/hooks/session-webhook/HOOK.yaml
name: session-webhook
description: Notify external service on new sessions
events:
- session:start
- session:reset
````python
# ~/.hermes/hooks/session-webhook/handler.py
import httpx

WEBHOOK_URL = "https://your-service.example.com/hermes-events"

async def handle(event_type: str, context: dict):
async with httpx.AsyncClient() as client:
await client.post(WEBHOOK_URL, json={
"event": event_type,
**context,
}, timeout=5)

튜토리얼: BOOT.md — 모든 게이트웨이 부팅 시 스타트업 체크리스트 실행

커뮤니티에서 인기 있는 패턴: ~/.hermes/BOOT.md에 Markdown 체크리스트를 배치하고, 게이트웨이가 시작될 때마다 에이전트가 한 번 실행하도록 합니다. '매번 부팅 시, 밤새 cron 실패를 확인하고 실패가 있으면 Discord로 알림 보내기' 또는 '지난 24시간의 deploy.log를 요약하여 Slack #ops에 게시하기'에 유용합니다.

이 튜토리얼은 사용자가 정의한 훅으로 직접 만드는 방법을 보여줍니다. Hermes에는 기본 제공 BOOT.md 훅이 포함되어 있지 않으며, 원하는 동작을 직접 연결해야 합니다.

우리가 만들고 있는 것

  1. 자연어 시작 지침이 포함된 ~/.hermes/BOOT.md의 파일.
  2. gateway:startup에서 작동하는 게이트웨이 훅으로, 게이트웨이의 해결된 모델/자격 증명을 사용하여 일회성 에이전트를 생성하고 BOOT.md 지침을 실행합니다.
  3. 에이전트가 보고할 내용이 없을 때 메시지 전송을 생략할 수 있도록 하는 [SILENT] 규약.

1단계: 체크리스트 작성

~/.hermes/BOOT.md를 만드세요. 마치 인간 조수에게 지시를 내리는 것처럼 작성하세요:

# Startup Checklist

1. Run `hermes cron list` and check if any scheduled jobs failed overnight.
2. If any failed, send a summary to Discord #ops using the `send_message` tool.
3. Check if `/opt/app/deploy.log` has any ERROR lines from the last 24 hours. If yes, summarize them and include in the same Discord message.
4. If nothing went wrong, reply with only `[SILENT]` so no message is sent.

에이전트는 이것을 자체 프롬프트의 일부로 보기 때문에, 일반 언어로 설명할 수 있는 모든 것이 작동합니다 — 도구 호출, 셸 명령, 메시지 전송, 파일 요약.

2단계: 후크 만들기

~/.hermes/hooks/boot-md/
├── HOOK.yaml
└── handler.py

~/.hermes/hooks/boot-md/HOOK.yaml

name: boot-md
description: Run ~/.hermes/BOOT.md on gateway startup
events:
- gateway:startup

~/.hermes/hooks/boot-md/handler.py

"""Run ~/.hermes/BOOT.md on every gateway startup."""

import logging
import threading
from pathlib import Path

logger = logging.getLogger("hooks.boot-md")

BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"


def _build_prompt(content: str) -> str:
return (
"You are running a startup boot checklist. Follow the instructions "
"below exactly.\n\n"
"---\n"
f"{content}\n"
"---\n\n"
"Execute each instruction. Use the send_message tool to deliver any "
"messages to platforms like Discord or Slack.\n"
"If nothing needs attention and there is nothing to report, reply "
"with ONLY: [SILENT]"
)


def _run_boot_agent(content: str) -> None:
"""Spawn a one-shot agent and execute the checklist.

Uses the gateway's resolved model and runtime credentials so this works
against custom endpoints, aggregators, and OAuth-based providers alike.
"""
try:
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
from run_agent import AIAgent

agent = AIAgent(
model=_resolve_gateway_model(),
**_resolve_runtime_agent_kwargs(),
platform="gateway",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
max_iterations=20,
)
result = agent.run_conversation(_build_prompt(content))
response = result.get("final_response", "")
if response and "[SILENT]" not in response:
logger.info("boot-md completed: %s", response[:200])
else:
logger.info("boot-md completed (nothing to report)")
except Exception as e:
logger.error("boot-md agent failed: %s", e)


async def handle(event_type: str, context: dict) -> None:
if not BOOT_FILE.exists():
return
content = BOOT_FILE.read_text(encoding="utf-8").strip()
if not content:
return

logger.info("Running BOOT.md (%d chars)", len(content))

# Background thread so gateway startup isn't blocked on a full agent turn.
thread = threading.Thread(
target=_run_boot_agent,
args=(content,),
name="boot-md",
daemon=True,
)
thread.start()

두 개의 핵심 줄:

  • _resolve_gateway_model()는 게이트웨이에 현재 구성된 모델을 읽습니다.
  • _resolve_runtime_agent_kwargs()는 API 키, 기본 URL, OAuth 토큰, 자격 증명 풀을 포함하여 일반 게이트웨이 회전과 동일한 방식으로 제공자 자격 증명을 해결합니다.

이것들이 없으면, 빈 AIAgent()는 내장 기본값으로 되돌아가며 비기본 엔드포인트에 대해 401 오류가 발생합니다.

3단계: 테스트하기

게이트웨이를 재시작하세요:

hermes gateway restart

로그를 확인하세요:

hermes logs --follow --level INFO | grep boot-md

당신은 에이전트가 [SILENT]라고 답변했을 때, Running BOOT.md (N chars) 다음에 boot-md completed:...(에이전트가 한 일에 대한 요약) 또는 boot-md completed (nothing to report)가 오는 것을 보아야 합니다.

~/.hermes/BOOT.md를 삭제하면 체크리스트가 비활성화됩니다 — 훅은 계속 로드되지만 파일이 없으면 조용히 건너뜁니다.

패턴 확장

  • 일정 인식 체크리스트: BOOT.md의 지침 안에서 datetime.now().weekday() 키를 꺼세요("월요일이면 주간 배포 로그도 확인하세요"). 지침은 자유로운 텍스트라서 에이전트가 논리적으로 설명할 수 있는 내용은 자유롭게 사용할 수 있습니다.
  • 여러 체크리스트: 후크를 다른 파일(STARTUP.md, MORNING.md 등)을 가리키도록 하고 각 파일에 대해 별도의 후크 디렉터리를 등록합니다.
  • 비에이전트 버전: 전체 에이전트 루프가 필요하지 않다면 AIAgent를 완전히 건너뛰고 핸들러가 httpx를 통해 고정 알림을 바로 게시하게 하세요. 더 저렴하고, 빠르며, 제공자 의존성이 없습니다.

왜 이것이 내장형이 아닌가

Hermes의 이전 버전은 이를 내장 훅으로 제공하고, 모든 게이트웨이 부팅 시 기본값만으로 에이전트를 조용히 생성했습니다. 이는 사용자 정의 엔드포인트를 사용하는 사용자들을 놀라게 했고, 실행 중인 사실조차 모르는 사용자들에게는 이 기능이 보이지 않게 했습니다. 이를 문서화된 패턴으로 유지하는 것 — 즉, 사용자가 자신의 훅 디렉토리에 직접 작성함 — 은 기능이 정확히 무엇을 하는지 확인할 수 있고, 파일을 작성함으로써 옵트인할 수 있다는 의미입니다.

작동 원리

  1. 게이트웨이 시작 시, HookRegistry.discover_and_load()~/.hermes/hooks/을 스캔합니다
  2. HOOK.yaml + handler.py가 있는 각 하위 디렉터리는 동적으로 로드됩니다
  3. 핸들러는 선언된 이벤트에 대해 등록됩니다
  4. 각 수명 주기 지점에서, hooks.emit()은 모든 일치하는 핸들러를 실행합니다
  5. 어떤 핸들러에서 발생한 오류도 포착되고 기록됩니다 — 깨진 훅은 에이전트를 절대 멈추게 하지 않습니다
정보

게이트웨이 훅은 게이트웨이(Telegram, Discord, Slack, WhatsApp, Teams)에서만 작동합니다. CLI는 게이트웨이 훅을 로드하지 않습니다. 어디에서나 작동하는 훅은 플러그인 훅을 사용하세요.

플러그인 훅

플러그인CLI와 게이트웨이 세션 모두에서 실행되는 훅을 등록할 수 있습니다. 이러한 훅들은 플러그인의 register() 함수에서 ctx.register_hook()을 통해 프로그래밍 방식으로 등록됩니다.

def register(ctx):
ctx.register_hook("pre_tool_call", my_tool_observer)
ctx.register_hook("post_tool_call", my_tool_logger)
ctx.register_hook("pre_llm_call", my_memory_callback)
ctx.register_hook("post_llm_call", my_sync_callback)
ctx.register_hook("on_session_start", my_init_callback)
ctx.register_hook("on_session_end", my_cleanup_callback)

모든 후크에 대한 일반 규칙:

  • 콜백은 키워드 인수를 받습니다. 앞으로의 호환성을 위해 항상 **kwargs를 받아야 합니다 — 향후 버전에서 새로운 매개변수가 추가되더라도 플러그인이 깨지지 않을 수 있습니다.
  • 콜백이 충돌하면 기록되고 건너뜁니다. 다른 후크와 에이전트는 정상적으로 계속 작동합니다. 잘못 작동하는 플러그인은 절대로 에이전트를 망가뜨릴 수 없습니다.
  • 두 후크의 반환 값이 동작에 영향을 줍니다: pre_tool_call은 도구를 차단할 수 있고, pre_llm_call은 LLM 호출에 컨텍스트를 주입할 수 있습니다. 다른 모든 후크는 단순한 관찰자입니다.

빠른 참고

갈고리발사할 때반환
pre_tool_call어떤 도구가 실행되기 전에{"action": "block", "message": str} 전화를 거부하다
post_tool_call어떤 도구가 반환된 후무시된
pre_llm_call턴마다 한 번, 도구 호출 루프 전에{"컨텍스트": str} 사용자 메시지에 컨텍스트를 첨부하기 위해
post_llm_call턴마다 한 번, 도구 호출 루프 후에무시된
on_session_start새 세션이 생성되었습니다 (첫 번째 턴만)무시된
on_session_end세션 종료무시된
on_session_finalizeCLI/게이트웨이는 활성 세션을 종료합니다 (플러시, 저장, 통계)무시된
on_session_reset게이트웨이는 새로운 세션 키(예: /new, /reset)로 교환합니다무시된
subagent_stop어린 아이가 나갔습니다무시된
pre_gateway_dispatch게이트웨이가 인증 및 디스패치 이전에 사용자 메시지를 수신했습니다
pre_approval_request위험한 명령은 프롬프트/알림이 전송되기 전에 사용자의 승인이 필요합니다무시된
post_approval_response사용자가 승인 요청에 응답했거나 시간이 초과되었습니다무시된
transform_tool_result모든 도구가 반환된 후, 결과가 모델에 전달되기 전에결과를 바꾸려면 str, 그대로 두려면 None
transform_terminal_outputterminal 도구 안에서, 잘라내기/ANSI 제거/편집 전str를 원시 출력물로 교체하고, None를 변경하지 않고 둡니다
transform_llm_output도구 호출 루프가 완료된 후, 최종 응답이 전달되기 전에str를 응답 텍스트로 교체, None/비워 두기

pre_tool_call

모든 도구 실행 직전에 — 내장 도구와 플러그인 도구 모두에서 — 실행됩니다.

콜백 시그니처:

def my_callback(tool_name: str, args: dict, task_id: str, **kwargs):
매개변수타입설명
tool_namestr실행하려는 도구의 이름 (예: "terminal", "web_search", "read_file")
argsdict모델이 도구에 전달한 인수
task_idstr세션/작업 식별자. 설정되지 않은 경우 빈 문자열.

발화: model_tools.py에서, handle_function_call() 안에서, 도구의 핸들러가 실행되기 전에 발화합니다. 도구 호출당 한 번 실행됩니다 — 모델이 3개의 도구를 병렬로 호출하면, 이 이벤트는 3번 발생합니다.

반환 값 — 호출 거부:

return {"action": "block", "message": "Reason the tool call was blocked"}

에이전트는 모델에 반환된 오류로 message를 사용하여 도구를 단락시킵니다. 첫 번째로 일치하는 블록 지시어가 우선합니다(파이썬 플러그인이 먼저 등록되고, 그 다음 셸 훅이 등록됩니다). 다른 반환 값은 무시되므로 기존 관찰자 전용 콜백은 변경 없이 계속 작동합니다.

사용 사례: 로깅, 감사 추적, 도구 호출 카운터, 위험한 작업 차단, 속도 제한, 사용자별 정책 시행.

예시 — 도구 호출 감사 로그:

import json, logging
from datetime import datetime

logger = logging.getLogger(__name__)

def audit_tool_call(tool_name, args, task_id, **kwargs):
logger.info("TOOL_CALL session=%s tool=%s args=%s",
task_id, tool_name, json.dumps(args)[:200])

def register(ctx):
ctx.register_hook("pre_tool_call", audit_tool_call)

예시 — 위험한 도구에 경고:

DANGEROUS = {"terminal", "write_file", "patch"}

def warn_dangerous(tool_name, **kwargs):
if tool_name in DANGEROUS:
print(f"⚠ Executing potentially dangerous tool: {tool_name}")

def register(ctx):
ctx.register_hook("pre_tool_call", warn_dangerous)

post_tool_call

모든 도구 실행이 즉시 완료된 후 실행됩니다.

콜백 시그니처:

def my_callback(tool_name: str, args: dict, result: str, task_id: str,
duration_ms: int, **kwargs):
매개변수타입설명
tool_namestr방금 실행된 도구의 이름
argsdict모델이 도구에 전달한 인수
resultstr도구의 반환 값(항상 JSON 문자열)
task_idstr세션/작업 식별자. 설정되지 않은 경우 빈 문자열.
duration_msint도구의 디스패치가 걸린 시간(밀리초 단위, time.monotonic()registry.dispatch() 주변에서 측정함).

발화(Fires): model_tools.py에서, handle_function_call() 내부에서, 도구 핸들러가 반환된 후에 발화합니다. 도구 호출당 한 번만 발화합니다. 도구가 처리되지 않은 예외를 발생시키면 발화하지 않습니다(오류는 대신 잡혀서 오류 JSON 문자열로 반환되며, post_tool_call는 그 오류 문자열을 result로 하여 발화합니다).

반환값: 무시됨.

사용 사례: 로깅 도구 결과, 메트릭 수집, 도구 성공/실패율 추적, 지연 시간 대시보드, 도구별 예산 알림, 특정 도구 완료 시 알림 전송.

예시 — 도구 사용 지표 추적:

from collections import Counter, defaultdict
import json

_tool_counts = Counter()
_error_counts = Counter()
_latency_ms = defaultdict(list)

def track_metrics(tool_name, result, duration_ms=0, **kwargs):
_tool_counts[tool_name] += 1
_latency_ms[tool_name].append(duration_ms)
try:
parsed = json.loads(result)
if "error" in parsed:
_error_counts[tool_name] += 1
except (json.JSONDecodeError, TypeError):
pass

def register(ctx):
ctx.register_hook("post_tool_call", track_metrics)

pre_llm_call

불은 턴당 한 번발동하며, 도구 호출 루프가 시작되기에 실행됩니다. 이것은 반환 값이 사용되는 유일한 훅으로, 현재 턴의 사용자 메시지에 컨텍스트를 주입할 수 있습니다.

콜백 시그니처:

def my_callback(session_id: str, user_message: str, conversation_history: list,
is_first_turn: bool, model: str, platform: str, **kwargs):
매개변수유형설명
session_idstr현재 세션의 고유 식별자
user_messagestr이 턴에 대한 사용자의 원래 메시지 (어떤 스킬 주입 전)
conversation_historylist전체 메시지 목록 사본 (OpenAI 형식: [{"role": "user", "content": "..."}])
is_first_turnboolTrue 이것이 새로운 세션의 첫 번째 차례라면, False 이후 차례에서는
modelstr모델 식별자 (예: "anthropic/claude-sonnet-4.6")
platformstr세션이 실행 중인 위치: "cli", "telegram", "discord" 등.

발화: run_agent.py에서, run_conversation() 안에서, context 압축 후이지만 주요 while 루프 전에서. run_conversation() 호출당 한 번 실행됩니다(즉, 사용자 턴당 한 번), 도구 루프 내의 API 호출당 한 번이 아닙니다.

반환 값: 콜백이 "context" 키가 있는 dict를 반환하거나, 단순한 비어 있지 않은 문자열을 반환하면 텍스트가 현재 턴의 사용자 메시지에 추가됩니다. 주입이 없으면 None을 반환합니다.

# Inject context
return {"context": "Recalled memories:\n- User likes Python\n- Working on hermes-agent"}

# Plain string (equivalent)
return "Recalled memories:\n- User likes Python"

# No injection
return None

컨텍스트가 주입되는 위치:항상사용자 메시지이며, 시스템 프롬프트는 절대 아닙니다. 이렇게 하면 프롬프트 캐시가 유지됩니다 — 시스템 프롬프트는 턴마다 동일하게 유지되므로 캐시된 토큰이 재사용됩니다. 시스템 프롬프트는 Hermes의 영역입니다(모델 가이드라인, 도구 적용, 성격, 기술). 플러그인은 사용자의 입력과 함께 컨텍스트를 제공합니다.

모든 주입된 컨텍스트는 일시적입니다 — API 호출 시에만 추가됩니다. 대화 기록에 있는 원래 사용자 메시지는 절대 변경되지 않으며, 세션 데이터베이스에 아무 것도 저장되지 않습니다.

여러 플러그인이 컨텍스트를 반환하면, 그 출력은 플러그인 검색 순서(디렉토리 이름의 알파벳순)에 따라 두 줄 바꿈으로 결합됩니다.

사용 사례: 기억 회상, RAG 컨텍스트 주입, 가드레일, 턴별 분석.

예시 — 기억 회상:

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall(session_id, user_message, is_first_turn, **kwargs):
try:
resp = httpx.post(f"{MEMORY_API}/recall", json={
"session_id": session_id,
"query": user_message,
}, timeout=3)
memories = resp.json().get("results", )
if not memories:
return None
text = "Recalled context:\n" + "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None

def register(ctx):
ctx.register_hook("pre_llm_call", recall)

예시 — 가드레일:

POLICY = "Never execute commands that delete files without explicit user confirmation."

def guardrails(**kwargs):
return {"context": POLICY}

def register(ctx):
ctx.register_hook("pre_llm_call", guardrails)

post_llm_call

불은 턴당 한 번발동하며, 도구 호출 루프가 완료되고 에이전트가 최종 응답을 생성한 후 발동합니다.성공한 턴에서만 발동하며, 턴이 중단된 경우에는 발동하지 않습니다.

콜백 시그니처:

def my_callback(session_id: str, user_message: str, assistant_response: str,
conversation_history: list, model: str, platform: str, **kwargs):
매개변수유형설명
session_idstr현재 세션의 고유 식별자
user_messagestr이번 차례에 사용자의 원본 메시지
assistant_responsestr이 턴에 대한 에이전트의 최종 텍스트 응답
conversation_historylist턴이 완료된 후 전체 메시지 목록 사본
modelstr모델 식별자
platformstr세션이 실행 중인 곳

발화: run_agent.py에서, run_conversation() 안에서, 도구 루프가 최종 응답과 함께 종료된 후 발생합니다. if final_response and not interrupted로 보호되므로 — 사용자가 중간에 중단하거나 에이전트가 응답을 생성하지 않고 반복 제한에 도달했을 때는 발생하지 않습니다.

반환값: 무시됨.

사용 사례: 대화 데이터를 외부 메모리 시스템과 동기화, 응답 품질 지표 계산, 대화 요약 기록, 후속 작업 트리거.

예시 — 외부 메모리와 동기화:

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def sync_memory(session_id, user_message, assistant_response, **kwargs):
try:
httpx.post(f"{MEMORY_API}/store", json={
"session_id": session_id,
"user": user_message,
"assistant": assistant_response,
}, timeout=5)
except Exception:
pass # best-effort

def register(ctx):
ctx.register_hook("post_llm_call", sync_memory)

예제 — 응답 길이 추적:

import logging
logger = logging.getLogger(__name__)

def log_response_length(session_id, assistant_response, model, **kwargs):
logger.info("RESPONSE session=%s model=%s chars=%d",
session_id, model, len(assistant_response or ""))

def register(ctx):
ctx.register_hook("post_llm_call", log_response_length)

on_session_start

새로운 세션이 생성될 때 한 번실행됩니다. 세션이 계속될 때(사용자가 기존 세션에서 두 번째 메시지를 보낼 때)실행되지 않습니다.

콜백 시그니처:

def my_callback(session_id: str, model: str, platform: str, **kwargs):
매개변수유형설명
session_idstr새 세션의 고유 식별자
modelstr모델 식별자
platformstr세션이 실행 중인 곳

발화: 새로운 세션의 첫 번째 턴 동안, run_agent.py 안에서, run_conversation() 내부에서 — 구체적으로 시스템 프롬프트가 구성된 후 도구 루프가 시작되기 전. 체크는 if not conversation_history (if not conversation_history 이전 메시지 없음 = 새로운 세션).

반환값: 무시됨.

사용 사례: 세션 범위 상태 초기화, 캐시 사전 로딩, 외부 서비스에 세션 등록, 세션 시작 기록.

예제 — 세션 캐시 초기화:

_session_caches = {}

def init_session(session_id, model, platform, **kwargs):
_session_caches[session_id] = {
"model": model,
"platform": platform,
"tool_calls": 0,
"started": __import__("datetime").datetime.now().isoformat(),
}

def register(ctx):
ctx.register_hook("on_session_start", init_session)

on_session_end

결과와 상관없이모든 run_conversation() 호출의가장 끝에서 실행됩니다. 또한 사용자가 중간 턴에 종료할 때 에이전트가 있는 경우 CLI의 종료 핸들러에서도 실행됩니다.

콜백 시그니처:

def my_callback(session_id: str, completed: bool, interrupted: bool,
model: str, platform: str, **kwargs):
매개변수유형설명
session_idstr세션의 고유 식별자
completedboolTrue 에이전트가 최종 응답을 생성했으면, False 그렇지 않으면
interruptedboolTrue 만약 대화가 중단되었다면 (사용자가 새 메시지를 보냈거나, /stop 또는 종료했을 경우)
modelstr모델 식별자
platformstr세션이 실행 중인 곳

화재: 두 곳에서:

  1. run_agent.py — 모든 run_conversation() 호출이 끝난 후, 모든 정리 작업 후에 실행됩니다. 회전이 오류가 발생하더라도 항상 실행됩니다.
  2. cli.py — CLI의 atexit 핸들러에서, 그러나 에이전트가 종료 시점에 중간 턴(_agent_running=True)에 있었던 경우에만. 이는 처리 중에 Ctrl+C와 /exit를 잡는다. 이 경우, completed=Falseinterrupted=True.

반환값: 무시됨.

사용 사례: 버퍼 플러시, 연결 종료, 세션 상태 유지, 세션 지속 시간 로깅, on_session_start에서 초기화된 리소스 정리.

예제 — 플러시 및 정리:

_session_caches = {}

def cleanup_session(session_id, completed, interrupted, **kwargs):
cache = _session_caches.pop(session_id, None)
if cache:
# Flush accumulated data to disk or external service
status = "completed" if completed else ("interrupted" if interrupted else "failed")
print(f"Session {session_id} ended: {status}, {cache['tool_calls']} tool calls")

def register(ctx):
ctx.register_hook("on_session_end", cleanup_session)

예시 — 세션 시간 추적:

import time, logging
logger = logging.getLogger(__name__)

_start_times = {}

def on_start(session_id, **kwargs):
_start_times[session_id] = time.time()

def on_end(session_id, completed, interrupted, **kwargs):
start = _start_times.pop(session_id, None)
if start:
duration = time.time() - start
logger.info("SESSION_DURATION session=%s seconds=%.1f completed=%s interrupted=%s",
session_id, duration, completed, interrupted)

def register(ctx):
ctx.register_hook("on_session_start", on_start)
ctx.register_hook("on_session_end", on_end)

on_session_finalize

CLI나 게이트웨이가 활성 세션을 종료할 때 발생합니다 — 예를 들어, 사용자가 /new를 실행했을 때, 게이트웨이가 유휴 세션을 GC했거나, CLI가 활성 에이전트와 함께 종료했을 때입니다. 이는 세션의 신원이 사라지기 전에 나가는 세션과 연결된 상태를 플러시할 수 있는 마지막 기회입니다.

콜백 시그니처:

def my_callback(session_id: str | None, platform: str, **kwargs):
매개변수타입설명
session_idstr 또는 None나가는 세션 ID. 활성 세션이 없었던 경우 None 일 수 있습니다.
platformstr"cli" 또는 메시징 플랫폼 이름 ("telegram", "discord" 등).

화재: cli.py에서 (/new/CLI 종료 시) 및 gateway/run.py에서 (세션이 재설정되거나 GC될 때). 항상 게이트웨이 측의 on_session_reset과 함께 사용됩니다.

반환값: 무시됨.

사용 사례: 세션 ID가 폐기되기 전에 최종 세션 지표를 유지하고, 세션별 리소스를 종료하며, 최종 원격 측정 이벤트를 전송하고, 대기 중인 쓰기를 비웁니다.


on_session_reset

활성 채팅에 대해 게이트웨이가 새 세션 키를 교체할 때 트리거됩니다 — 사용자가 /new, /reset, /clear를 호출했거나, 어댑터가 유휴 창 이후 새로운 세션을 선택한 경우입니다. 이를 통해 플러그인은 대화 상태가 초기화된 사실을 다음 on_session_start를 기다리지 않고도 반응할 수 있습니다.

콜백 시그니처:

def my_callback(session_id: str, platform: str, **kwargs):
매개변수유형설명
session_idstr새 세션의 ID(이미 새로운 값으로 변경됨).
platformstr메시징 플랫폼 이름.

발화: 새 세션 키가 할당된 직후이지만 다음 인바운드 메시지가 처리되기 전인 gateway/run.py에서. 게이트웨이에서는 순서가 다음과 같습니다: on_session_finalize(old_id) → 스왑 → on_session_reset(new_id)on_session_start(new_id) 첫 번째 인바운드 턴에서.

반환값: 무시됨.

사용 사례: session_id로 키 지정된 세션별 캐시를 재설정하고, "세션 회전됨" 분석 데이터를 전송하며, 새로운 상태 버킷을 초기화합니다.


도구 스키마, 핸들러 및 고급 훅 패턴을 포함한 전체 절차는 **플러그인 빌드 가이드**를 참조하세요.


subagent_stop

delegate_task가 끝난 후 각 자식 에이전트마다 한 번씩 실행됩니다. 단일 작업을 위임했든 세 개의 작업 배치를 위임했든, 이 훅은 각 자식마다 한 번씩 실행되며, 부모 스레드에서 순차적으로 처리됩니다.

콜백 시그니처:

def my_callback(parent_session_id: str, child_role: str | None,
child_summary: str | None, child_status: str,
duration_ms: int, **kwargs):
매개변수타입설명
parent_session_idstr위임하는 상위 에이전트의 세션 ID
child_role`str "}없음`
child_summary`str "}없음`
child_statusstr"completed", "failed", "interrupted", 또는 "error"
duration_msint자식 프로세스를 실행하는 데 소요된 실제 시간(밀리초)

발화: tools/delegate_tool.py에서, ThreadPoolExecutor.as_completed()가 모든 자식 미래를 소진한 후. 발화는 후크 작성자가 동시 콜백 실행에 대해 고민하지 않아도 되도록 부모 스레드로 전달됩니다.

반환값: 무시됨.

사용 사례: 오케스트레이션 활동 기록, 청구를 위한 하위 기간 누적, 위임 후 감사 기록 작성.

예시 — 로그 오케스트레이터 활동:

import logging
logger = logging.getLogger(__name__)

def log_subagent(parent_session_id, child_role, child_status, duration_ms, **kwargs):
logger.info(
"SUBAGENT parent=%s role=%s status=%s duration_ms=%d",
parent_session_id, child_role, child_status, duration_ms,
)

def register(ctx):
ctx.register_hook("subagent_stop", log_subagent)
정보

중앙 집중식 위임(예: 오케스트레이터 역할 × 5개의 리프 × 중첩 깊이)으로 인해, subagent_stop는 한 턴에 여러 번 실행됩니다. 콜백을 빠르게 유지하고, 비용이 큰 작업은 백그라운드 큐로 넘기세요.


pre_gateway_dispatch

게이트웨이에서 내부 이벤트 가드 이후 하지만 인증/페어링 및 에이전트 배정 이전에, 들어오는 MessageEvent한 번 발동합니다. 이는 특정 플랫폼 어댑터에 깔끔하게 맞지 않는 게이트웨이 수준 메시지 흐름 정책(보기 전용 창, 인적 인계, 채팅별 라우팅 등)을 가로챌 수 있는 지점입니다.

콜백 시그니처:

def my_callback(event, gateway, session_store, **kwargs):
매개변수유형설명
eventMessageEvent정규화된 수신 메시지에는 .text, .source, .message_id, .internal 등이 포함되어 있습니다.
gatewayGatewayRunner활성 게이트웨이 러너로, 플러그인이 부채널 응답(소유자 알림 등)을 위해 gateway.adapters[platform].send(...)을 호출할 수 있습니다.
session_storeSessionStoresession_store.append_to_transcript(...)을 통해 조용한 전사 수집을 위해.

화재:gateway/run.py에서, GatewayRunner._handle_message() 안에서, is_internal가 계산된 직후.내부 이벤트는 훅을 완전히 건너뜁니다 (시스템에서 생성된 것 — 백그라운드 프로세스 완료 등 — 사용자 대상 정책에 의해 제한되어서는 안 됩니다).

반환 값: None 또는 dict. 처음 인식된 action dict가 승리하며 나머지 플러그인 결과는 무시됩니다. 플러그인 콜백에서 발생한 예외는 포착되어 기록되며, 게이트웨이는 오류 발생 시 항상 정상 디스패치로 진행됩니다.

반환효과
{"action": "skip", "reason": "..."}메시지를 보내세요 — 에이전트 응답 없음, 페어링 흐름 없음, 인증 없음. 플러그인이 이를 처리한 것으로 간주됩니다(예: 대화 기록에 조용히 수집됨).
{"action": "rewrite", "text": "new text"}event.text를 교체한 후, 수정된 이벤트로 정상적인 디스패치를 계속 진행합니다. 버퍼링된 주변 메시지를 하나의 프롬프트로 합치는 데 유용합니다.
{"action": "allow"} / None정상 발송 — 전체 인증 / 페어링 / 에이전트 루프 체인을 실행합니다.

사용 사례: 듣기 전용 그룹 채팅(태그될 때만 응답; 주변 메시지를 컨텍스트로 버퍼링); 인간 전환(소유자가 채팅을 수동으로 처리하는 동안 고객 메시지를 조용히 수집); 프로필별 속도 제한; 정책 기반 라우팅.

예시 — 페어링 코드를 작동시키지 않고 무단 DM을 조용히 삭제하기:

def deny_unauthorized_dms(event, **kwargs):
src = event.source
if src.chat_type == "dm" and not _is_approved_user(src.user_id):
return {"action": "skip", "reason": "unauthorized-dm"}
return None

def register(ctx):
ctx.register_hook("pre_gateway_dispatch", deny_unauthorized_dms)

예시 — 언급 시 환경 메시지 버퍼를 하나의 프롬프트로 다시 작성하기:

_buffers = {}

def buffer_or_rewrite(event, **kwargs):
key = (event.source.platform, event.source.chat_id)
buf = _buffers.setdefault(key, )
if _bot_mentioned(event.text):
combined = "\n".join(buf + [event.text])
buf.clear()
return {"action": "rewrite", "text": combined}
buf.append(event.text)
return {"action": "skip", "reason": "ambient-buffered"}

def register(ctx):
ctx.register_hook("pre_gateway_dispatch", buffer_or_rewrite)

pre_approval_request

승인 요청이 사용자에게 표시되기 직전에 실행됩니다 — 모든 환경을 포함합니다: 대화형 CLI, Ink TUI, 게이트웨이 플랫폼(텔레그램, 디스코드, 슬랙, 왓츠앱, 매트릭스 등) 및 ACP 클라이언트(VS 코드, Zed, JetBrains).

여기는 맞춤 알림기를 연결하기에 적합한 장소입니다 — 예를 들어, 허용/거부 알림을 표시하는 macOS 메뉴 바 앱이나, 모든 승인 요청을 맥락과 함께 기록하는 감사 로그 등이 있습니다.

콜백 시그니처:

def my_callback(
command: str,
description: str,
pattern_key: str,
pattern_keys: list[str],
session_key: str,
surface: str,
**kwargs,
):
매개변수타입설명
commandstr승인을 기다리는 셸 명령
descriptionstr명령이 플래그된 사람 읽기 가능한 이유(여러 패턴이 일치할 경우 결합됨)
pattern_keystr승인을 촉발한 주요 패턴 키(예: "rm_rf", "sudo")
pattern_keyslist[str]일치한 모든 패턴 키
session_keystr세션 식별자, 채팅별 알림 범위를 지정하는 데 유용함
surfacestr인터랙티브 CLI/TUI 프롬프트용 "cli", 비동기 플랫폼 승인을 위한 "gateway"

반환 값: 무시됨. 여기의 훅은 관찰자 전용이며 승인에 대해 거부하거나 사전 응답할 수 없습니다. 도구가 승인 시스템에 도달하기 전에 차단하려면 pre_tool_call을 사용하세요.

사용 사례: 데스크톱 알림, 푸시 알림, 감사 로그, Slack 웹후크, 에스컬레이션 라우팅, 지표.

예시 — macOS의 데스크톱 알림:

import subprocess

def notify_approval(command, description, session_key, **kwargs):
title = "Hermes needs approval"
body = f"{description}: {command[:80]}"
subprocess.Popen([
"osascript", "-e",
f'display notification "{body}" with title "{title}"',
])

def register(ctx):
ctx.register_hook("pre_approval_request", notify_approval)

post_approval_response

사용자가 승인 요청에 응답한 (또는 요청이 시간 초과된 경우)에 발생합니다.

콜백 시그니처:

def my_callback(
command: str,
description: str,
pattern_key: str,
pattern_keys: list[str],
session_key: str,
surface: str,
choice: str,
**kwargs,
):
``pre_approval_request`와 동일한 키워드 인자, 추가로:

| 매개변수 | 타입 | 설명 |
|-----------|------|-------------|
| `choice` | `str` | `"once"`, `"session"`, `"always"`, `"deny"`, 또는 `"timeout"` 중 하나 |

**반환 값:** 무시됨.

**사용 사례:** 일치하는 데스크톱 알림을 닫고, 최종 결정을 감사 로그에 기록하며, 지표를 업데이트하고, 속도 제한기를 진행합니다.

```python
def log_decision(command, choice, session_key, **kwargs):
logger.info("approval %s: %s for session %s", choice, command[:60], session_key)

def register(ctx):
ctx.register_hook("post_approval_response", log_decision)

transform_tool_result

도구가 반환된 와 결과가 대화에 추가되기 에 실행됩니다. 플러그인이 모델이 보기 전에 모든 도구의 결과 문자열(터미널 출력뿐만 아니라)을 다시 쓸 수 있게 합니다.

콜백 시그니처:

def my_callback(
tool_name: str,
arguments: dict,
result: str,
task_id: str | None,
**kwargs,
) -> str | None:
매개변수유형설명
tool_namestr결과를 생성한 도구 (read_file, web_extract, delegate_task, …).
argumentsdict모델이 도구를 호출할 때 사용한 인수.
resultstr도구의 원시 결과 문자열, 잘림(truncation) 및 ANSI 제거 후.
task_id`str "}없음`

반환 값: 결과를 대체하려면 str, 변경하지 않으려면 None.

사용 사례: web_extract 출력에서 조직별 PII를 삭제하고, 긴 JSON 도구 응답을 요약 헤더로 감싸며, read_file 결과에 검색 강화 힌트를 삽입하고, delegate_task 하위 에이전트 보고서를 프로젝트별 스키마로 다시 작성합니다.

import re
SECRET = re.compile(r"sk-[A-Za-z0-9]{32,}")

def redact_secrets(tool_name, result, **kwargs):
if SECRET.search(result):
return SECRET.sub("[REDACTED]", result)
return None

def register(ctx):
ctx.register_hook("transform_tool_result", redact_secrets)

모든 도구에 적용됩니다. 터미널 전용 재작성은 아래 transform_terminal_output 을 참조하세요 — 더 좁고 파이프라인에서 더 일찍 실행됩니다(자르기 전, 삭제 전).


transform_terminal_output

terminal 도구의 전경 출력 파이프라인 내부에서 실행되며, 기본 잘림, ANSI 제거, 비밀 정보 삭제 이전에 발생합니다. 플러그인이 하위 처리 과정이 이를 건드리기 전에 셸 명령의 원시 stdout/stderr를 다시 작성할 수 있게 합니다.

콜백 시그니처:

def my_callback(
command: str,
output: str,
exit_code: int,
cwd: str,
task_id: str | None,
**kwargs,
) -> str | None:
매개변수유형설명
commandstr출력을 생성한 셸 명령어.
outputstr원시 결합 stdout/stderr (매우 클 수 있음 — 후크 후에 잘림 발생).
exit_codeint프로세스 종료 코드.
cwdstr명령이 실행된 작업 디렉토리.

반환 값: 출력물을 대체하려면 str를, 변경하지 않으려면 None를 사용하세요.

사용 사례: 대규모 출력을 생성하는 명령에 대한 요약을 주입(du -ah, find, tree), 다운스트림 훅이 처리 방법을 알 수 있도록 프로젝트별 마커로 출력을 태그, 실행 간 차이가 있어 프롬프트 캐싱을 방해하는 타이밍 노이즈 제거.

def summarize_find(command, output, **kwargs):
if command.startswith("find ") and len(output) > 50_000:
lines = output.count("\n")
head = "\n".join(output.splitlines()[:40])
return f"{head}\n\n[summary: {lines} paths total, showing first 40]"
return None

def register(ctx):
ctx.register_hook("transform_terminal_output", summarize_find)
``transform_tool_result`(모든 다른 도구를 포함함)와 잘 어울립니다.

---

### `transform_llm_output` \{#onsessionfinalize}

도구 호출 루프가 완료되고 모델이 최종 응답을 생성한 후 **한 턴당 한 번**발동하며, 해당 응답이 사용자(CLI, 게이트웨이, 또는 프로그램 호출자)에게 전달되기**전에** 발동합니다. 플러그인이 고전적인 프로그래밍 방식을 사용하여 어시스턴트의 최종 텍스트를 재작성할 수 있게 해주며 — SOUL 풍의 텍스트나 스킬 기반 변환에 추가 추론 토큰을 사용하지 않습니다.

**콜백 시그니처:**

```python
def my_callback(
response_text: str,
session_id: str,
model: str,
platform: str,
**kwargs,
) -> str | None:
매개변수유형설명
response_textstr이번 차례에 대한 조수의 최종 응답 텍스트.
session_idstr이 대화의 세션 ID(일회성 실행의 경우 비어있을 수 있음).
modelstr응답을 생성한 모델 이름 (예: anthropic/claude-sonnet-4.6)
platformstr배달 플랫폼 (cli, telegram, discord, …; 설정되지 않은 경우 비어 있음).

반환 값:응답 텍스트를 대체할 비어 있지 않은 str, 변경하지 않으려면 None 또는 빈 문자열. 여러 플러그인이 등록된 경우첫 번째 비어 있지 않은 문자열이 우선transform_tool_result를 반영합니다.

사용 사례: 성격/어휘 변환 적용(해적 말투, 스폰지밥), 최종 텍스트에서 사용자 특정 식별자 삭제, 프로젝트별 서명 푸터 추가, SOUL 지침에 토큰을 낭비하지 않고 하우스 스타일 가이드 적용.

import os, re

def spongebob(response_text, **kwargs):
if os.environ.get("SPONGEBOB_MODE") != "on":
return None # pass through unchanged
return re.sub(r"!", "!! Tartar sauce!", response_text)

def register(ctx):
ctx.register_hook("transform_llm_output", spongebob)

이 훅은 비어 있지 않고 중단되지 않은 응답에서 보호됩니다 — 중지 버튼에 의한 중단이나 빈 턴에서는 작동하지 않습니다. 예외는 경고로 기록되며 에이전트 실행을 중단하지 않습니다.


쉘 훅

cli-config.yaml에 셸 스크립트 훅을 선언하면 해당 플러그인-훅 이벤트가 발생할 때마다 Hermes가 이를 서브프로세스로 실행합니다 — CLI와 게이트웨이 세션 모두에서. Python 플러그인 작성은 필요하지 않습니다.

드롭인, 단일 파일 스크립트(Bash, Python, shebang이 있는 모든 것)를 사용하고 싶을 때 셸 후크를 사용하세요:

  • 도구 호출 차단 — 위험한 terminal 명령을 거부하고, 디렉터리별 정책을 시행하며, 파괴적인 write_file / patch 작업에 대한 승인을 요구합니다.
  • 도구 호출 후 실행 — 에이전트가 작성한 Python 또는 TypeScript 파일을 자동으로 포맷하고, API 호출을 기록하며, CI 워크플로를 트리거합니다.
  • 다음 LLM 턴에 컨텍스트 주입 — 사용자 메시지 앞에 git status 출력, 현재 요일 또는 검색된 문서를 추가합니다 (자세한 내용은 pre_llm_call 참조).
  • 라이프사이클 이벤트 관찰 — 서브에이전트가 완료될 때 (subagent_stop) 또는 세션이 시작될 때 (on_session_start) 로그 라인을 작성하세요.

쉘 훅은 CLI 시작(hermes_cli/main.py) 및 게이트웨이 시작(gateway/run.py) 시 agent.shell_hooks.register_from_config(cfg)를 호출하여 등록됩니다. 이들은 Python 플러그인 훅과 자연스럽게 결합되며, 둘 다 동일한 디스패처를 통해 흐릅니다.

한눈에 보는 비교

차원쉘 훅플러그인 훅게이트웨이 훅
선언됨hooks:~/.hermes/config.yaml에서 차단register()plugin.yaml 플러그인에서HOOK.yaml + handler.py 디렉토리
아래에 거주~/.hermes/agent-hooks/ (관례상)~/.hermes/plugins/<name>/~/.hermes/hooks/<name>/
언어Any (Bash, Python, Go 바이너리, …)파이썬만파이썬만
달린다CLI + 게이트웨이CLI + 게이트웨이게이트웨이 전용
이벤트VALID_HOOKS (포함 subagent_stop)VALID_HOOKS게이트웨이 수명주기 (gateway:startup, agent:*, command:*)
도구 호출을 차단할 수 있음예 (pre_tool_call)예 (pre_tool_call)No
LLM 맥락을 주입할 수 있음예 (pre_llm_call)예 (pre_llm_call)No
동의(event, command) 쌍당 첫 사용 프롬프트암묵적(파이썬 플러그인 신뢰)암시적(디렉터리 신뢰)
프로세스 간 격리예 (하위 프로세스)아니요 (진행 중)아니요 (진행 중)

구성 스키마

hooks:
<event_name>: # Must be in VALID_HOOKS
- matcher: "<regex>" # Optional; used for pre/post_tool_call only
command: "<shell command>" # Required; runs via shlex.split, shell=False
timeout: <seconds> # Optional; default 60, capped at 300

hooks_auto_accept: false # See "Consent model" below

이벤트 이름은 플러그인 훅 이벤트 중 하나여야 합니다; 오타가 있으면 "X를 의미했나요?" 경고가 표시되고 건너뜁니다. 단일 항목 안의 알 수 없는 키는 무시됩니다; command가 없으면 경고와 함께 건너뜁니다. timeout > 300는 경고와 함께 제한됩니다.

JSON 와이어 프로토콜

이벤트가 발생할 때마다 Hermes는 일치하는 모든 훅(매처 허용 시)에 대해 서브프로세스를 생성하고, JSON 페이로드를 stdin으로 전달하며, stdout을 JSON으로 다시 읽습니다.

stdin — 스크립트가 받는 페이로드:

{
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": {"command": "rm -rf /"},
"session_id": "sess_abc123",
"cwd": "/home/user/project",
"extra": {"task_id": "...", "tool_call_id": "..."}
}
``tool_name`와 `tool_input`은 비도구 이벤트(`pre_llm_call`, `subagent_stop`, 세션 수명주기)에 대한 `null`입니다. `extra` 사전은 모든 이벤트별 kwargs(`user_message`, `conversation_history`, `child_role`, `duration_ms` 등)을 담고 있습니다. 직렬화할 수 없는 값은 생략되지 않고 문자열로 변환됩니다.

**stdout — 선택적 응답:**

```jsonc
// Block a pre_tool_call (both shapes accepted; normalised internally):
&#123;"decision": "block", "reason": "Forbidden: rm -rf"&#125; // Claude-Code style
&#123;"action": "block", "message": "Forbidden: rm -rf"&#125; // Hermes-canonical

// Inject 컨텍스트 for pre_llm_call:
&#123;"컨텍스트": "Today is Friday, 2026-04-17"&#125;

// Silent no-op — any empty / non-matching output is fine:

잘못된 JSON, 0이 아닌 종료 코드 및 타임아웃은 경고를 기록하지만 에이전트 루프를 중단하지 않습니다.

실제 예제

1. 매번 파일을 쓴 후 Python 파일을 자동으로 포맷합니다

# ~/.hermes/config.yaml
hooks:
post_tool_call:
- matcher: "write_file|patch"
command: "~/.hermes/agent-hooks/auto-format.sh"
````bash
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/auto-format.sh
payload="$(cat -)"
path=$(echo "$payload" | jq -r '.tool_input.path // empty')
[[ "$path" == *.py ]] && command -v black >/dev/null && black "$path" 2>/dev/null
printf '{}\n'

에이전트의 파일에 대한 인-컨텍스트 뷰는 자동으로 다시 읽히지 않습니다 — 재포맷은 디스크상의 파일에만 영향을 미칩니다. 이후의 read_file 호출은 포맷된 버전을 가져옵니다.

2. 파괴적인 terminal 명령 차단

hooks:
pre_tool_call:
- matcher: "terminal"
command: "~/.hermes/agent-hooks/block-rm-rf.sh"
timeout: 5
````bash
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
printf '&#123;"decision": "block", "reason": "blocked: rm -rf / is not permitted"&#125;\n'
else
printf '&#123;&#125;\n'
fi

3. 모든 턴에 git status를 주입하십시오 (Claude-Code UserPromptSubmit와 동일)

hooks:
pre_llm_call:
- command: "~/.hermes/agent-hooks/inject-cwd-컨텍스트.sh"
````bash
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null # discard stdin payload
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
jq --null-input --arg s "$status" \
'{context: ("Uncommitted changes in cwd:\n" + $s)}'
else
printf '{}\n'
fi

Claude Code의 UserPromptSubmit 이벤트는 의도적으로 별도의 Hermes 이벤트가 아닙니다 — pre_llm_call은 같은 위치에서 발생하며 이미 컨텍스트 주입을 지원합니다. 여기에서 사용하세요.

4. 모든 하위 에이전트 완료를 기록하세요

hooks:
subagent_stop:
- command: "~/.hermes/agent-hooks/log-orchestration.sh"
````bash
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/log-orchestration.sh
log=~/.hermes/logs/orchestration.log
jq -c '{ts: now, parent:.session_id, extra:.extra}' < /dev/stdin >> "$log"
printf '&#123;&#125;\n'

각 고유한 (event, command) 쌍은 Hermes가 처음 볼 때 사용자 승인을 요청하며, 이후 결정은 ~/.hermes/shell-hooks-allowlist.json에 저장됩니다. 이후 실행(CLI 또는 게이트웨이)에서는 프롬프트를 건너뜁니다.

세 개의 탈출 해치가 대화형 프롬프트를 우회합니다 — 어느 하나면 충분합니다:

  1. CLI의 --accept-hooks 플래그(예: hermes --accept-hooks chat)
  2. HERMES_ACCEPT_HOOKS=1 환경 변수
  3. hooks_auto_accept: truecli-config.yaml

Non-TTY 실행(게이트웨이, cron, CI)에는 이 세 가지 중 하나가 필요합니다 — 그렇지 않으면 새로 추가된 훅이 조용히 등록되지 않은 상태로 남고 경고를 기록합니다.

스크립트 편집은 조용히 신뢰됩니다. 허용 목록 키는 스크립트의 해시가 아니라 정확한 명령 문자열에 적용되므로, 디스크상의 스크립트를 편집해도 동의가 무효화되지 않습니다. hermes hooks doctor 는 mtime 변화를 표시하여 편집을 확인하고 재승인할지 결정할 수 있게 합니다.

hermes hooks CLI

명령그것이 하는 일
hermes hooks list매처, 타임아웃, 동의 상태와 함께 구성된 후크 덤프
hermes hooks test &lt;event&gt; [--for-tool X] [--payload-file F]모든 일치하는 훅을 합성 페이로드에 대해 실행하고 파싱된 응답을 출력하세요
hermes hooks revoke &lt;command&gt;다음 재시작 시 적용되도록 &lt;command&gt;와 일치하는 모든 허용 목록 항목을 제거하세요
hermes hooks doctor설정된 각 훅에 대해: 실행 비트, 허용 목록 상태, 수정 시간 차이, JSON 출력 유효성 및 대략적인 실행 시간을 확인합니다

보안

셸 훅은 사용자의 전체 사용자 자격 증명으로 실행됩니다 — 크론 항목이나 셸 별칭과 동일한 신뢰 경계입니다. hooks: 블록을 config.yaml에서 특권 구성으로 취급하세요:

  • 작성했거나 완전히 검토한 스크립트만 참고하세요.
  • 스크립트를 ~/.hermes/agent-hooks/ 안에 보관하여 경로를 쉽게 감사할 수 있도록 하세요.
  • 공유 구성을 가져온 후 등록되기 전에 새로 추가된 훅을 확인하기 위해 hermes hooks doctor를 다시 실행하세요.
  • 만약 여러분의 config.yaml이 팀 전체에서 버전 관리되고 있다면, hooks: 섹션을 변경하는 PR을 CI 설정을 검토하듯 검토하세요.

순서와 우선순위

파이썬 플러그인 훅과 셸 훅은 모두 동일한 invoke_hook() 디스패처를 통해 흐릅니다. 파이썬 플러그인은 먼저 등록되고(discover_and_load()), 셸 훅은 그 다음에 등록됩니다(register_from_config()), 따라서 동점인 경우 파이썬 pre_tool_call 블록 결정이 우선합니다. 첫 번째 유효한 블록이 승리하며 — 집계기는 어떤 콜백이든 비어 있지 않은 메시지와 함께 {"action": "block", "message": str}를 생성하는 즉시 반환됩니다.