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

With @voltagent/server-hono, you can add custom routes using the configureApp callback which gives you direct access to the Hono app instance.

Basic Setup

Add custom endpoints through the 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 });
});
},
}),
});

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 when you configure an auth provider, your custom routes automatically inherit the same authentication behavior as VoltAgent's built-in routes.

How Authentication Works with Custom Routes

VoltAgent applies authentication middleware to all routes before configureApp is called. This ensures your custom endpoints have the same security posture as built-in endpoints.

// 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

Opt-In Mode (Default)

By default (defaultPrivate: false), only execution endpoints require authentication. Custom routes are public unless they match a protected pattern:

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

new VoltAgent({
agents: { myAgent },
server: honoServer({
auth: jwtAuth({
secret: process.env.JWT_SECRET,
// defaultPrivate: false (default)
}),
configureApp: (app) => {
// Public endpoint (doesn't match protected patterns)
app.get("/api/public-data", (c) => {
return c.json({ data: "anyone can access this" });
});

// Also public (you can access authenticatedUser if it exists)
app.get("/api/optional-auth", (c) => {
const user = c.get("authenticatedUser");
if (user) {
return c.json({ message: `Hello, ${user.email}` });
}
return c.json({ message: "Hello, anonymous" });
});
},
}),
});

Set defaultPrivate: true to protect all routes by default, including custom endpoints. Then selectively make routes public using publicRoutes:

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

new VoltAgent({
agents: { myAgent },
server: honoServer({
auth: jwtAuth({
secret: process.env.JWT_SECRET,
defaultPrivate: true, // Protect all routes by default
publicRoutes: ["GET /api/health", "GET /api/status", "POST /api/webhooks/*"],
}),
configureApp: (app) => {
// Public endpoint (in publicRoutes)
app.get("/api/health", (c) => c.json({ status: "ok" }));

// Protected endpoint (requires authentication)
app.get("/api/user/profile", (c) => {
const user = c.get("authenticatedUser");
return c.json({ user }); // user is guaranteed to exist
});

// All custom routes are protected unless in publicRoutes
app.post("/api/data", async (c) => {
const user = c.get("authenticatedUser");
const body = await c.req.json();
// Process authenticated request
return c.json({ success: true, userId: user.id });
});
},
}),
});

Benefits of Opt-Out Mode:

  • ✅ Automatic protection for all custom endpoints
  • ✅ No need to manually check authentication in each route
  • ✅ Better security by default (fail-safe)
  • ✅ Easier to maintain when using third-party auth providers (Clerk, Auth0)
  • ✅ Consistent auth behavior across all routes

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": "[email protected]"}'

# 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