Skip to content

popoto.integrations.hooks

popoto.integrations.hooks

Harness hook adapter: normalize a payload, call the service, emit a payload.

This is the subconscious half of the integration. A hook fires because the harness reached a point in its own turn loop, not because the model decided to call something, which is the only way recall and capture can happen on every turn.

Claude Code and Codex have converged on the same hook contract: JSON on stdin carrying hook_event_name, UserPromptSubmit before the model sees the prompt, Stop after the turn with the assistant's text in last_assistant_message, and hookSpecificOutput.additionalContext as the injection channel. One executable dispatching on hook_event_name therefore serves both harnesses with no per-harness code. Hermes and OpenClaw send different field names for the same four facts, so they are handled by normalizing their payloads into the same shape. Hermes's plugin hooks (plugins/hermes/, registered via ctx.register_hook) pass flat keyword args in-process, with no cwd and no sub-object nesting; the service is called directly rather than through this module's stdin/stdout run() entry point.

Output rules that are not negotiable:

  • Build the response as one string and write it once. Codex treats stdout that starts with { but fails to parse as a hook failure, so a partial write is worse than no write.
  • Emit nothing when there is nothing to inject. No empty additionalContext, no empty header.
  • Always exit 0. A memory failure must never fail a user's turn.

READ_EVENTS = frozenset({'UserPromptSubmit', 'user_prompt_submit', 'pre_llm_call', 'before_prompt_build', 'agent_turn_prepare'}) module-attribute

Events that inject context, one per harness's pre-turn hook. Codex matches Claude Code; the remaining names are the Hermes and OpenClaw equivalents.

These are the events this adapter uses, not the complete set a harness accepts. Claude Code also honors additionalContext from UserPromptExpansion, SessionStart, and PostToolUse -- the last one verified end to end on 2026-08-31 against a scratch hook emitting a nonce the model was asked to echo back, and declared by the Agent SDK's PostToolUseHookSpecificOutput. An earlier version of this docstring claimed PostToolUse could not inject, following the public hooks documentation, which does not list it. The documentation is behind the implementation; do not reason about this contract from it.

The pre-turn event stays the only read hook here regardless. Injecting per tool call multiplies resident context on a path already bounded by POPOTO_MEMORY_MAX_TOKENS, and turn granularity is what the write hook's outcome handoff is paired against.

Whatever the event, the payload must be nested under hookSpecificOutput. A bare top-level {"additionalContext": ...} is parsed, matched against no key the harness acts on, and discarded silently -- exit 0, no warning, nothing injected. :func:render_context emits the nested shape; do not "simplify" it.

WRITE_EVENTS = frozenset({'Stop', 'SubagentStop', 'stop', 'post_llm_call', 'llm_output', 'agent_end'}) module-attribute

Events that capture the turn. All of them carry the assistant's final text, so no transcript file is ever parsed and no JSONL schema change can break capture.

NormalizedEvent

A harness payload reduced to the five facts the service needs.

Attributes:

Name Type Description
event

The raw hook_event_name as sent by the harness.

kind

"read", "write", or "ignore".

text

The prompt text on a read event, the assistant's final message on a write event.

session_id

Harness session identifier, or None.

cwd

Working directory the harness reports, used to derive the default agent id. The harness's cwd is more accurate than the hook process's own, which some harnesses do not set.

turn_id

The harness's own per-turn identifier, or None. Claude Code sends prompt_id and Codex sends turn_id, both on the read event and the write event of the same turn, which is what lets the service pair an outcome report with the read that staged it. OpenClaw sends one too, but not on the event: its hooks take a second ctx argument carrying runId, identical across the before_prompt_build and llm_output of one turn, which the plugin forwards as turn_id. Hermes does send a turn id: it mints one once per turn (agent/turn_context.py:370) and passes the same local to both pre_llm_call and post_llm_call (agent/turn_context.py:696-707, agent/turn_finalizer.py:484-493); the plugin forwards it verbatim as turn_id, which this function already reads flat with no code change (see #688, #704). The session-wide FIFO fallback now applies only when POPOTO_MEMORY_TURN_KEYED=0 or a harness genuinely sends no id. Populated on read, write and ignore events alike: it is a fact about the payload, not about the branch.

Source code in src/popoto/integrations/hooks.py
class NormalizedEvent:
    """A harness payload reduced to the five facts the service needs.

    Attributes:
        event: The raw ``hook_event_name`` as sent by the harness.
        kind: ``"read"``, ``"write"``, or ``"ignore"``.
        text: The prompt text on a read event, the assistant's final message
            on a write event.
        session_id: Harness session identifier, or ``None``.
        cwd: Working directory the harness reports, used to derive the
            default agent id. The harness's cwd is more accurate than the
            hook process's own, which some harnesses do not set.
        turn_id: The harness's own per-turn identifier, or ``None``. Claude
            Code sends ``prompt_id`` and Codex sends ``turn_id``, both on the
            read event *and* the write event of the same turn, which is what
            lets the service pair an outcome report with the read that staged
            it. OpenClaw sends one too, but not on the event: its hooks take a
            second ``ctx`` argument carrying ``runId``, identical across the
            ``before_prompt_build`` and ``llm_output`` of one turn, which the
            plugin forwards as ``turn_id``. Hermes **does** send a turn id: it
            mints one once per turn (``agent/turn_context.py:370``) and passes
            the same local to both ``pre_llm_call`` and ``post_llm_call``
            (``agent/turn_context.py:696-707``,
            ``agent/turn_finalizer.py:484-493``); the plugin forwards it
            verbatim as ``turn_id``, which this function already reads flat
            with no code change (see #688, #704). The session-wide FIFO
            fallback now applies only when ``POPOTO_MEMORY_TURN_KEYED=0`` or a
            harness genuinely sends no id. Populated on
            read, write and ignore events alike: it is a fact about the
            payload, not about the branch.
    """

    __slots__ = ("event", "kind", "text", "session_id", "cwd", "turn_id")

    def __init__(
        self,
        event: str,
        kind: str,
        text: str,
        session_id: Optional[str],
        cwd: Optional[str],
        turn_id: Optional[str] = None,
    ):
        self.event = event
        self.kind = kind
        self.text = text
        self.session_id = session_id
        self.cwd = cwd
        self.turn_id = turn_id

normalize(payload)

Reduce any supported harness payload to a :class:NormalizedEvent.

This is the single point where a harness schema is read. A change to any harness's payload shape touches this function and nothing else.

Parameters:

Name Type Description Default
payload Dict[str, Any]

The decoded hook JSON.

required

Returns:

Name Type Description
A NormalizedEvent

class:NormalizedEvent; kind is "ignore" for events this

NormalizedEvent

integration does not handle, which is most of them. turn_id carries

NormalizedEvent

the harness's per-turn identifier when it sends one (Claude Code

NormalizedEvent

prompt_id, Codex turn_id) and is None otherwise, which is

NormalizedEvent

what selects the turn-keyed handoff over the session FIFO.

Source code in src/popoto/integrations/hooks.py
def normalize(payload: Dict[str, Any]) -> NormalizedEvent:
    """Reduce any supported harness payload to a :class:`NormalizedEvent`.

    This is the single point where a harness schema is read. A change to any
    harness's payload shape touches this function and nothing else.

    Args:
        payload: The decoded hook JSON.

    Returns:
        A :class:`NormalizedEvent`; ``kind`` is ``"ignore"`` for events this
        integration does not handle, which is most of them. ``turn_id`` carries
        the harness's per-turn identifier when it sends one (Claude Code
        ``prompt_id``, Codex ``turn_id``) and is ``None`` otherwise, which is
        what selects the turn-keyed handoff over the session FIFO.
    """
    event = ""
    for name in ("hook_event_name", "hookEventName", "event", "event_type", "type"):
        value = payload.get(name)
        if isinstance(value, str) and value.strip():
            event = value.strip()
            break

    session_id = None
    for name in ("session_id", "sessionId", "conversation_id"):
        value = payload.get(name)
        if isinstance(value, str) and value.strip():
            session_id = value.strip()
            break

    cwd = None
    for name in ("cwd", "working_directory", "workspace"):
        value = payload.get(name)
        if isinstance(value, str) and value.strip():
            cwd = value.strip()
            break

    # The harness's own per-turn identifier. Probed with the same one-level
    # nested search, ``isinstance(str)`` guard and ``.strip()`` truthiness rule
    # the other fields use, so a non-string ``prompt_id`` yields ``None``
    # rather than a TypeError, and an empty or whitespace-only value takes the
    # FIFO path instead of staging an entry tagged with the empty string.
    turn_id = _first_string(payload, _TURN_FIELDS).strip() or None

    if event in READ_EVENTS:
        return NormalizedEvent(
            event,
            "read",
            _first_string(payload, _QUERY_FIELDS),
            session_id,
            cwd,
            turn_id,
        )
    if event in WRITE_EVENTS:
        return NormalizedEvent(
            event,
            "write",
            _first_string(payload, _RESPONSE_FIELDS),
            session_id,
            cwd,
            turn_id,
        )
    return NormalizedEvent(event, "ignore", "", session_id, cwd, turn_id)

render_context(event, context)

Wrap assembled context in the harness's own response shape.

Claude Code and Codex read hookSpecificOutput.additionalContext. Hermes reads context (confirmed against the installed hermes-agent==0.19.0 source, agent/turn_context.py:720-741: the return dict's "context" key, or an equally-accepted bare string, is joined with other plugins' pieces and appended to the user message). OpenClaw reads appendContext. All three place the text in the user turn rather than the system prompt, which is what keeps the cached system prefix intact across turns.

Parameters:

Name Type Description Default
event NormalizedEvent

The normalized event, whose raw event name selects the response shape.

required
context str

The assembled context block. Must be non-empty; callers emit nothing at all when it is empty.

required

Returns:

Type Description
Dict[str, Any]

The response object to serialize.

Source code in src/popoto/integrations/hooks.py
def render_context(event: NormalizedEvent, context: str) -> Dict[str, Any]:
    """Wrap assembled context in the harness's own response shape.

    Claude Code and Codex read ``hookSpecificOutput.additionalContext``.
    Hermes reads ``context`` (confirmed against the installed
    hermes-agent==0.19.0 source, ``agent/turn_context.py:720-741``: the
    return dict's ``"context"`` key, or an equally-accepted bare string, is
    joined with other plugins' pieces and appended to the user message).
    OpenClaw reads ``appendContext``. All three place the text in the *user*
    turn rather than the system prompt, which is what keeps the cached
    system prefix intact across turns.

    Args:
        event: The normalized event, whose raw ``event`` name selects the
            response shape.
        context: The assembled context block. Must be non-empty; callers
            emit nothing at all when it is empty.

    Returns:
        The response object to serialize.
    """
    if event.event in ("pre_llm_call",):
        return {"context": context}
    if event.event in ("before_prompt_build", "agent_turn_prepare"):
        return {"appendContext": context}
    return {
        "hookSpecificOutput": {
            "hookEventName": event.event,
            "additionalContext": context,
        }
    }

handle_payload(payload, service=None)

Run one hook event end to end.

Parameters:

Name Type Description Default
payload Dict[str, Any]

The decoded hook JSON.

required
service Any

An optional :class:~popoto.integrations.service.MemoryService. Defaults to one built from the environment and the payload's cwd. Plugin-hook payloads (Hermes) carry no cwd at all, so that fallback never applies to them in practice -- the Hermes plugin always passes a prebuilt service, which is how it and the tests avoid rebuilding the service per event and how operators are expected to set POPOTO_MEMORY_AGENT_ID explicitly instead of relying on a derived one.

None

Returns:

Type Description
Optional[str]

The exact string to write to stdout, or None when the hook must

Optional[str]

stay silent. The caller writes it in a single write call.

Source code in src/popoto/integrations/hooks.py
def handle_payload(payload: Dict[str, Any], service: Any = None) -> Optional[str]:
    """Run one hook event end to end.

    Args:
        payload: The decoded hook JSON.
        service: An optional :class:`~popoto.integrations.service.MemoryService`.
            Defaults to one built from the environment and the payload's
            ``cwd``. Plugin-hook payloads (Hermes) carry no ``cwd`` at all,
            so that fallback never applies to them in practice -- the Hermes
            plugin always passes a prebuilt service, which is how it and the
            tests avoid rebuilding the service per event and how operators
            are expected to set ``POPOTO_MEMORY_AGENT_ID`` explicitly instead
            of relying on a derived one.

    Returns:
        The exact string to write to stdout, or ``None`` when the hook must
        stay silent. The caller writes it in a single ``write`` call.
    """
    event = normalize(payload)
    if event.kind == "ignore":
        return None

    if service is None:
        from .config import MemoryConfig
        from .service import MemoryService

        service = MemoryService(MemoryConfig.from_env(os.environ, cwd=event.cwd))

    if not service.config.enabled:
        return None

    if event.kind == "read":
        context = service.assemble(
            event.text,
            session_id=event.session_id,
            turn_id=event.turn_id,
        )
        if not context.strip():
            return None
        return json.dumps(render_context(event, context))

    service.capture(event.text, session_id=event.session_id)
    if event.session_id:
        # A hook fires on every turn and cannot know whether a surfaced
        # memory actually influenced the response, so it must not claim
        # "acted" (which strengthens ConfidenceField/decay clocks on every
        # turn and defeats decay entirely). "used" only confirms the staged
        # read. See fields/observation.py for the outcome effects matrix.
        service.feedback(
            event.session_id,
            outcome="used",
            turn_id=event.turn_id,
        )
    return None

run(stdin_text, service=None)

Decode a raw stdin body and handle it.

Parameters:

Name Type Description Default
stdin_text str

The raw bytes the harness wrote to the hook's stdin, already decoded to str.

required
service Any

Optional service override, as for :func:handle_payload.

None

Returns:

Type Description
Optional[str]

The stdout string, or None. Malformed JSON returns None

Optional[str]

rather than raising: an unparseable payload is a harness problem,

Optional[str]

and crashing would surface it as a failed turn.

Source code in src/popoto/integrations/hooks.py
def run(stdin_text: str, service: Any = None) -> Optional[str]:
    """Decode a raw stdin body and handle it.

    Args:
        stdin_text: The raw bytes the harness wrote to the hook's stdin,
            already decoded to ``str``.
        service: Optional service override, as for :func:`handle_payload`.

    Returns:
        The stdout string, or ``None``. Malformed JSON returns ``None``
        rather than raising: an unparseable payload is a harness problem,
        and crashing would surface it as a failed turn.
    """
    if not stdin_text or not stdin_text.strip():
        return None
    try:
        payload = json.loads(stdin_text)
    except (ValueError, TypeError):
        _log_hook_error(
            "hook_decode", ValueError(f"unparseable stdin ({len(stdin_text)} bytes)")
        )
        return None
    if not isinstance(payload, dict):
        _log_hook_error(
            "hook_decode", ValueError(f"stdin was {type(payload).__name__}, not object")
        )
        return None
    try:
        return handle_payload(payload, service=service)
    except Exception as exc:
        # Covers a misconfigured POPOTO_MEMORY_URL, which raises out of
        # MemoryService construction. Exiting 0 keeps the turn alive; the
        # log line is what stops it from being invisible.
        _log_hook_error("hook_run", exc)
        return None