Agent Hooks Need Event Contracts, Not Shell Scripts

Aug 3 2026 · 9 min · Sieon

Agent hooks look harmless when they start as small scripts. Run a formatter before a tool call. Block rm -rf. Send a Slack message when a session ends. Capture a transcript for later review. These are useful conveniences, but they hide a more important architectural fact: once a hook can inspect an agent action and influence what happens next, it is part of the runtime control plane.

That boundary deserves more discipline than a folder of shell scripts.

The useful mental model is simple: every hook invocation should be a typed event contract. The handler can still be a shell command, HTTP endpoint, MCP tool, or small policy service. The contract around it should be explicit enough that the runtime can retry it safely, audit it later, correlate it with traces, and decide what to do when the handler fails.

If that sounds heavy, it is because agent hooks sit exactly where small mistakes become operational incidents. They run near credentials, file systems, tool calls, approvals, memory writes, and model-visible feedback. A weak hook interface does not only fail as automation. It changes the behavior of the agent.

Original Sieon Labs diagram rendered with Kroki from Mermaid source

Hooks are not just extension points

The Claude Code hooks reference describes hooks as user-defined shell commands, HTTP endpoints, or prompts that execute at defined lifecycle points. The runtime passes JSON context to command hooks on stdin and to HTTP hooks as a POST body. Events can fire once per session, once per turn, or on every tool call, including PreToolUse and PostToolUse.

That is already an event system.

The MCP transport specification makes a similar point from the protocol side. In stdio mode, a server reads JSON-RPC from stdin and writes JSON-RPC to stdout. It must not write arbitrary output to stdout because stdout is the protocol channel. In Streamable HTTP, clients and servers exchange JSON-RPC over POST and optional server-sent events, with session IDs and event IDs available for stateful and resumable interactions.

Both systems teach the same lesson: when the transport is simple, the message contract becomes more important, not less important.

A hook that receives JSON on stdin is not safer because it is local. A local handler can still block a tool call, leak context, corrupt files, hang the runtime, double-send an external request, or produce model-visible advice that changes the next action. The control-plane question is not whether the hook is a script. The question is whether the runtime and the operator understand the contract.

The event envelope is the real interface

A production hook event needs more than event_name and a blob of context. It needs an envelope that answers the operational questions a responder will ask after something goes wrong:

Field Why it matters
schema_version Lets handlers evolve without guessing which payload shape arrived.
event_id Gives each hook invocation a stable identity for logs, retries, and audits.
idempotency_key Prevents duplicate side effects when a hook is retried.
trace_id and span_id Connects the hook decision to the agent run and downstream tool call.
event_type Separates lifecycle events, tool events, memory events, and approval events.
subject Names what the hook is deciding about, such as a tool call, file path, or memory write.
actor Identifies the agent, user, subagent, or service that caused the event.
decision_set Limits output to known decisions such as allow, deny, redact, retry, defer, or stop.
timeout_ms Makes blocking behavior explicit.
redaction_profile States which fields were removed or summarized before the hook saw the payload.

The event envelope is where runtime policy becomes testable. Without it, every hook handler invents its own assumptions. One script treats missing input as allow. Another treats it as deny. A third writes a warning to stdout and accidentally changes the protocol response. The failure is not the script language. The failure is the absence of a contract.

Traces turn hook decisions into evidence

OpenTelemetry describes traces as the path of a request through an application. Spans represent units of work, share a trace ID, and can include events, attributes, links, and status. That model maps cleanly onto agent hooks.

A PreToolUse policy check is a span or a span event. A model call is a span. A tool execution is a span. A recovery decision after a failed tool call is another span. If the work crosses an async boundary, OpenTelemetry span links and producer or consumer span kinds give the runtime a way to preserve causality without pretending that everything happened in one synchronous stack.

This matters because agent incidents are rarely single-function bugs. The bad outcome is often a chain:

  1. The model proposed a tool call.
  2. A policy hook allowed it because the input summary omitted one risky field.
  3. A tool failed halfway through.
  4. A post-tool hook converted the failure into ambiguous text.
  5. The model retried with a slightly different argument.
  6. The second attempt succeeded against the wrong target.

A transcript can show the symptoms. A trace can show the causal structure. A typed hook event can show the decision input and decision output at each boundary.

That is the difference between debugging by folklore and debugging by evidence.

A minimal contract is better than a clever hook

Here is a small event shape that is boring on purpose:

{
  "schema_version": "hook.event.v1",
  "event_id": "evt_01JZK9H6AV6M8P0KVF6R6K4W1X",
  "idempotency_key": "tool:file_write:/repo/app.py:sha256:abc123",
  "trace_id": "5b8aa5a2d2c872e8321cf37308d69df2",
  "parent_span_id": "051581bf3cb55c13",
  "event_type": "tool.pre_use",
  "actor": {
    "kind": "agent",
    "name": "daily-editorial-runtime"
  },
  "subject": {
    "tool": "write_file",
    "resource": "/opt/data/forge/articles/example/article.md",
    "operation": "overwrite"
  },
  "redaction_profile": "secrets-and-private-notes-removed",
  "timeout_ms": 1500,
  "allowed_decisions": ["allow", "deny", "redact", "defer"]
}

The corresponding response should be just as narrow:

{
  "schema_version": "hook.decision.v1",
  "event_id": "evt_01JZK9H6AV6M8P0KVF6R6K4W1X",
  "decision": "deny",
  "reason_code": "restricted_resource",
  "message": "Requested resource is outside the approved workspace boundary.",
  "retryable": false
}

This is less expressive than a general script, which is the point. The runtime should not need to parse a paragraph, infer whether a nonzero exit code means policy denial or handler crash, or decide whether duplicated output is safe to replay.

The handler can still be implemented in Python:

import json
import sys

ALLOWED_DECISIONS = {"allow", "deny", "redact", "defer"}


def main() -> int:
    event = json.load(sys.stdin)

    required = ["schema_version", "event_id", "event_type", "subject"]
    missing = [field for field in required if field not in event]
    if missing:
        print(json.dumps({
            "schema_version": "hook.decision.v1",
            "event_id": event.get("event_id", "unknown"),
            "decision": "deny",
            "reason_code": "invalid_event",
            "message": "Missing required fields: " + ", ".join(missing),
            "retryable": False,
        }))
        return 0

    subject = event["subject"]
    resource = subject.get("resource", "")
    if "/restricted/" in resource:
        decision = "deny"
        reason = "restricted_resource"
    else:
        decision = "allow"
        reason = "policy_ok"

    assert decision in ALLOWED_DECISIONS
    print(json.dumps({
        "schema_version": "hook.decision.v1",
        "event_id": event["event_id"],
        "decision": decision,
        "reason_code": reason,
        "retryable": False,
    }))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The important part is not Python. The important part is that the runtime can distinguish a policy decision from a handler failure. Exit status can still signal handler execution health, but the decision should be structured data.

Design hooks around failure, not success

Most hook designs start with the happy path: when event X occurs, run action Y. Production hooks should start with failure cases.

What if the hook times out? What if the hook service is unavailable? What if a retry reaches the handler after the original request already succeeded? What if a hook reads redacted input and cannot make a safe decision? What if two hooks disagree? What if the hook emits a decision that the runtime version does not understand?

A practical runtime needs default policies for each case:

Failure Safer default
Policy hook timeout Deny or defer risky actions, allow only low-risk read-only actions.
Audit hook timeout Continue only if the event is durably queued for later processing.
Unknown decision Treat as handler error, not as allow.
Duplicate event Use idempotency key and return the original decision.
Redacted field required Defer to human review or a higher-trust policy service.
Handler crash Record span status as error and apply the event type's fallback policy.

This is where reliability patterns from LLM gateways become relevant. Provider fallback, retries, timeouts, and health checks are not only model-routing features. They are runtime-control features. Hooks need the same clarity because a hook can be more sensitive than the model call it surrounds.

Governance belongs at the hook layer

Claude Code's hook reference includes managed settings and HTTP hook allowlists. That is not an administrative detail. It points to the governance boundary.

A mature agent platform should be able to answer:

  • Which hook packages are allowed in this workspace?
  • Which HTTP endpoints can receive hook payloads?
  • Which environment variables may be interpolated into hook headers?
  • Which hooks can block tool calls?
  • Which hooks can write to memory?
  • Which hooks run inside subagents?
  • Which hook decisions are visible to the model?

These questions should not be left to convention. If every project adds local hooks with different scopes, every agent run becomes a different runtime. That may be acceptable for experiments. It is not acceptable for systems that touch repositories, credentials, production data, or publishing workflows.

The governance rule is: any hook that can affect agent behavior must be reviewable as runtime policy.

The decision rule

Use hooks for automation, but design them as event contracts.

If a hook only sends a notification, a simple handler may be enough. If a hook can allow, deny, redact, retry, enrich, persist, or change model-visible context, it needs a typed envelope, structured decision output, trace correlation, timeout behavior, idempotency, redaction rules, and an audit trail.

The sentence to remember is this: a hook is safe only when the runtime can explain what event arrived, what decision was made, why it was made, and what happened when the decision path failed.

That is the difference between a useful extension point and an unreviewed control plane.

Sources

References

  1. Anthropic Claude Code Docs, Hooks reference
  2. Model Context Protocol Specification, Transports
  3. OpenTelemetry Docs, Traces
  4. OpenAI Agents SDK, Tracing
  5. LiteLLM Docs, Fallbacks (Provider Failover)