Skip to main content
API

Custom REST Endpoints

VoltAgent Server allows you to add custom REST endpoints alongside the built-in agent and workflow endpoints. This enables extending your API with business logic, integrations, and custom functionality.

Overview

Both @voltagent/server-hono and @voltagent/server-elysia allow you to add custom routes using the configureApp callback, which gives you direct access to the underlying app instance.

Using Hono

Add custom endpoints through the Hono server configuration:

import { VoltAgent } from "@voltagent/core";
import { honoServer } from "@voltagent/server-hono";

new VoltAgent({
agents: { myAgent },
server: honoServer({
configureApp: (app) => {
// Add custom routes here
app.get("/api/health", (c) => c.json({ status: "healthy" }));

app.post("/api/data", async (c) => {
const body = await c.req.json();
// Process data
return c.json({ success: true, data: body });
});
},
}),
});

Using Elysia

Add custom endpoints through the Elysia server configuration:

import { VoltAgent } from "@voltagent/core";
import { elysiaServer } from "@voltagent/server-elysia";

new VoltAgent({
agents: { myAgent },
server: elysiaServer({
configureApp: (app) => {
// Add custom routes here
app.get("/api/health", () => ({ status: "healthy" }));

app.post("/api/data", ({ body }) => {
// Process data
return { success: true, data: body };
});
},
}),
});

CORS Configuration

Configure Cross-Origin Resource Sharing (CORS) for your API using the cors field in server configuration. By default, VoltAgent allows all origins (*).

Default CORS (Permissive)

new VoltAgent({
agents: { myAgent },
server: honoServer({
// Default: allows all origins
}),
});

Custom CORS Settings

new VoltAgent({
agents: { myAgent },
server: honoServer({
cors: {
origin: "https://your-domain.com",
allowHeaders: ["X-Custom-Header", "Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
},
}),
});

Multiple Origins

new VoltAgent({
agents: { myAgent },
server: honoServer({
cors: {
origin: ["https://app1.com", "https://app2.com", "https://app3.com"],
credentials: true,
},
}),
});

Dynamic Origin

new VoltAgent({
agents: { myAgent },
server: honoServer({
cors: {
origin: (origin) => {
// Allow specific domains
const allowedDomains = ["app1.com", "app2.com"];
if (allowedDomains.some((domain) => origin.includes(domain))) {
return origin;
}
return undefined; // Reject
},
credentials: true,
},
}),
});

Route-Specific CORS

For advanced use cases where different routes need different CORS policies, disable the default CORS and configure route-specific CORS in configureApp:

import { cors } from "hono/cors";

new VoltAgent({
agents: { myAgent },
server: honoServer({
cors: false, // Disable default CORS

configureApp: (app) => {
// Agent routes - strict CORS
app.use(
"/agents/*",
cors({
origin: "https://agents-app.com",
credentials: true,
}),
);

// Public API - permissive CORS
app.use(
"/api/public/*",
cors({
origin: "*",
}),
);

// Admin routes - very strict CORS
app.use(
"/api/admin/*",
cors({
origin: ["https://admin.com", "https://admin-staging.com"],
credentials: true,
allowMethods: ["GET", "POST"],
}),
);

// Your custom routes
app.get("/api/public/status", (c) => c.json({ status: "ok" }));
app.get("/api/admin/stats", (c) => c.json({ stats: {...} }));
},
}),
});

Important: When cors: false, you must manually configure CORS for all routes that need it, including VoltAgent's built-in routes.

Route Patterns

Hono supports various route patterns:

Static Routes

configureApp: (app) => {
app.get("/api/status", (c) => c.json({ status: "ok" }));
app.post("/api/users", async (c) => {
/* ... */
});
app.put("/api/settings", async (c) => {
/* ... */
});
app.delete("/api/cache", async (c) => {
/* ... */
});
};

Path Parameters

configureApp: (app) => {
// Single parameter
app.get("/api/users/:id", (c) => {
const userId = c.req.param("id");
return c.json({ userId });
});

// Multiple parameters
app.get("/api/posts/:postId/comments/:commentId", (c) => {
const postId = c.req.param("postId");
const commentId = c.req.param("commentId");
return c.json({ postId, commentId });
});

// Optional parameters with regex
app.get("/api/files/:filename{.+\\.pdf}", (c) => {
const filename = c.req.param("filename");
return c.json({ pdf: filename });
});
};

Wildcards

configureApp: (app) => {
// Match any path after /api/
app.get("/api/*", (c) => {
const path = c.req.path;
return c.json({ path });
});
};

Request Handling

Query Parameters

app.get("/api/search", (c) => {
// Single value
const query = c.req.query("q");

// Multiple values for same key
const tags = c.req.queries("tag");

// All query parameters
const allParams = c.req.query();

return c.json({
query,
tags,
allParams,
});
});

Request Body

// JSON body
app.post("/api/json", async (c) => {
const body = await c.req.json();
return c.json({ received: body });
});

// Form data
app.post("/api/form", async (c) => {
const formData = await c.req.formData();
const name = formData.get("name");
return c.json({ name });
});

// Text body
app.post("/api/text", async (c) => {
const text = await c.req.text();
return c.text(`Received: ${text}`);
});

// Raw body
app.post("/api/raw", async (c) => {
const buffer = await c.req.arrayBuffer();
return c.json({ size: buffer.byteLength });
});

Headers

app.get("/api/headers", (c) => {
// Get specific header
const auth = c.req.header("Authorization");
const contentType = c.req.header("Content-Type");

// Get all headers
const headers = c.req.header();

return c.json({ auth, contentType, headers });
});

Response Types

JSON Response

app.get("/api/data", (c) => {
return c.json(
{ success: true, data: { id: 1, name: "Item" } },
200 // Optional status code
);
});

Text Response

app.get("/api/text", (c) => {
return c.text("Hello World", 200);
});

HTML Response

app.get("/api/html", (c) => {
return c.html("<h1>Hello World</h1>", 200);
});

File Response

app.get("/api/file", async (c) => {
const file = await readFile("./data.pdf");
return c.body(file, 200, {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="data.pdf"',
});
});

Redirect

app.get("/api/redirect", (c) => {
return c.redirect("/new-location", 301);
});

Custom Headers

app.get("/api/custom", (c) => {
c.header("X-Custom-Header", "value");
c.header("Cache-Control", "max-age=3600");
return c.json({ data: "with custom headers" });
});

Middleware

Add middleware to your custom routes:

Route-Specific Middleware

import { logger } from "hono/logger";
import { compress } from "hono/compress";

configureApp: (app) => {
// Apply to specific routes
app.use("/api/*", logger());
app.use("/api/*", compress());

// Custom middleware
app.use("/api/admin/*", async (c, next) => {
// Check admin access
const user = c.get("authenticatedUser");
if (!user?.roles?.includes("admin")) {
return c.json({ error: "Admin access required" }, 403);
}
await next();
});
};

Request Validation

import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).max(120),
});

configureApp: (app) => {
app.post("/api/users", zValidator("json", userSchema), async (c) => {
const data = c.req.valid("json");
// data is typed and validated
return c.json({ success: true, user: data });
});
};

Error Handling

Try-Catch Pattern

app.get("/api/risky", async (c) => {
try {
const result = await riskyOperation();
return c.json({ success: true, data: result });
} catch (error) {
logger.error("Operation failed:", error);
return c.json({ success: false, error: error.message }, 500);
}
});

Global Error Handler

configureApp: (app) => {
// Add error handler
app.onError((err, c) => {
console.error("Global error:", err);
return c.json(
{
success: false,
error: err.message,
stack: process.env.NODE_ENV === "development" ? err.stack : undefined,
},
500
);
});

// Add not found handler
app.notFound((c) => {
return c.json({ success: false, error: "Route not found" }, 404);
});
};

Route Groups

Organize related routes:

configureApp: (app) => {
// Create a sub-application
const api = app.basePath("/api/v2");

// User routes
api.get("/users", listUsers);
api.get("/users/:id", getUser);
api.post("/users", createUser);
api.put("/users/:id", updateUser);
api.delete("/users/:id", deleteUser);

// Product routes
api.get("/products", listProducts);
api.get("/products/:id", getProduct);
api.post("/products", createProduct);
};

Integration Examples

Database Integration

import { db } from "./database";

configureApp: (app) => {
app.get("/api/items", async (c) => {
const items = await db.query("SELECT * FROM items");
return c.json({ success: true, data: items });
});

app.post("/api/items", async (c) => {
const data = await c.req.json();
const result = await db.insert("items", data);
return c.json({ success: true, id: result.id }, 201);
});
};

External API Integration

configureApp: (app) => {
app.get("/api/weather/:city", async (c) => {
const city = c.req.param("city");

const response = await fetch(`https://api.weather.com/v1/weather?city=${city}`, {
headers: { "API-Key": process.env.WEATHER_API_KEY },
});

const weather = await response.json();
return c.json({ success: true, data: weather });
});
};

WebSocket Upgrade

import { createWebSocketHandler } from "./websocket";

configureApp: (app) => {
app.get("/api/ws", (c) => {
// Upgrade to WebSocket
const wsHandler = createWebSocketHandler();
return wsHandler(c.req.raw, c.env);
});
};

Authentication for Custom Endpoints

Important: Custom routes added via configureApp are registered AFTER the authentication middleware. This means your custom routes follow the same auth rules as built-in routes.

How Authentication Works with Custom Routes

VoltAgent applies authentication middleware before configureApp:

// Authentication flow:
// 1. CORS middleware applied
// 2. Auth middleware applied (if configured)
// 3. VoltAgent built-in routes registered
// 4. configureApp called → your custom routes registered

authNext (Default Behavior)

With authNext, custom routes are user routes by default. To make a route public, add it to authNext.publicRoutes. To make a route console-only, add it to authNext.consoleRoutes.

import { DEFAULT_CONSOLE_ROUTES, jwtAuth } from "@voltagent/server-core";

new VoltAgent({
agents: { myAgent },
server: honoServer({
authNext: {
provider: jwtAuth({ secret: process.env.JWT_SECRET! }),
publicRoutes: ["GET /api/health"],
consoleRoutes: [...DEFAULT_CONSOLE_ROUTES, "GET /api/admin/metrics"],
},
configureApp: (app) => {
// Public route
app.get("/api/health", (c) => c.json({ status: "ok" }));

// Console-only route
app.get("/api/admin/metrics", (c) => c.json({ ok: true }));

// User route (JWT required)
app.get("/api/user/profile", (c) => {
const user = c.get("authenticatedUser");
return c.json({ user });
});
},
}),
});

Notes:

  • consoleRoutes replaces the default console list. Include DEFAULT_CONSOLE_ROUTES if you want the built-in console endpoints to stay console-protected.
  • If NODE_ENV is not "production", you can send x-voltagent-dev: true to bypass auth.

Legacy auth (Deprecated)

Legacy auth uses DEFAULT_LEGACY_PUBLIC_ROUTES (alias DEFAULT_PUBLIC_ROUTES) and PROTECTED_ROUTES. Custom routes are public by default unless you set defaultPrivate: true. See Authentication for details.

Best Practices

1. Consistent Response Format

// Create a standard response helper
const apiResponse = (success: boolean, data?: any, error?: string) => ({
success,
...(data && { data }),
...(error && { error }),
timestamp: new Date().toISOString(),
});

app.get("/api/example", (c) => {
return c.json(apiResponse(true, { message: "Hello" }));
});

2. Input Validation

Always validate user input:

app.post("/api/data", async (c) => {
const body = await c.req.json();

// Validate required fields
if (!body.name || !body.email) {
return c.json(apiResponse(false, null, "Missing required fields"), 400);
}

// Process valid data
return c.json(apiResponse(true, body));
});

3. Async Error Handling

Use try-catch for async operations:

app.get("/api/async", async (c) => {
try {
const result = await someAsyncOperation();
return c.json(apiResponse(true, result));
} catch (error) {
logger.error("Async operation failed:", error);
return c.json(apiResponse(false, null, "Internal server error"), 500);
}
});

4. Rate Limiting

Protect endpoints from abuse:

import { rateLimiter } from "hono-rate-limiter";

configureApp: (app) => {
app.use(
"/api/*",
rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // Max requests per window
standardHeaders: "draft-6",
keyGenerator: (c) => c.req.header("x-forwarded-for") || "anonymous",
})
);
};

Testing Custom Endpoints

# Test GET endpoint
curl http://localhost:3141/api/health

# Test POST with JSON
curl -X POST http://localhost:3141/api/users \
-H "Content-Type: application/json" \
-d '{"name": "John", "email": "john@example.com"}'

# Test with authentication
curl http://localhost:3141/api/protected \
-H "Authorization: Bearer $TOKEN"

# Test with query parameters
curl "http://localhost:3141/api/search?q=test&limit=10"

Next Steps

Table of Contents