How the Hook Output JSON Shape Differs Between Native Claude Code, Codex, and Qoder
The writeHookOutput function in Ponytail emits three distinct JSON schemas depending on whether it detects Native Claude Code, Claude Code Codex, or Qoder, with only Codex requiring a systemMessage field and only Native Claude supporting raw text output for non-subagent events.
The DietrichGebert/ponytail repository provides a portable hook system that adapts its hook output JSON shape to the specific LLM host environment. Understanding these differences is critical when debugging context passing or extending the plugin across different AI coding agents.
Environment Detection Logic
Inside hooks/ponytail-runtime.js, the writeHookOutput function determines the target environment by checking for specific environment variables at runtime. The detection follows a priority order:
- Codex: Identified by the presence of
PLUGIN_DATA(settingisCodexto true) - Qoder: Identified by the presence of
QODER_SESSION_ID(settingisQoderto true) - Native Claude Code: Default fallback when neither Copilot, Codex, nor Qoder variables are present
This detection logic resides in the conditional branches spanning lines 58–89 of the runtime file.
JSON Output Shapes by Environment
Each environment expects a different payload structure to correctly surface additional context to the user or sub-agent.
Native Claude Code
When running in the native Anthropic CLI tool (no special environment variables set), the function produces two possible output shapes depending on the event type.
For SubagentStart events (lines 82–89), the output wraps context in a structured object:
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": "<text>"
}
}
For all other events, the function bypasses JSON serialization entirely and emits the raw text string directly to stdout. This allows Native Claude to consume plain context without additional parsing overhead.
Claude Code Codex
When the Codex plugin is active (isCodex === true), the output always includes a systemMessage field that prefixes the current Ponytail mode. According to lines 58–66, the structure is:
{
"systemMessage": "PONYTAIL:<MODE>",
"hookSpecificOutput": {
"hookEventName": "<event>",
"additionalContext": "<text>"
}
}
The hookSpecificOutput block is appended only when additionalContext is non-empty. The systemMessage serves as a mode banner that Codex renders in the conversation interface to indicate whether the plugin is operating in lite, full, or another mode.
Qoder
Qoder sessions (isQoder === true) expect a simplified structure that omits the systemMessage entirely. As implemented in lines 69–79, the output contains only the hookSpecificOutput wrapper:
{
"hookSpecificOutput": {
"hookEventName": "<event>",
"additionalContext": "<text>"
}
}
Qoder injects this context directly into the agent's conversation stream without requiring the mode prefix, relying instead on internal session state tracked via QODER_SESSION_ID.
Key Structural Differences
Three critical distinctions separate the implementations:
- Presence of
systemMessage: Only Codex requires this field to display the mode banner. Qoder and Native Claude omit it entirely. - Event-specific handling: Native Claude treats
SubagentStartas a special case requiring JSON wrapping, while emitting raw text for all other events. Both Codex and Qoder consistently wrap output inhookSpecificOutputobjects when context is present. - Raw text vs. JSON: Native Claude is the only environment that outputs unwrapped plain text for standard events, whereas Codex and Qoder always produce valid JSON objects.
Practical Code Examples
Emitting for Codex with Mode Context
// PLUGIN_DATA is present (isCodex === true)
writeHookOutput('UserPromptSubmit', 'full', 'Review this PR');
Output:
{
"systemMessage": "PONYTAIL:FULL",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Review this PR"
}
}
Emitting for Qoder
// QODER_SESSION_ID is present (isQoder === true)
writeHookOutput('UserPromptSubmit', 'full', 'Review this PR');
Output:
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Review this PR"
}
}
Native Claude SubagentStart Event
// No environment variables set (native mode)
writeHookOutput('SubagentStart', 'lite', 'Initialize sub-agent');
Output:
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": "Initialize sub-agent"
}
}
Native Claude Standard Event
writeHookOutput('UserPromptSubmit', 'lite', 'Just a plain text note');
Output:
Just a plain text note
Implementation Reference
The core logic resides in hooks/ponytail-runtime.js, which defines the writeHookOutput function and environment detection heuristics. The function interacts with hooks/ponytail-mode-tracker.js to persist and retrieve the active mode before serializing output. These divergent JSON shapes ensure compatibility with each host's specific context consumption protocol—Codex requires explicit mode signaling via systemMessage, Qoder expects minimal JSON payloads, and Native Claude optimizes for raw text efficiency except when initializing subagents.
Summary
- Codex requires a
systemMessagefield containing"PONYTAIL:<MODE>"plus a conditionalhookSpecificOutputobject. - Qoder accepts only the
hookSpecificOutputwrapper without anysystemMessagefield. - Native Claude Code emits raw text for most events but wraps
SubagentStartevents in ahookSpecificOutputobject. - All three formats are generated by
writeHookOutputinhooks/ponytail-runtime.jsbased on environment variable detection.
Frequently Asked Questions
Why does Codex require a systemMessage field while Qoder does not?
Codex uses the systemMessage to render a visual mode banner in the conversation interface, indicating whether Ponytail is operating in lite, full, or another mode. Qoder manages mode state internally through the QODER_SESSION_ID and injects context directly into the conversation stream without requiring an explicit banner field.
What happens if additionalContext is empty when emitting for Codex?
According to the implementation in hooks/ponytail-runtime.js lines 58–66, the hookSpecificOutput block is added only when additionalContext is non-empty. If the context string is empty, Codex receives only the systemMessage field containing the mode prefix, without the nested hook event object.
Why does Native Claude Code use raw text instead of JSON for most events?
Native Claude can accept raw text directly into the conversation buffer without JSON parsing overhead. However, the SubagentStart event requires the structured hookSpecificOutput wrapper to ensure the context persists across the subagent boundary without being stripped by the CLI's text processing pipeline.
How does the hook detect which environment it's running in?
The writeHookOutput function checks for the presence of PLUGIN_DATA (indicating Codex) or QODER_SESSION_ID (indicating Qoder) environment variables. If neither is present—and no Copilot variables are detected—it defaults to Native Claude Code behavior, as defined in the conditional logic spanning lines 58–89 of hooks/ponytail-runtime.js.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →