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

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 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, 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 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 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 and 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)

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)

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)

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 – Core implementation that receives and normalizes agent output before routing to the Ponytail runtime.
  • 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 – Hook definition for the Claude Codex agent, showing error handling and metadata extraction patterns.
  • tests/hooks.test.js – Unit tests that validate the payload shape for each supported agent, ensuring normalization correctness.
  • 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, allowing the core engine to process any LLM response without vendor-specific logic.
  • Hook configurations in JSON files (e.g., 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.

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) 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, 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, which describes the architectural rationale and field definitions. Concrete implementation details and validation rules are found in hooks/ponytail-subagent.js and tests/hooks.test.js respectively.

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 →