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 includingrequestId,durationMs, andtotalTokens. Ponytail enriches this with timing and request tracking.error(optional object) – Present only whenstatusis"error"; containscode,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 frommessage.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 thecompletionfield).toolResults: Array of tool execution results (fromtool_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 (fromcandidates[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 containingtext),finishReason, andtokenCountinto the standardoutputobject. - Anthropic (Legacy): Uses
role,content, andstopReasonfields, 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:
- Pre-LLM Hook – Registers the agent identifier and initializes telemetry tracking.
- Normalization – The
ponytail-subagent.jshook implementation extracts provider-specific fields (content,text,parts, etc.) and populates the commonoutputshape. - Post-LLM Hook – Enriches
metadatawith 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 optionalerrorfields to abstract away provider differences. - Provider-specific mappings translate OpenAI's
choices, Claude'scompletion, and Gemini'scandidatesinto common keys likecontent,text, andparts. - 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →