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 |
|
kind |
|
|
text |
The prompt text on a read event, the assistant's final message on a write event. |
|
session_id |
Harness session identifier, or |
|
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 |
Source code in src/popoto/integrations/hooks.py
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
|
integration does not handle, which is most of them. |
|
NormalizedEvent
|
the harness's per-turn identifier when it sends one (Claude Code |
|
NormalizedEvent
|
|
|
NormalizedEvent
|
what selects the turn-keyed handoff over the session FIFO. |
Source code in src/popoto/integrations/hooks.py
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 |
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
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: |
None
|
Returns:
| Type | Description |
|---|---|
Optional[str]
|
The exact string to write to stdout, or |
Optional[str]
|
stay silent. The caller writes it in a single |
Source code in src/popoto/integrations/hooks.py
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 |
required |
service
|
Any
|
Optional service override, as for :func: |
None
|
Returns:
| Type | Description |
|---|---|
Optional[str]
|
The stdout string, or |
Optional[str]
|
rather than raising: an unparseable payload is a harness problem, |
Optional[str]
|
and crashing would surface it as a failed turn. |