# Cross-Host Hook Output Structure for Different Agents in Ponytail: A Complete Guide

> Understand the cross-host hook output structure for various agents in Ponytail. This guide explains the unified JSON payload for OpenAI, Claude, Gemini, and more, ensuring seamless interoperability.

- Repository: [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail)
- Tags: how-to-guide
- Published: 2026-09-11

---

**Ponytail normalizes every LLM provider's response into a unified JSON payload containing `agent`, `status`, `output`, `metadata`, and optional `error` fields, enabling seamless interoperability between OpenAI, Claude, Gemini, and other agents.**

Ponytail treats every large language model (LLM) provider as a *cross-host* plugin that communicates through a standardized hook interface. When an agent finishes processing a request, it emits a hook payload that the Ponytail runtime consumes—regardless of whether the underlying provider is OpenAI, Anthropic, Google, or a local model. This article breaks down the exact output structure defined in the [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail) repository, mapping how each vendor's native response transforms into the canonical cross-host format.

## Canonical Hook Payload Structure

According to the implementation in [`hooks/ponytail-subagent.js`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-subagent.js), every agent must return a JSON object with five top-level keys. This contract allows downstream components—such as the skill dispatcher and command executor—to remain agnostic to the specific LLM vendor.

The **cross-host hook payload** conforms to the following schema:

- **`agent`** (string) – Identifier of the source provider (`"openai"`, `"claude"`, `"gemini"`, `"cohere"`, etc.).
- **`status`** (string) – Execution state: `"success"` for completed requests, `"error"` for failures, or `"partial"` for streaming-only results.
- **`output`** (object) – Normalized response content containing provider-agnostic fields (see Provider-Specific Mappings below).
- **`metadata`** (object) – Telemetry data including `requestId`, `durationMs`, and `totalTokens`. Ponytail enriches this with timing and request tracking.
- **`error`** (optional object) – Present only when `status` is `"error"`; contains `code`, `message`, and provider-specific error details.

## Provider-Specific Output Mappings

While the top-level envelope remains constant, the **`output`** object varies by provider to accommodate each API's unique response shape. The normalization layer—implemented in the hook definitions—maps native fields to a common vocabulary.

### OpenAI

OpenAI's Chat Completions API returns choices with message objects. The hook extracts and renames these fields:

- **`role`**: Always `"assistant"` for generated responses.
- **`content`**: The generated text string.
- **`toolCalls`**: Array of tool invocation objects (mapped from `message.tool_calls`).

### Claude

Anthropic's Claude API uses a completion-style response. The hook transforms this into:

- **`type`**: Set to `"assistant"` for consistency.
- **`text`**: The completion string (from the `completion` field).
- **`toolResults`**: Array of tool execution results (from `tool_results`).

### Gemini

Google's Gemini API returns candidates with content parts. The hook normalizes these as:

- **`role`**: Set to `"model"` to indicate the assistant role.
- **`parts`**: Array of text or function-call parts (from `candidates[0].content.parts`).
- **`functionCalls`**: Extracted function-call objects for tool-using workflows.

### Cohere and Other Providers

Additional providers follow the same pattern:

- **Cohere**: Maps `generations` (array containing `text`), `finishReason`, and `tokenCount` into the standard `output` object.
- **Anthropic (Legacy)**: Uses `role`, `content`, and `stopReason` fields, aligning with the newer Claude API structure where applicable.

## How Ponytail Processes Cross-Host Hooks

The hook lifecycle defined in [`docs/agent-portability.md`](https://github.com/DietrichGebert/ponytail/blob/main/docs/agent-portability.md) consists of three stages that ensure consistent handling across hosts:

1. **Pre-LLM Hook** – Registers the agent identifier and initializes telemetry tracking.
2. **Normalization** – The [`ponytail-subagent.js`](https://github.com/DietrichGebert/ponytail/blob/main/ponytail-subagent.js) hook implementation extracts provider-specific fields (`content`, `text`, `parts`, etc.) and populates the common `output` shape.
3. **Post-LLM Hook** – Enriches `metadata` with token counts, latency metrics, and request IDs, then routes the payload to the status line, skill dispatcher, or command executor.

Because the core engine reads only the normalized fields, adding a new provider requires only a small mapping layer in the hook configuration (see [`hooks/qoder-hooks.json`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/qoder-hooks.json) and [`hooks/claude-codex-hooks.json`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/claude-codex-hooks.json) for examples).

## Implementation Examples by Agent

Below are concrete implementations showing how each provider's raw API response transforms into the Ponytail hook payload.

### OpenAI Agent (Node.js/Axios)

```javascript
import axios from 'axios';
import { registerHook } from 'ponytail';

async function callOpenAI(prompt) {
  const resp = await axios.post(
    'https://api.openai.com/v1/chat/completions',
    { model: 'gpt-4', messages: [{ role: 'user', content: prompt }] },
    { headers: { Authorization: `Bearer ${process.env.OPENAI_KEY}` } }
  );

  const payload = {
    agent: 'openai',
    status: 'success',
    output: {
      role: resp.data.choices[0].message.role,
      content: resp.data.choices[0].message.content,
      toolCalls: resp.data.choices[0].message.tool_calls || []
    },
    metadata: {
      requestId: resp.headers['x-request-id'],
      durationMs: resp.headers['x-response-time'] ?? null,
      totalTokens: resp.data.usage?.total_tokens
    }
  };

  registerHook('post_llm_call', payload);
}

```

### Claude Agent (Fetch API)

```javascript
async function callClaude(prompt) {
  const resp = await fetch('https://api.anthropic.com/v1/complete', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.CLAUDE_KEY,
      'content-type': 'application/json'
    },
    body: JSON.stringify({ prompt, model: 'claude-2.1' })
  });
  const data = await resp.json();

  const payload = {
    agent: 'claude',
    status: resp.ok ? 'success' : 'error',
    output: {
      type: 'assistant',
      text: data.completion,
      toolResults: data.tool_results ?? []
    },
    metadata: {
      requestId: resp.headers.get('x-request-id'),
      durationMs: parseInt(resp.headers.get('x-response-time'), 10),
      totalTokens: data.usage?.total_tokens
    },
    ...(resp.ok ? {} : { error: { code: data.error?.type, message: data.error?.message } })
  };

  registerHook('post_llm_call', payload);
}

```

### Gemini Agent (Node-Fetch)

```javascript
async function callGemini(prompt) {
  const resp = await fetch('https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent', {
    method: 'POST',
    headers: { 
      'content-type': 'application/json', 
      'x-goog-api-key': process.env.GEMINI_KEY 
    },
    body: JSON.stringify({ 
      contents: [{ role: 'user', parts: [{ text: prompt }] }] 
    })
  });
  const data = await resp.json();

  const payload = {
    agent: 'gemini',
    status: resp.ok ? 'success' : 'error',
    output: {
      role: 'model',
      parts: data.candidates?.[0]?.content?.parts ?? [],
      functionCalls: data.candidates?.[0]?.content?.functionCalls ?? []
    },
    metadata: {
      requestId: resp.headers.get('x-request-id'),
      durationMs: parseInt(resp.headers.get('x-response-time'), 10),
      totalTokens: data.usageMetadata?.totalTokenCount
    },
    ...(resp.ok ? {} : { error: { code: data.error?.code, message: data.error?.message } })
  };

  registerHook('post_llm_call', payload);
}

```

## Key Source Files

The cross-host hook output structure is defined, enforced, and documented in the following files within the DietrichGebert/ponytail repository:

- **[`hooks/ponytail-subagent.js`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-subagent.js)** – Core implementation that receives and normalizes agent output before routing to the Ponytail runtime.
- **[`hooks/qoder-hooks.json`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/qoder-hooks.json)** – Configuration specification for the Qoder agent, demonstrating how provider-specific fields map to the canonical structure.
- **[`hooks/claude-codex-hooks.json`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/claude-codex-hooks.json)** – Hook definition for the Claude Codex agent, showing error handling and metadata extraction patterns.
- **[`tests/hooks.test.js`](https://github.com/DietrichGebert/ponytail/blob/main/tests/hooks.test.js)** – Unit tests that validate the payload shape for each supported agent, ensuring normalization correctness.
- **[`docs/agent-portability.md`](https://github.com/DietrichGebert/ponytail/blob/main/docs/agent-portability.md)** – Architectural documentation explaining how Ponytail abstracts different LLM providers using the cross-host hook format.

## Summary

- **Ponytail uses a unified JSON envelope** with `agent`, `status`, `output`, `metadata`, and optional `error` fields to abstract away provider differences.
- **Provider-specific mappings** translate OpenAI's `choices`, Claude's `completion`, and Gemini's `candidates` into common keys like `content`, `text`, and `parts`.
- **The normalization layer** lives in [`hooks/ponytail-subagent.js`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-subagent.js), allowing the core engine to process any LLM response without vendor-specific logic.
- **Hook configurations** in JSON files (e.g., [`qoder-hooks.json`](https://github.com/DietrichGebert/ponytail/blob/main/qoder-hooks.json)) define how each agent's raw output transforms into the canonical structure.

## Frequently Asked Questions

### What fields are required in every cross-host hook payload?

Every payload must include `agent` (string identifier), `status` (success/error indicator), `output` (normalized response object), and `metadata` (telemetry). The `error` object is required only when `status` is `"error"`, as enforced by the validation logic in [`tests/hooks.test.js`](https://github.com/DietrichGebert/ponytail/blob/main/tests/hooks.test.js).

### How does Ponytail handle tool calls from different providers?

Tool calls are normalized into arrays regardless of source. OpenAI's `tool_calls` become `output.toolCalls`, Claude's `tool_results` map to `output.toolResults`, and Gemini's function calls populate `output.functionCalls`. Downstream components consume these arrays uniformly without checking the original provider.

### Can I add a custom LLM provider to Ponytail?

Yes. You need to create a hook configuration file (similar to [`claude-codex-hooks.json`](https://github.com/DietrichGebert/ponytail/blob/main/claude-codex-hooks.json)) that defines how your provider's native response maps to the canonical `output` fields. Implement the normalization logic in a subagent hook following the pattern in [`ponytail-subagent.js`](https://github.com/DietrichGebert/ponytail/blob/main/ponytail-subagent.js), then register it using `registerHook('post_llm_call', payload)`.

### Where is the cross-host hook output structure documented?

The authoritative documentation lives in [`docs/agent-portability.md`](https://github.com/DietrichGebert/ponytail/blob/main/docs/agent-portability.md), which describes the architectural rationale and field definitions. Concrete implementation details and validation rules are found in [`hooks/ponytail-subagent.js`](https://github.com/DietrichGebert/ponytail/blob/main/hooks/ponytail-subagent.js) and [`tests/hooks.test.js`](https://github.com/DietrichGebert/ponytail/blob/main/tests/hooks.test.js) respectively.