Claude-skill-registry bknd-custom-endpoint
Use when creating custom API endpoints in Bknd. Covers HTTP triggers with Flows, plugin routes via onServerInit, request/response handling, sync vs async modes, accessing request data, and returning custom responses.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/bknd-custom-endpoint" ~/.claude/skills/majiayu000-claude-skill-registry-bknd-custom-endpoint && rm -rf "$T"
skills/data/bknd-custom-endpoint/SKILL.mdCustom Endpoint
Create custom API endpoints beyond Bknd's auto-generated CRUD routes.
Prerequisites
- Running Bknd instance
- Basic understanding of HTTP methods and REST APIs
- Familiarity with TypeScript/JavaScript
When to Use UI Mode
Custom endpoints require code configuration. No UI approach available.
When to Use Code Mode
- Creating webhooks for external services
- Building custom business logic endpoints
- Adding endpoints that combine multiple operations
- Integrating with third-party APIs
- Creating public endpoints without entity CRUD
Two Approaches
Bknd offers two ways to create custom endpoints:
| Approach | Best For | Complexity |
|---|---|---|
| Flows + HTTP Triggers | Business logic, webhooks, multi-step processes | Medium |
| Plugin Routes | Simple endpoints, middleware, direct Hono access | Low |
Approach 1: Flows with HTTP Triggers
Step 1: Create a Basic Flow Endpoint
import { App, Flow, HttpTrigger, LogTask } from "bknd"; // Define a flow with tasks const helloFlow = new Flow("hello-endpoint", [ new LogTask("log", { message: "Hello endpoint called!" }), ]); // Attach HTTP trigger helloFlow.setTrigger( new HttpTrigger({ path: "/api/custom/hello", method: "GET", }) ); // Register in app config const app = new App({ flows: { flows: [helloFlow], }, });
Test:
curl http://localhost:7654/api/custom/hello # Returns: { "success": true }
Step 2: Create Endpoint with Response
Use
setRespondingTask() to return data from a specific task:
import { App, Flow, HttpTrigger, FetchTask } from "bknd"; const fetchTask = new FetchTask("fetch-data", { url: "https://api.example.com/data", method: "GET", }); const apiFlow = new Flow("external-api", [fetchTask]); // This task's output becomes the response apiFlow.setRespondingTask(fetchTask); apiFlow.setTrigger( new HttpTrigger({ path: "/api/custom/external", method: "GET", response_type: "json", // "json" | "text" | "html" }) );
Step 3: Handle POST with Request Body
Access request data in tasks:
import { App, Flow, HttpTrigger, Task } from "bknd"; import { s } from "bknd/utils"; // Custom task to process request class ProcessTask extends Task<typeof ProcessTask.schema> { override type = "process"; static override schema = s.strictObject({ // Define expected params (can use template syntax) }); override async execute(input: Request) { // input is the raw Request object const body = await input.json(); return { received: body, processed: true, timestamp: new Date().toISOString(), }; } } const processTask = new ProcessTask("process-input", {}); const postFlow = new Flow("process-data", [processTask]); postFlow.setRespondingTask(processTask); postFlow.setTrigger( new HttpTrigger({ path: "/api/custom/process", method: "POST", response_type: "json", }) );
Test:
curl -X POST http://localhost:7654/api/custom/process \ -H "Content-Type: application/json" \ -d '{"name": "test", "value": 42}'
Step 4: Sync vs Async Mode
// Sync (default): Wait for flow completion, return result new HttpTrigger({ path: "/api/custom/sync", method: "POST", mode: "sync", // Wait for completion }); // Async: Return immediately, process in background new HttpTrigger({ path: "/api/custom/async", method: "POST", mode: "async", // Fire and forget }); // Returns: { "success": true } immediately
Use async for:
- Long-running operations
- Webhook receivers
- Background jobs
Step 5: Multi-Task Flow with Connections
import { Flow, HttpTrigger, FetchTask, LogTask, Condition } from "bknd"; const validateTask = new FetchTask("validate", { url: "https://api.example.com/validate", method: "POST", }); const successTask = new LogTask("success", { message: "Validation passed!", }); const failTask = new LogTask("fail", { message: "Validation failed!", }); const flow = new Flow("validation-flow", [ validateTask, successTask, failTask, ]); // Connect tasks with conditions flow.task(validateTask) .asInputFor(successTask, Condition.success()) .asInputFor(failTask, Condition.error()); flow.setRespondingTask(successTask); flow.setTrigger( new HttpTrigger({ path: "/api/custom/validate", method: "POST", }) );
HTTP Trigger Options Reference
type HttpTriggerOptions = { path: string; // URL path (must start with /) method?: string; // "GET" | "POST" | "PUT" | "PATCH" | "DELETE" response_type?: string; // "json" | "text" | "html" (default: "json") mode?: string; // "sync" | "async" (default: "sync") };
Approach 2: Plugin Routes (Direct Hono)
For simpler endpoints, use plugins with
onServerInit:
Step 1: Create Plugin with Routes
import { App, createPlugin } from "bknd"; import type { Hono } from "hono"; const customRoutes = createPlugin({ name: "custom-routes", onServerInit: (server: Hono) => { // Simple GET endpoint server.get("/api/custom/status", (c) => { return c.json({ status: "ok", timestamp: Date.now() }); }); // POST endpoint with body server.post("/api/custom/echo", async (c) => { const body = await c.req.json(); return c.json({ echo: body }); }); // With path parameters server.get("/api/custom/users/:id", (c) => { const id = c.req.param("id"); return c.json({ userId: id }); }); // With query parameters server.get("/api/custom/search", (c) => { const query = c.req.query("q"); const limit = c.req.query("limit") || "10"; return c.json({ query, limit: parseInt(limit) }); }); }, }); const app = new App({ plugins: [customRoutes], });
Step 2: Access App Context in Plugin Routes
import { App, createPlugin } from "bknd"; const apiPlugin = createPlugin({ name: "api-plugin", onServerInit: (server, { app }) => { server.get("/api/custom/posts-count", async (c) => { // Access data API const em = app.modules.data?.em; if (!em) { return c.json({ error: "Data module not available" }, 500); } const count = await em.repo("posts").count(); return c.json({ count }); }); server.post("/api/custom/create-post", async (c) => { const body = await c.req.json(); const em = app.modules.data?.em; const post = await em.repo("posts").insertOne({ title: body.title, content: body.content, }); return c.json({ created: post }, 201); }); }, });
Step 3: Protected Plugin Routes
import { createPlugin } from "bknd"; const protectedPlugin = createPlugin({ name: "protected-routes", onServerInit: (server, { app }) => { // Middleware for auth check const requireAuth = async (c, next) => { const auth = app.modules.auth; const user = await auth?.authenticator?.verify(c.req.raw); if (!user) { return c.json({ error: "Unauthorized" }, 401); } c.set("user", user); return next(); }; // Protected endpoint server.get("/api/custom/profile", requireAuth, (c) => { const user = c.get("user"); return c.json({ user }); }); // Admin-only endpoint server.delete("/api/custom/admin/clear-cache", requireAuth, async (c) => { const user = c.get("user"); if (user.role !== "admin") { return c.json({ error: "Forbidden" }, 403); } // Clear cache logic... return c.json({ cleared: true }); }); }, });
Step 4: Plugin with Sub-Router
import { createPlugin } from "bknd"; import { Hono } from "hono"; const webhooksPlugin = createPlugin({ name: "webhooks", onServerInit: (server) => { const webhooks = new Hono(); webhooks.post("/stripe", async (c) => { const payload = await c.req.text(); const sig = c.req.header("stripe-signature"); // Verify and process Stripe webhook... return c.json({ received: true }); }); webhooks.post("/github", async (c) => { const event = c.req.header("x-github-event"); const body = await c.req.json(); // Process GitHub webhook... return c.json({ received: true }); }); // Mount sub-router server.route("/api/webhooks", webhooks); }, });
Accessing Request Data
In Flow Tasks (via input)
class MyTask extends Task { async execute(input: Request) { // Body const json = await input.json(); const text = await input.text(); const form = await input.formData(); // Headers const auth = input.headers.get("authorization"); const contentType = input.headers.get("content-type"); // URL info const url = new URL(input.url); const searchParams = url.searchParams; return { processed: true }; } }
In Plugin Routes (via Hono context)
server.post("/api/custom/upload", async (c) => { // Body const json = await c.req.json(); const text = await c.req.text(); const form = await c.req.formData(); // Headers const auth = c.req.header("authorization"); // Query params const format = c.req.query("format"); // Path params (if route has :param) const id = c.req.param("id"); // Raw request const raw = c.req.raw; return c.json({ received: true }); });
Response Patterns
In Plugin Routes
server.get("/api/custom/demo", (c) => { // JSON response return c.json({ data: "value" }); // JSON with status return c.json({ error: "Not found" }, 404); // Text response return c.text("Hello, World!"); // HTML response return c.html("<h1>Hello</h1>"); // Redirect return c.redirect("/other-path"); // Custom response return new Response(body, { status: 200, headers: { "X-Custom": "header" }, }); });
Complete Example: Webhook Receiver
import { App, createPlugin, Flow, HttpTrigger, Task } from "bknd"; import { s } from "bknd/utils"; // Option 1: Using Flows class WebhookTask extends Task<typeof WebhookTask.schema> { override type = "webhook-processor"; static override schema = s.strictObject({}); override async execute(input: Request) { const event = input.headers.get("x-webhook-event"); const body = await input.json(); // Process webhook based on event type switch (event) { case "user.created": console.log("New user:", body.user); break; case "order.completed": console.log("Order completed:", body.order); break; } return { processed: true, event }; } } const webhookFlow = new Flow("webhook-handler", [ new WebhookTask("process", {}), ]); webhookFlow.setRespondingTask(webhookFlow.tasks[0]); webhookFlow.setTrigger( new HttpTrigger({ path: "/api/webhooks/external", method: "POST", mode: "async", // Return immediately }) ); // Option 2: Using Plugin (simpler) const webhookPlugin = createPlugin({ name: "webhook-handler", onServerInit: (server) => { server.post("/api/webhooks/simple", async (c) => { const event = c.req.header("x-webhook-event"); const body = await c.req.json(); // Queue for background processing queueMicrotask(async () => { // Process webhook... }); return c.json({ received: true }); }); }, }); const app = new App({ flows: { flows: [webhookFlow] }, plugins: [webhookPlugin], });
Listing Custom Endpoints
# List all registered routes including custom ones bknd debug routes
Common Pitfalls
Flow Not Responding
Problem: Endpoint returns
{ success: true } but no data
Fix: Set responding task:
// WRONG - no response data const flow = new Flow("my-flow", [task]); flow.setTrigger(new HttpTrigger({ path: "/api/test" })); // CORRECT - task output becomes response const flow = new Flow("my-flow", [task]); flow.setRespondingTask(task); // Add this! flow.setTrigger(new HttpTrigger({ path: "/api/test" }));
Path Conflicts
Problem: Custom endpoint conflicts with built-in routes
Fix: Use unique path prefixes:
// WRONG - conflicts with data API new HttpTrigger({ path: "/api/data/custom" }); // CORRECT - unique namespace new HttpTrigger({ path: "/api/custom/data" }); new HttpTrigger({ path: "/api/v1/custom" }); new HttpTrigger({ path: "/webhooks/stripe" });
Missing Content-Type in Response
Problem: Client can't parse response
Fix: Use Hono's response helpers:
// WRONG return new Response(JSON.stringify(data)); // CORRECT return c.json(data); // Sets Content-Type automatically
Async Mode Confusion
Problem: Expecting data from async endpoint
Fix: Understand async returns immediately:
// Async mode - returns { success: true } immediately new HttpTrigger({ path: "/api/job", mode: "async" }); // For data responses, use sync (default) new HttpTrigger({ path: "/api/query", mode: "sync" });
Plugin Not Loading
Problem: Custom routes return 404
Fix: Ensure plugin is registered:
const app = new App({ plugins: [myPlugin], // Must include plugin here });
DOs and DON'Ts
DO:
- Use Flows for complex multi-step operations
- Use plugins for simple CRUD-style endpoints
- Set
for webhooks and long operationsmode: "async" - Use unique path prefixes (
,/api/custom/
)/webhooks/ - Call
when you need response datasetRespondingTask() - Validate request bodies before processing
DON'T:
- Conflict with built-in paths (
,/api/data/
)/api/auth/ - Forget to register flows/plugins in App config
- Use sync mode for long-running operations
- Return raw Response without Content-Type
- Expose sensitive operations without auth checks
Related Skills
- bknd-api-discovery - Explore auto-generated endpoints
- bknd-webhooks - Configure webhook integrations
- bknd-protect-endpoint - Secure custom endpoints
- bknd-client-setup - Call custom endpoints from frontend