Unnamed Skill
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/GolferGeek/orchestrator-ai /tmp/orchestrator-ai && cp -r /tmp/orchestrator-ai/.claude/skills/api-agent-development-skill ~/.claude/skills/orchestrator-ai// tip: Run this command in your terminal to install the skill
name: API Agent Development description: 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. allowed-tools: Read, Write, Edit, Bash, Grep, Glob
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 |
|---|---|---|
{{userMessage}} | User's message/prompt | "Write a blog post about AI" |
{{prompt}} | Alias for userMessage | Same as above |
{{sessionId}} | Session identifier | "session-123" |
{{conversationId}} | Conversation identifier | "conv-456" |
{{taskId}} | Task identifier | "task-789" |
{{agentSlug}} | Agent slug | "marketing-swarm-n8n" |
{{organizationSlug}} | Organization slug | "demo" |
{{org}} | Alias for organizationSlug | Same as above |
{{env.API_BASE_URL}} | Environment variable | "http://localhost:7100" |
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
resultfield if path not found at root - 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
buildApiRequestBody()(applies template) - Sanitize headers (only allowlisted headers forwarded)
- Make HTTP request with retry logic
- Extract content using
extractApiResponseContent()(applies field extraction) - 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:
-
endpointURL is correct (webhook URL for n8n, API URL for others) -
methodmatches endpoint requirements (usually POST) -
request_transform.templateincludes all required parameters -
statusWebhookreads from environment (not hardcoded) -
response_transform.fieldmatches actual response structure - Field path supports dotted/bracket notation if needed
-
timeoutis appropriate (120000 for n8n workflows) - Headers include
Content-Type: application/json -
.well-known/agent.jsonendpoint is configured - 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:
@orchestrator-ai/transport-typespackage
Repository
