packages/docs/plugins/webhooks-and-routes.mdx
Plugins in elizaOS can expose HTTP routes that act as webhooks, allowing external services to trigger agent actions and send messages on behalf of agents. This enables powerful integrations with third-party services, automated workflows, and custom APIs.
Routes are defined in your plugin's main export using the routes property. Each route specifies an HTTP method, path, and handler function.
type Route = {
type: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "STATIC";
path: string;
handler?: (
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
) => Promise<void>;
filePath?: string; // For static file serving
public?: boolean; // Whether route is publicly accessible
name?: string; // Optional route name
isMultipart?: boolean; // For file upload endpoints
};
import type { Plugin } from "@elizaos/core";
export const myPlugin: Plugin = {
name: "webhook-plugin",
description: "Plugin with webhook endpoints",
routes: [
{
name: "webhook-endpoint",
path: "/webhook",
type: "POST",
handler: async (req, res, runtime) => {
// Access request data
const { event, data } = req.body;
// Process webhook
console.log(`Received webhook: ${event}`);
// Send response
res.json({
success: true,
message: "Webhook processed",
});
},
},
],
};
The most powerful use case for plugin routes is sending messages on behalf of the agent. This allows external services to make the agent speak in any conversation.
To send a message as an agent, your route handler needs to make a POST request to the messaging submit endpoint:
{
name: 'send-message-webhook',
path: '/send-agent-message',
type: 'POST',
handler: async (req, res, runtime) => {
try {
const { channelId, message, metadata } = req.body;
// Validate input
if (!channelId || !message) {
return res.status(400).json({
success: false,
error: 'channelId and message are required'
});
}
// Prepare message payload
const messagePayload = {
channel_id: channelId,
server_id: '00000000-0000-0000-0000-000000000000', // Default server
author_id: runtime.agentId,
content: message,
source_type: 'agent_response',
raw_message: {
text: message,
thought: metadata?.thought,
actions: metadata?.actions || []
},
metadata: {
agent_id: runtime.agentId,
agentName: runtime.character.name,
...metadata
}
};
// Send message via messaging API
const baseUrl = runtime.getSetting('SERVER_URL') || 'http://localhost:3000';
const response = await fetch(`${baseUrl}/api/messaging/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Add any required auth headers
},
body: JSON.stringify(messagePayload)
});
if (!response.ok) {
throw new Error(`Failed to send message: ${response.statusText}`);
}
const result = await response.json();
res.json({
success: true,
messageId: result.data.id,
message: 'Message sent successfully'
});
} catch (error) {
console.error('Error sending agent message:', error);
res.status(500).json({
success: false,
error: 'Failed to send message'
});
}
}
}
Here's a complete example of a plugin that receives GitHub webhooks and makes the agent comment on events:
import type { Plugin, Route } from "@elizaos/core";
import { validateUuid } from "@elizaos/core";
const githubWebhookRoute: Route = {
name: "github-webhook",
path: "/github/webhook",
type: "POST",
handler: async (req, res, runtime) => {
try {
// Verify GitHub signature (optional but recommended)
const signature = req.headers["x-hub-signature-256"];
// ... signature verification logic ...
// Parse GitHub event
const event = req.headers["x-github-event"];
const payload = req.body;
// Determine channel to send message to
const channelId = runtime.getSetting("GITHUB_NOTIFICATION_CHANNEL");
if (!channelId || !validateUuid(channelId)) {
console.error("No valid channel configured for GitHub notifications");
return res.status(200).json({ ok: true }); // Return 200 to prevent GitHub retries
}
// Format message based on event type
let message = "";
switch (event) {
case "push":
message =
`🔄 New push to ${payload.repository.full_name} by ${payload.pusher.name}:\n` +
`Branch: ${payload.ref.replace("refs/heads/", "")}\n` +
`Commits: ${payload.commits.length}\n` +
`Message: "${payload.head_commit.message}"`;
break;
case "pull_request":
const pr = payload.pull_request;
message =
`🔀 Pull Request ${payload.action} in ${payload.repository.full_name}:\n` +
`#${pr.number}: ${pr.title}\n` +
`Author: ${pr.user.login}\n` +
`${pr.html_url}`;
break;
case "issues":
const issue = payload.issue;
message =
`📝 Issue ${payload.action} in ${payload.repository.full_name}:\n` +
`#${issue.number}: ${issue.title}\n` +
`Author: ${issue.user.login}\n` +
`${issue.html_url}`;
break;
default:
message = `GitHub event: ${event} on ${payload.repository?.full_name || "unknown repo"}`;
}
// Send message as agent
const messagePayload = {
channel_id: channelId,
server_id: "00000000-0000-0000-0000-000000000000",
author_id: runtime.agentId,
content: message,
source_type: "agent_response",
raw_message: {
text: message,
actions: ["GITHUB_NOTIFICATION"],
},
metadata: {
agent_id: runtime.agentId,
agentName: runtime.character.name,
githubEvent: event,
repository: payload.repository?.full_name,
},
};
// Submit message
const baseUrl =
runtime.getSetting("SERVER_URL") || "http://localhost:3000";
const submitResponse = await fetch(`${baseUrl}/api/messaging/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(messagePayload),
});
if (!submitResponse.ok) {
throw new Error(
`Failed to submit message: ${submitResponse.statusText}`,
);
}
res.json({
success: true,
message: "GitHub webhook processed",
});
} catch (error) {
console.error("GitHub webhook error:", error);
res.status(500).json({
success: false,
error: "Failed to process webhook",
});
}
},
};
export const githubPlugin: Plugin = {
name: "github-integration",
description: "GitHub webhook integration for agent notifications",
routes: [githubWebhookRoute],
init: async (config, runtime) => {
const channelId = runtime.getSetting("GITHUB_NOTIFICATION_CHANNEL");
if (!channelId) {
console.warn("GITHUB_NOTIFICATION_CHANNEL not configured");
}
console.log("GitHub integration initialized");
},
};
For secure webhook endpoints, implement authentication:
{
name: 'secure-webhook',
path: '/secure/webhook',
type: 'POST',
handler: async (req, res, runtime) => {
// Check for API key
const apiKey = req.headers['x-api-key'];
const expectedKey = runtime.getSetting('WEBHOOK_API_KEY');
if (!apiKey || apiKey !== expectedKey) {
return res.status(401).json({
success: false,
error: 'Unauthorized'
});
}
// Process authenticated request
// ...
}
}
For endpoints that accept file uploads:
{
name: 'upload-webhook',
path: '/upload',
type: 'POST',
isMultipart: true,
handler: async (req, res, runtime) => {
const file = req.file; // Access uploaded file
const { description } = req.body;
// Process file and send agent message
const message = `📎 New file uploaded: ${file.originalname}\n${description}`;
// Send message with file attachment
const messagePayload = {
channel_id: channelId,
author_id: runtime.agentId,
content: message,
metadata: {
attachments: [{
filename: file.originalname,
size: file.size,
mimeType: file.mimetype
}]
}
};
// Submit message...
}
}
Create endpoints that schedule future agent messages:
{
name: 'schedule-message',
path: '/schedule',
type: 'POST',
handler: async (req, res, runtime) => {
const { channelId, message, sendAt } = req.body;
// Calculate delay
const delay = new Date(sendAt).getTime() - Date.now();
if (delay <= 0) {
return res.status(400).json({
error: 'sendAt must be in the future'
});
}
// Schedule message
setTimeout(async () => {
// Send message as agent
await sendAgentMessage(runtime, channelId, message);
}, delay);
res.json({
success: true,
message: `Message scheduled for ${sendAt}`
});
}
}
When your plugin is loaded:
routes arrayRoutes are available at:
http://localhost:3000{path}https://your-domain.com{path}With query parameter for agent selection:
http://localhost:3000/webhook?agentId=YOUR_AGENT_IDUse ngrok or similar tools to expose your local server:
# Install ngrok
npm install -g ngrok
# Start your agent
elizaos start
# In another terminal, expose port 3000
ngrok http 3000
# Use the ngrok URL for webhook configuration
# https://abc123.ngrok.io/webhook
# Test your webhook endpoint
curl -X POST http://localhost:3000/webhook?agentId=YOUR_AGENT_ID \
-H "Content-Type: application/json" \
-d '{
"channelId": "test-channel-id",
"message": "Hello from webhook!",
"metadata": {
"source": "curl-test"
}
}'
ElizaOS provides two types of tests for validating webhook functionality: component tests and e2e tests.
Component tests verify route handler logic using a real runtime. All tests use PGLite for in-memory database operations:
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createTestRuntimeWithCleanup } from "./test-utils";
import { webhookPlugin } from "../index";
import type { IAgentRuntime } from "@elizaos/core";
describe("Webhook Plugin Routes", () => {
let runtime: IAgentRuntime;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const result = await createTestRuntimeWithCleanup();
runtime = result.runtime;
cleanup = result.cleanup;
// Spy on getSetting if you need specific values
vi.spyOn(runtime, "getSetting").mockImplementation((key: string) => {
const settings: Record<string, string> = {
GITHUB_NOTIFICATION_CHANNEL: "test-channel-123",
SERVER_URL: "http://localhost:3000",
};
return settings[key];
});
});
afterEach(async () => {
await cleanup();
});
it("should handle GitHub webhook and return success", async () => {
const githubRoute = webhookPlugin.routes?.find(
(r) => r.name === "github-webhook",
);
expect(githubRoute).toBeDefined();
const mockReq = {
headers: {
"x-github-event": "push",
"x-hub-signature-256": "sha256=test",
},
body: {
repository: { full_name: "test/repo" },
pusher: { name: "testuser" },
ref: "refs/heads/main",
commits: [{ message: "Test commit" }],
head_commit: { message: "Test commit" },
},
};
let responseData: Record<string, unknown> | null = null;
const mockRes = {
json: (data: Record<string, unknown>) => {
responseData = data;
},
status: (code: number) => mockRes,
};
// Mock fetch for the messaging API call
const originalFetch = global.fetch;
global.fetch = async () =>
({
ok: true,
json: async () => ({ success: true, data: { id: "msg-123" } }),
}) as Response;
await githubRoute!.handler!(mockReq, mockRes, runtime);
expect(responseData).toEqual({
success: true,
message: "GitHub webhook processed",
});
// Restore fetch
global.fetch = originalFetch;
});
it("should validate required fields in send-message webhook", async () => {
const sendMessageRoute = webhookPlugin.routes?.find(
(r) => r.name === "send-message-webhook",
);
expect(sendMessageRoute).toBeDefined();
const mockReq = {
body: {}, // Missing required fields
};
let responseData: Record<string, unknown> | null = null;
let statusCode: number = 200;
const mockRes = {
json: (data: Record<string, unknown>) => {
responseData = data;
},
status: (code: number) => {
statusCode = code;
return mockRes;
},
};
await sendMessageRoute!.handler!(mockReq, mockRes, runtime);
expect(statusCode).toBe(400);
expect(responseData).toEqual({
success: false,
error: "channelId and message are required",
});
});
});
E2E tests validate the complete webhook flow with a live agent runtime:
import { TestSuite } from "@elizaos/core";
export const webhookE2ETests: TestSuite = {
name: "Webhook Integration E2E Tests",
tests: [
{
name: "should process webhook and send agent message",
fn: async (runtime) => {
// Create a test channel
const testChannel = await runtime.createMemory(
{
id: "test-channel-webhook",
roomId: "test-room-webhook",
entityId: "test-entity",
content: {
text: "Test channel for webhook testing",
source: "test",
},
},
"channels",
);
// Test the webhook endpoint via HTTP request
const webhookPayload = {
channelId: testChannel.roomId,
message: "Hello from webhook integration test!",
metadata: {
source: "e2e-test",
testRun: true,
},
};
// Make HTTP request to webhook endpoint
const response = await fetch(
`http://localhost:3000/send-agent-message?agentId=${runtime.agentId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(webhookPayload),
},
);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(result.messageId).toBeDefined();
// Verify the message was actually sent
const messages = await runtime.getMemories({
roomId: testChannel.roomId,
tableName: "messages",
count: 10,
});
const webhookMessage = messages.find(
(m) =>
m.content.text === "Hello from webhook integration test!" &&
m.metadata?.source === "e2e-test",
);
expect(webhookMessage).toBeDefined();
expect(webhookMessage.entityId).toBe(runtime.agentId);
},
},
{
name: "should handle GitHub webhook events",
fn: async (runtime) => {
// Set required environment variable
const channelId = "github-test-channel";
const githubPayload = {
repository: { full_name: "elizaos/test-repo" },
pusher: { name: "testdev" },
ref: "refs/heads/main",
commits: [{ message: "Add new feature" }],
head_commit: { message: "Add new feature" },
};
const response = await fetch(
`http://localhost:3000/github/webhook?agentId=${runtime.agentId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-github-event": "push",
},
body: JSON.stringify(githubPayload),
},
);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(result.message).toBe("GitHub webhook processed");
},
},
],
};
// Export for plugin registration
export default webhookE2ETests;
Include tests in your plugin definition:
import { webhookE2ETests } from "./__tests__/webhook-e2e.test";
export const webhookPlugin: Plugin = {
name: "webhook-integration",
description: "Plugin with webhook endpoints",
routes: [
/* your routes */
],
tests: [webhookE2ETests], // Add your test suites
};
Use the ElizaOS test command to run your webhook tests:
# Run component tests only
elizaos test --type component
# Run e2e tests only
elizaos test --type e2e
# Run all tests
elizaos test
# Run tests for specific plugin
elizaos test --name "Webhook Integration"