Claude-skill-registry API Agent Development
Create API agents that wrap external HTTP services (n8n, LangGraph, CrewAI, OpenAI endpoints). Configure request/response transforms, webhook status tracking, A2A protocol compliance. CRITICAL: Request transforms use template variables ({{userMessage}}, {{conversationId}}, etc.). Response transforms use field extraction. Status webhook URL must read from environment variables.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/api-agent-development-skill" ~/.claude/skills/majiayu000-claude-skill-registry-api-agent-development && rm -rf "$T"
skills/data/api-agent-development-skill/SKILL.md- references API keys
API Agent Development Skill
CRITICAL: API agents wrap external HTTP services. They use request/response transforms to adapt between Orchestrator AI's format and the external service's format. Status webhook URLs MUST read from environment variables.
When to Use This Skill
Use this skill when:
- Wrapping n8n workflows as API agents
- Wrapping LangGraph/CrewAI/OpenAI endpoints as API agents
- Creating agents that call external HTTP services
- Configuring request/response transformations
- Setting up webhook status tracking
- Ensuring A2A protocol compliance
API Agent Structure
API agents wrap external HTTP endpoints and transform requests/responses. They follow this structure:
Minimal API Agent Configuration
From
demo-agents/productivity/jokes_agent/agent.yaml:
api_configuration: endpoint: "http://localhost:5678/webhook/f7387dc8-c6e4-460d-9a0c-685c86d76d1f" method: "POST" timeout: 30000 headers: Content-Type: "application/json" authentication: null request_transform: format: "custom" template: '{"sessionId": "{{sessionId}}", "prompt": "{{userMessage}}"}' response_transform: format: "field_extraction" field: "output"
Full API Agent Configuration
Complete example with all options:
metadata: name: "marketing-swarm-n8n" displayName: "Marketing Swarm N8N" description: "API agent that calls n8n webhook for marketing campaign swarm processing" version: "0.1.0" type: "api" api_configuration: endpoint: "http://localhost:5678/webhook/marketing-swarm-flexible" method: "POST" timeout: 120000 headers: Content-Type: "application/json" authentication: type: "none" request_transform: format: "custom" template: | { "taskId": "{{taskId}}", "conversationId": "{{conversationId}}", "userId": "{{userId}}", "announcement": "{{userMessage}}", "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status", "provider": "{{payload.provider}}", "model": "{{payload.model}}" } response_transform: format: "field_extraction" field: "payload.content" configuration: execution_capabilities: supports_converse: false supports_plan: false supports_build: true deliverable: format: "markdown" type: "marketing-campaign"
Request Transform: Building API Requests
Template Variables Available
From
apps/api/src/agent-platform/services/agent-runtime-dispatch.service.ts:
private buildApiRequestBody( api: NonNullable<AgentRuntimeDefinition['transport']>['api'], options: AgentRuntimeDispatchOptions, ): unknown { const t = api?.requestTransform; const sessionId = options.request.sessionId ?? options.request.conversationId ?? null; const userMessage = options.prompt.userMessage ?? ''; const conversationId = options.request.conversationId ?? null; const agentSlug = options.definition.slug; const organizationSlug = options.definition.organizationSlug ?? null; if (t && t.format === 'custom' && typeof t.template === 'string') { try { const rendered = t.template.replace( /\{\{\s*(\w+)\s*\}\}/g, (_m, key) => { switch (String(key)) { case 'userMessage': case 'prompt': return userMessage; case 'sessionId': return String(sessionId ?? ''); case 'conversationId': return String(conversationId ?? ''); case 'agentSlug': return String(agentSlug ?? ''); case 'organizationSlug': case 'org': return String(organizationSlug ?? ''); default: return ''; } }, ); // If the template is JSON-like, parse it; otherwise send as string const maybeJson = rendered.trim(); if ( (maybeJson.startsWith('{') && maybeJson.endsWith('}')) || (maybeJson.startsWith('[') && maybeJson.endsWith(']')) ) { return JSON.parse(maybeJson); } return rendered; } catch { // Fall through to minimal body } } // Minimal default body expected by n8n: send only prompt return { prompt: userMessage }; }
Available Template Variables:
| Variable | Description | Example |
|---|---|---|
| User's message/prompt | |
| Alias for | Same as above |
| Session identifier | |
| Conversation identifier | |
| Task identifier | |
| Agent slug | |
| Organization slug | |
| Alias for | Same as above |
| Environment variable | |
Request Transform Examples
Example 1: Simple Prompt Forwarding
request_transform: format: "custom" template: '{"prompt": "{{userMessage}}"}'
Example 2: Full Context Forwarding (N8N Pattern)
request_transform: format: "custom" template: | { "taskId": "{{taskId}}", "conversationId": "{{conversationId}}", "userId": "{{userId}}", "announcement": "{{userMessage}}", "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status", "provider": "{{payload.provider}}", "model": "{{payload.model}}" }
Example 3: Session-Based API
request_transform: format: "custom" template: '{"sessionId": "{{sessionId}}", "prompt": "{{userMessage}}", "agent": "{{agentSlug}}"}'
Example 4: GraphQL Query
request_transform: format: "custom" template: | { "query": "query($input: String!) { search(query: $input) { results } }", "variables": { "input": "{{userMessage}}" } }
Response Transform: Extracting Content
Field Extraction Pattern
From
apps/api/src/agent-platform/services/agent-runtime-dispatch.service.ts:
private extractApiResponseContent( api: NonNullable<AgentRuntimeDefinition['transport']>['api'], data: unknown, ): string { const rt = api?.responseTransform; if ( rt && rt.format === 'field_extraction' && typeof rt.field === 'string' && rt.field.trim() ) { const fieldPath = rt.field.trim(); try { // Support dotted/bracket paths like "a.b[0].c" const tryExtract = (obj: unknown, path: string): unknown => { if (!obj || typeof obj !== 'object') return undefined; const objRecord = obj as Record<string | number, unknown>; // direct field hit if (Object.prototype.hasOwnProperty.call(objRecord, path)) { return objRecord[path]; } // dotted/bracket notation const normalized = path.replace(/\[(\d+)\]/g, '.$1'); const parts: Array<string | number> = normalized .split('.') .filter((segment) => segment.length > 0) .map((segment) => { const numeric = Number(segment); return Number.isNaN(numeric) ? segment : numeric; }); let cur: unknown = obj; for (const p of parts) { if (cur == null) return undefined; const curRecord = cur as Record<string | number, unknown>; cur = curRecord[p]; } return cur; }; const fromRoot = tryExtract(data, fieldPath); if (fromRoot !== undefined) { return typeof fromRoot === 'string' ? fromRoot : this.stringifyContent(fromRoot); } const dataRecord = data as Record<string, unknown> | undefined; if (dataRecord && typeof dataRecord === 'object' && dataRecord.result) { const fromResult = tryExtract(dataRecord.result, fieldPath); if (fromResult !== undefined) { return typeof fromResult === 'string' ? fromResult : this.stringifyContent(fromResult); } } } catch { // fallthrough to generic stringify } } return this.stringifyContent(data); }
Key Points:
- Supports dotted paths:
"data.answer.text" - Supports bracket notation:
"data.items[0].text" - Falls back to
field if path not found at rootresult - Stringifies non-string values
Response Transform Examples
Example 1: Simple Field Extraction
response_transform: format: "field_extraction" field: "output"
Example 2: Nested Field Extraction
response_transform: format: "field_extraction" field: "data.answer.text"
Example 3: Array Element Extraction
response_transform: format: "field_extraction" field: "data.items[0].text"
Example 4: Deep Nested Path
response_transform: format: "field_extraction" field: "payload.content[0].message"
Complete Example: N8N Workflow Wrapper
API Agent Configuration
From
storage/snapshots/agents/demo_marketing_swarm_n8n.json:
"yaml": "\n{\n \"metadata\": {\n \"name\": \"marketing-swarm-n8n\",\n \"displayName\": \"Marketing Swarm N8N\",\n \"description\": \"API agent that calls n8n webhook for marketing campaign swarm processing\",\n \"version\": \"0.1.0\",\n \"type\": \"api\"\n },\n \"configuration\": {\n \"api\": {\n \"endpoint\": \"http://localhost:5678/webhook/marketing-swarm-flexible\",\n \"method\": \"POST\",\n \"headers\": {\n \"Content-Type\": \"application/json\"\n },\n \"body\": {\n \"taskId\": \"{{taskId}}\",\n \"conversationId\": \"{{conversationId}}\",\n \"userId\": \"{{userId}}\",\n \"announcement\": \"{{userMessage}}\",\n \"statusWebhook\": \"http://host.docker.internal:7100/webhooks/status\",\n \"provider\": \"{{payload.provider}}\",\n \"model\": \"{{payload.model}}\"\n },\n \"authentication\": {\n \"type\": \"none\"\n },\n \"response_mapping\": {\n \"status_field\": \"status\",\n \"result_field\": \"payload\"\n },\n \"timeout\": 120000\n },\n \"deliverable\": {\n \"format\": \"markdown\",\n \"type\": \"marketing-campaign\"\n },\n \"execution_capabilities\": {\n \"supports_converse\": false,\n \"supports_plan\": false,\n \"supports_build\": true\n }\n }\n}\n",
Note: This example has hardcoded
statusWebhook. The correct format should use {{env.API_BASE_URL}}.
How the Request is Built
Step 1: User calls agent
POST /agent-to-agent/demo/marketing-swarm-n8n/tasks { "mode": "build", "conversationId": "conv-123", "userMessage": "We're launching our new AI agent platform!", "payload": { "provider": "openai", "model": "gpt-4" } }
Step 2: Request transform applies template
The
buildApiRequestBody function processes the template:
// Template variables replaced: { "taskId": "task-789", // From request.taskId "conversationId": "conv-123", // From request.conversationId "userId": "user-456", // From request.userId "announcement": "We're launching...", // From prompt.userMessage "statusWebhook": "http://localhost:7100/webhooks/status", // From env "provider": "openai", // From payload.provider "model": "gpt-4" // From payload.model }
Step 3: HTTP request sent to N8N
POST http://localhost:5678/webhook/marketing-swarm-flexible Content-Type: application/json { "taskId": "task-789", "conversationId": "conv-123", "userId": "user-456", "announcement": "We're launching our new AI agent platform!", "statusWebhook": "http://localhost:7100/webhooks/status", "provider": "openai", "model": "gpt-4" }
How the Response is Handled
Step 1: N8N returns response
{ "status": "completed", "payload": { "webPost": "Full blog post content...", "seoContent": "SEO content...", "socialMedia": "Social media posts..." } }
Step 2: Response transform extracts content
If
response_transform.field is "payload":
// extractApiResponseContent extracts: { "webPost": "Full blog post content...", "seoContent": "SEO content...", "socialMedia": "Social media posts..." }
Step 3: Content stringified and returned
{ "success": true, "mode": "build", "payload": { "content": "{\"webPost\":\"Full blog post...\",\"seoContent\":\"SEO content...\",\"socialMedia\":\"Social media posts...\"}", "metadata": { "provider": "external_api", "model": "api_endpoint", "status": "completed" } } }
Status Webhook Configuration
❌ WRONG - Hardcoded URL
request_transform: format: "custom" template: | { "statusWebhook": "http://host.docker.internal:7100/webhooks/status" }
✅ CORRECT - Environment Variable
request_transform: format: "custom" template: | { "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status" }
Fallback Pattern:
template: | { "statusWebhook": "{{env.API_BASE_URL || env.VITE_API_BASE_URL || 'http://host.docker.internal:7100'}}/webhooks/status" }
A2A Protocol Compliance
Required Endpoints
API agents must expose:
GET /agents/:orgSlug/:agentSlug/.well-known/agent.json POST /agents/:orgSlug/:agentSlug/tasks GET /agents/:orgSlug/:agentSlug/health
.well-known/agent.json Format
{ "name": "marketing-swarm-n8n", "displayName": "Marketing Swarm N8N", "description": "API agent that calls n8n webhook", "type": "api", "version": "0.1.0", "capabilities": { "modes": ["build"], "inputModes": ["application/json"], "outputModes": ["application/json"] } }
Complete API Call Flow
From Backend Runtime Dispatch
From
apps/api/src/agent-platform/services/agent-runtime-dispatch.service.ts:
private async dispatchApi( options: AgentRuntimeDispatchOptions, ): Promise<AgentRuntimeDispatchResult> { const api = options.definition.transport!.api!; const method = (api.method || 'POST').toUpperCase(); const url = api.endpoint; const payloadOptions = options.request.payload?.options as | Record<string, unknown> | undefined; const mergedHeaders: Record<string, unknown> = { 'content-type': 'application/json', ...(api.headers ?? {}), ...((payloadOptions?.headers as Record<string, unknown>) || {}), }; const headers = this.sanitizeForwardHeaders(mergedHeaders); const body: unknown = this.buildApiRequestBody(api, options); const start = Date.now(); const defaultTimeout = this.resolveDefaultTimeout('api'); let res; try { res = await this.performWithRetry(() => this.http.axiosRef.request({ url, method: method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', headers: headers as Record<string, string>, timeout: api.timeout ?? defaultTimeout, data: body, validateStatus: () => true, }), ); } catch (err: unknown) { const end = Date.now(); const errObj = err as { response?: { status?: number } }; const status = Number(errObj?.response?.status ?? -1); this.safeLog('api', url, status, end - start); this.metrics.record( 'api', options.definition.slug, false, end - start, status, ); throw err; } const end = Date.now(); // Normalize content (apply response transform if configured) const content = this.extractApiResponseContent(api, res.data); const isOk = res.status >= 200 && res.status < 300; const response = { content, metadata: { provider: 'external_api', model: 'api_endpoint', requestId: (res.headers['x-request-id'] as string | undefined) || '', timestamp: new Date(end).toISOString(), usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, timing: { startTime: start, endTime: end, duration: end - start }, tier: 'external', status: isOk ? 'completed' : 'error', providerSpecific: { status: res.status }, ...(isOk ? {} : { errorMessage: this.buildHttpErrorMessage(res.status, res.data) }), }, } as const; // Observability: log sanitized outcome this.safeLog('api', url, res.status, end - start); this.metrics.record( 'api', options.definition.slug, isOk, end - start, res.status, ); if (options.onStreamChunk) { options.onStreamChunk({ type: 'final', content: response.content, metadata: response.metadata as unknown as Record<string, unknown>, }); } return { response, config: { provider: 'external_api', model: 'api_endpoint', timeout: api.timeout ?? 30_000, baseUrl: url, }, params: { systemPrompt: options.prompt.systemPrompt, userMessage: options.prompt.userMessage, config: { provider: 'external_api', model: 'api_endpoint' }, }, routingDecision: options.routingDecision, }; }
Key Steps:
- Build request body using
(applies template)buildApiRequestBody() - Sanitize headers (only allowlisted headers forwarded)
- Make HTTP request with retry logic
- Extract content using
(applies field extraction)extractApiResponseContent() - Return normalized response
Header Sanitization
Only these headers are forwarded to external APIs:
const base = [ 'authorization', 'x-user-key', 'x-api-key', 'x-agent-api-key', 'content-type', ];
Additional headers can be added via
AGENT_EXTERNAL_HEADER_ALLOWLIST environment variable.
Common Patterns
Pattern 1: Wrapping N8N Workflow
api_configuration: endpoint: "http://localhost:5678/webhook/workflow-name" method: "POST" request_transform: format: "custom" template: | { "taskId": "{{taskId}}", "conversationId": "{{conversationId}}", "userId": "{{userId}}", "prompt": "{{userMessage}}", "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status", "provider": "{{payload.provider}}", "model": "{{payload.model}}" } response_transform: format: "field_extraction" field: "payload.content"
Pattern 2: Wrapping LangGraph/CrewAI/OpenAI Endpoint
api_configuration: endpoint: "http://localhost:8000/api/orchestrate" method: "POST" request_transform: format: "custom" template: | { "conversationId": "{{conversationId}}", "userMessage": "{{userMessage}}", "provider": "{{payload.provider}}", "model": "{{payload.model}}", "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status" } response_transform: format: "field_extraction" field: "result.content"
Pattern 3: Simple REST API
api_configuration: endpoint: "https://api.example.com/v1/generate" method: "POST" headers: Authorization: "Bearer {{env.API_KEY}}" request_transform: format: "custom" template: '{"prompt": "{{userMessage}}"}' response_transform: format: "field_extraction" field: "data.text"
Common Mistakes
❌ Mistake 1: Hardcoded Status Webhook
# ❌ WRONG "statusWebhook": "http://host.docker.internal:7100/webhooks/status"
Fix:
# ✅ CORRECT "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status"
❌ Mistake 2: Missing Required Parameters (for N8N)
# ❌ WRONG - Missing status tracking parameters template: '{"prompt": "{{userMessage}}"}'
Fix:
# ✅ CORRECT - Include all required parameters template: | { "taskId": "{{taskId}}", "conversationId": "{{conversationId}}", "userId": "{{userId}}", "prompt": "{{userMessage}}", "statusWebhook": "{{env.API_BASE_URL}}/webhooks/status" }
❌ Mistake 3: Wrong Field Path
# ❌ WRONG - Field doesn't exist response_transform: field: "response.data.text" # But actual response is {"result": {"text": "..."}}
Fix:
# ✅ CORRECT - Use correct path response_transform: field: "result.text"
❌ Mistake 4: Template Syntax Errors
# ❌ WRONG - Invalid JSON template: '{"prompt": {{userMessage}}}' # Missing quotes
Fix:
# ✅ CORRECT - Valid JSON template: '{"prompt": "{{userMessage}}"}'
Checklist for API Agents
When creating API agents:
-
URL is correct (webhook URL for n8n, API URL for others)endpoint -
matches endpoint requirements (usually POST)method -
includes all required parametersrequest_transform.template -
reads from environment (not hardcoded)statusWebhook -
matches actual response structureresponse_transform.field - Field path supports dotted/bracket notation if needed
-
is appropriate (120000 for n8n workflows)timeout - Headers include
Content-Type: application/json -
endpoint is configured.well-known/agent.json - A2A protocol compliance verified
Related Documentation
- N8N Development: See N8N Development Skill for workflow parameter requirements
- A2A Protocol: See Back-End Structure Skill for protocol details
- Transport Types:
package@orchestrator-ai/transport-types