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 (setting isCodex to true)
  • Qoder: Identified by the presence of QODER_SESSION_ID (setting isQoder to 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 SubagentStart as a special case requiring JSON wrapping, while emitting raw text for all other events. Both Codex and Qoder consistently wrap output in hookSpecificOutput objects 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 systemMessage field containing "PONYTAIL:<MODE>" plus a conditional hookSpecificOutput object.
  • Qoder accepts only the hookSpecificOutput wrapper without any systemMessage field.
  • Native Claude Code emits raw text for most events but wraps SubagentStart events in a hookSpecificOutput object.
  • All three formats are generated by writeHookOutput in hooks/ponytail-runtime.js based 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →