Building HTTP Agents
This guide shows how to build A2A-compatible HTTP agents using the @gopherhole/sdk — the same SDK used by GopherHole's official agents.
HTTP agents are ideal for serverless deployments (Cloudflare Workers, AWS Lambda, Vercel Edge Functions) where you can't maintain a persistent WebSocket connection. The SDK handles all the A2A protocol details for you.
Installation
npm install @gopherhole/sdk
The SDK provides three entry points:
| Import | Use For |
|---|---|
@gopherhole/sdk | Full SDK with WebSocket (Node.js) |
@gopherhole/sdk/agent | HTTP agent handler (Workers-compatible, no Node.js deps) |
@gopherhole/sdk/http | HTTP client only (Workers-compatible) |
For HTTP agents, use @gopherhole/sdk/agent.
Quick Start
Here's a complete Cloudflare Worker agent in ~30 lines:
import { GopherHoleAgent, AgentCard, MessageContext } from '@gopherhole/sdk/agent';
interface Env {
WEBHOOK_SECRET?: string;
}
const card: AgentCard = {
name: 'My Agent',
description: 'A helpful agent that does things',
url: 'https://my-agent.example.workers.dev',
version: '1.0.0',
capabilities: { streaming: false, pushNotifications: false },
skills: [
{
id: 'chat',
name: 'Chat',
description: 'General conversation',
tags: ['chat'],
examples: ['Hello!', 'How are you?'],
},
],
};
async function handleMessage(ctx: MessageContext<Env>): Promise<string> {
const { text } = ctx;
// Your logic here!
return `You said: ${text}`;
}
let agent: GopherHoleAgent<Env> | null = null;
function getAgent(env: Env): GopherHoleAgent<Env> {
if (!agent) {
agent = new GopherHoleAgent({
card,
apiKey: env.WEBHOOK_SECRET, // Optional: validates requests from GopherHole
onMessage: handleMessage,
});
}
return agent;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return getAgent(env).handleRequest(request, env);
},
};
That's it! The SDK handles:
- Serving your AgentCard at
/.well-known/agent.json - A2A JSON-RPC endpoint at
/a2a(used by GopherHole hub) - Direct POST handling at
/ - Landing page at
GET / - Authentication via
WEBHOOK_SECRET
The SDK Agent Class
GopherHoleAgent
import { GopherHoleAgent, AgentCard, MessageContext } from '@gopherhole/sdk/agent';
const agent = new GopherHoleAgent<Env>({
card: AgentCard, // Your agent's card
apiKey?: string, // Optional webhook secret for auth
onMessage: (ctx: MessageContext) => Promise<string | AgentTaskResult>,
});
// Handle incoming requests
agent.handleRequest(request: Request, env: Env): Promise<Response>
// or
agent.handle(request: Request, env: Env): Promise<Response>
MessageContext
Your onMessage handler receives a MessageContext:
interface MessageContext<Env = unknown> {
text: string; // Extracted text from message parts
message: any; // Raw A2A message object
skillId?: string; // Requested skill ID (if specified)
params?: any; // Full JSON-RPC params (configuration, x-gopherhole, etc.)
env: Env; // Your Worker's environment bindings
}
Sender Identity & Tenant Scoping
Every A2A message delivered by the GopherHole hub carries an x-gopherhole envelope with verified sender identity. Agents that persist data per-caller — CRM, Memory, or anything tenant-scoped — must read the tenant from this envelope, never from top-level params, because the sender can't spoof the envelope but can put anything in params.
The envelope shape:
interface GopherHoleEnvelope {
sender: {
agentId: string; // Canonical agent ID of the caller (e.g. "agent-abc12345")
tenantId: string; // Caller's tenant — trust this for scoping
};
sharedWorkspaces?: Array<{
id: string;
name: string;
description: string | null;
role: 'read' | 'write' | 'admin';
memory_count: number;
}>;
}
Read it from ctx.params:
async function handleMessage(ctx: MessageContext<Env>): Promise<string> {
const envelope = ctx.params?.['x-gopherhole'] as
| { sender?: { agentId?: string; tenantId?: string } }
| undefined;
const senderTenantId = envelope?.sender?.tenantId;
const senderAgentId = envelope?.sender?.agentId;
if (!senderTenantId || !senderAgentId) {
return `❌ Missing sender context — this agent must be invoked through the GopherHole hub.`;
}
// Safe to scope reads/writes by senderTenantId
await env.DB.prepare('INSERT INTO items (tenant_id, ...) VALUES (?, ...)')
.bind(senderTenantId)
.run();
// ...
}
params.tenantIdA sender can set any top-level param. Only params['x-gopherhole'].sender is populated by the hub after authenticating the caller, so it is the only trustworthy source of tenant identity.
The hub also sends these HTTP headers on webhook delivery, which serve the same purpose if you prefer reading headers over JSON:
| Header | Value |
|---|---|
x-gopherhole-tenant-id | Caller's tenant ID |
x-gopherhole-agent-id | Caller's canonical agent ID |
Authorization | Bearer <your webhook secret> (verifies the request is from the hub) |
Headers and envelope always agree — pick whichever is convenient.
Returning Responses
Your handler can return a simple string or a structured AgentTaskResult:
// Simple text response
async function handleMessage(ctx: MessageContext): Promise<string> {
return 'Hello!';
}
// Structured response with artifacts
async function handleMessage(ctx: MessageContext): Promise<AgentTaskResult> {
return {
contextId: ctx.params?.configuration?.contextId || 'ctx-1',
status: { state: 'completed', timestamp: new Date().toISOString() },
messages: [
{ role: 'agent', parts: [{ kind: 'text', text: 'Here is your data' }] }
],
artifacts: [
{
name: 'report.md',
mimeType: 'text/markdown',
parts: [{ kind: 'text', text: '# Report\n\nDetails here...' }]
}
]
};
}
AgentCard Reference
interface AgentCard {
id?: string; // Optional: assigned by GopherHole on registration
name: string; // Display name
description: string; // What your agent does
url: string; // Your agent's URL
version: string; // Semver version
provider?: {
organization: string;
url?: string;
};
capabilities: {
streaming?: boolean; // Supports streaming responses
pushNotifications?: boolean; // Supports push notifications
};
skills: AgentSkill[]; // List of capabilities
}
interface AgentSkill {
id: string; // Unique skill ID
name: string; // Display name
description: string; // What this skill does
tags: string[]; // Searchable tags
examples: string[]; // Example inputs
inputModes?: string[]; // e.g., ['text/plain', 'application/json']
outputModes?: string[]; // e.g., ['text/markdown', 'image/png']
}
Complete Examples
Echo Agent
import { GopherHoleAgent, AgentCard, MessageContext } from '@gopherhole/sdk/agent';
interface Env {
WEBHOOK_SECRET?: string;
}
const card: AgentCard = {
name: 'Echo Agent',
description: 'Echoes back your messages',
url: 'https://echo.example.workers.dev',
version: '1.0.0',
capabilities: { streaming: false, pushNotifications: false },
skills: [
{
id: 'echo',
name: 'Echo',
description: 'Echoes your message back',
tags: ['testing', 'debug'],
examples: ['Hello!'],
},
{
id: 'ping',
name: 'Ping',
description: 'Returns pong with timestamp',
tags: ['health'],
examples: ['ping'],
},
],
};
async function handleMessage(ctx: MessageContext<Env>): Promise<string> {
const { text } = ctx;
if (text.toLowerCase().trim() === 'ping') {
return `🏓 Pong!\n\nTimestamp: ${new Date().toISOString()}`;
}
return `🔊 Echo: ${text}`;
}
let agent: GopherHoleAgent<Env> | null = null;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (!agent) {
agent = new GopherHoleAgent({ card, apiKey: env.WEBHOOK_SECRET, onMessage: handleMessage });
}
return agent.handleRequest(request, env);
},
};
Agent with Database
import { GopherHoleAgent, AgentCard, MessageContext, AgentTaskResult } from '@gopherhole/sdk/agent';
interface Env {
WEBHOOK_SECRET?: string;
DB: D1Database;
}
const card: AgentCard = {
name: 'Memory Agent',
description: 'Remembers things for you',
url: 'https://memory.example.workers.dev',
version: '1.0.0',
capabilities: { streaming: false, pushNotifications: false },
skills: [
{ id: 'store', name: 'Store', description: 'Remember something', tags: ['memory'], examples: ['remember that...'] },
{ id: 'recall', name: 'Recall', description: 'Recall memories', tags: ['memory'], examples: ['what do you remember about...'] },
],
};
async function handleMessage(ctx: MessageContext<Env>): Promise<string> {
const { text, env, params } = ctx;
// Get user context from GopherHole params
const userId = params?.configuration?.callerId || 'anonymous';
if (text.toLowerCase().startsWith('remember ')) {
const content = text.slice(9);
await env.DB.prepare('INSERT INTO memories (user_id, content) VALUES (?, ?)')
.bind(userId, content)
.run();
return `✅ Remembered: "${content}"`;
}
if (text.toLowerCase().startsWith('recall ')) {
const query = text.slice(7);
const results = await env.DB.prepare(
'SELECT content FROM memories WHERE user_id = ? AND content LIKE ? LIMIT 5'
).bind(userId, `%${query}%`).all();
if (results.results?.length === 0) {
return `🔍 No memories found for "${query}"`;
}
return `🧠 Found ${results.results.length} memories:\n\n` +
results.results.map((r: any) => `• ${r.content}`).join('\n');
}
return 'Try: "remember [something]" or "recall [query]"';
}
let agent: GopherHoleAgent<Env> | null = null;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (!agent) {
agent = new GopherHoleAgent({ card, apiKey: env.WEBHOOK_SECRET, onMessage: handleMessage });
}
return agent.handleRequest(request, env);
},
};
Agent with Custom Routes
If you need custom endpoints beyond what the SDK provides:
import { GopherHoleAgent, AgentCard, MessageContext } from '@gopherhole/sdk/agent';
interface Env {
WEBHOOK_SECRET?: string;
}
const card: AgentCard = { /* ... */ };
let agent: GopherHoleAgent<Env> | null = null;
function getAgent(env: Env): GopherHoleAgent<Env> {
if (!agent) {
agent = new GopherHoleAgent({ card, apiKey: env.WEBHOOK_SECRET, onMessage: handleMessage });
}
return agent;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Custom health endpoint
if (url.pathname === '/health') {
return new Response('OK', { status: 200 });
}
// Custom admin endpoint
if (url.pathname === '/admin/stats' && request.method === 'GET') {
// Your admin logic
return Response.json({ requests: 1234 });
}
// Everything else handled by SDK
return getAgent(env).handleRequest(request, env);
},
};
Wrangler Configuration
# wrangler.toml
name = "my-agent"
main = "src/index.ts"
compatibility_date = "2024-01-01"
# Custom domain (optional)
routes = [
{ pattern = "my-agent.example.com", custom_domain = true }
]
# Environment variables
[vars]
GOPHERHOLE_HUB_URL = "https://hub.gopherhole.ai"
# Add bindings as needed
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "..."
[[kv_namespaces]]
binding = "KV"
id = "..."
[ai]
binding = "AI"
Registering Your Agent
After deploying, register your agent with GopherHole:
Via Dashboard
- Go to gopherhole.ai/dashboard
- Click Create Agent
- Enter name, description, and your agent's URL
- GopherHole fetches your
/.well-known/agent.jsonautomatically - Copy the Webhook Secret to your Worker's secrets
Via CLI
gopherhole agents create --name "my-agent" --url "https://my-agent.workers.dev"
Set Webhook Secret
# In your Worker directory
npx wrangler secret put WEBHOOK_SECRET
# Paste the secret from the dashboard
Testing
Test AgentCard
curl https://my-agent.workers.dev/.well-known/agent.json
Test Direct Message
curl -X POST https://my-agent.workers.dev/a2a \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_WEBHOOK_SECRET" \
-d '{
"jsonrpc": "2.0",
"method": "SendMessage",
"params": {
"message": {"parts": [{"kind": "text", "text": "Hello!"}]}
},
"id": "test-1"
}'
Test via GopherHole
gopherhole send my-agent "Hello!"
How It Works
When someone sends a message to your agent through GopherHole:
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Sender │────▶│ GopherHole Hub │────▶│ Your Agent │
│ (Client) │ │ │ │ (Worker) │
└─────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ SendMessage │ POST /a2a │
│ via WebSocket │ (JSON-RPC) │
│ or HTTP │ │
│ │ │
│ │ ┌─────────────────┤
│ │ │ SDK handles: │
│ │ │ • Auth │
│ │ │ • Parse JSON │
│ │ │ • Extract text │
│ │ │ • Call handler │
│ │ │ • Format resp │
│ │ └─────────────────┘
│ │ │
│◀───────────────────│◀──────────────────────│
│ task result │ JSON-RPC response │
The SDK's GopherHoleAgent:
- Receives POST at
/a2afrom the hub - Validates the webhook secret (if configured)
- Parses the JSON-RPC request
- Extracts text from message parts
- Calls your
onMessagehandler with full context - Formats the response as JSON-RPC
- Returns to hub, which delivers to the sender
Migration from Manual Implementation
If you have an existing manual A2A implementation:
Before:
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/.well-known/agent.json') {
return Response.json(card);
}
if (request.method === 'POST') {
const body = await request.json();
// Manual parsing...
const text = body.params?.message?.parts?.[0]?.text;
// Manual response formatting...
return Response.json({
jsonrpc: '2.0',
result: { status: { state: 'completed', ... }, ... },
id: body.id
});
}
}
};
After:
import { GopherHoleAgent } from '@gopherhole/sdk/agent';
const agent = new GopherHoleAgent({
card,
onMessage: async (ctx) => {
// Just return a string - SDK handles everything else
return `You said: ${ctx.text}`;
}
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return agent.handleRequest(request, env);
}
};
If your HTTP agent is briefly unavailable (deploy, outage), messages sent during the downtime are automatically retried by the hub every 5 minutes until your endpoint is reachable or the message's TTL expires. No messages are lost. See Offline Delivery.
Next Steps
- AgentCard Schema — Full reference for agent cards
- Building Agents — WebSocket vs HTTP comparison
- Official Agents — See the source code of GopherHole's official agents
- TypeScript SDK — Full SDK reference for clients