home / skills / cameronapak / bknd-skills / bknd-custom-endpoint

bknd-custom-endpoint skill

/skills/bknd-custom-endpoint

This skill helps you create and manage custom API endpoints in BKND using flows, HTTP triggers, and plugin routes for flexible backend logic.

npx playbooks add skill cameronapak/bknd-skills --skill bknd-custom-endpoint

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
14.2 KB
---
name: bknd-custom-endpoint
description: 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.
---

# Custom 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

```typescript
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:**

```bash
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:

```typescript
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:

```typescript
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:**

```bash
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

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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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)

```typescript
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)

```typescript
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

```typescript
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

```typescript
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

```bash
# 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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 `mode: "async"` for webhooks and long operations
- Use unique path prefixes (`/api/custom/`, `/webhooks/`)
- Call `setRespondingTask()` when you need response data
- 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

Overview

This skill explains how to create custom API endpoints in Bknd using two approaches: Flows with HTTP triggers and plugin routes via onServerInit. It covers request/response handling, sync vs async modes, accessing request data, and returning custom responses. The goal is practical guidance to build webhooks, composite endpoints, and lightweight middleware endpoints.

How this skill works

Flows expose HTTP triggers that run choreographed tasks; you attach an HttpTrigger to a Flow and optionally mark a responding task whose output becomes the HTTP response. Plugins let you register Hono routes directly during onServerInit, giving full control of request handling, headers, query/path params, and response types. Both approaches require registering flows or plugins in the App config so routes are active.

When to use it

  • Use Flows + HttpTrigger for multi-step business logic and when you need task orchestration or waiting for results.
  • Use plugin routes for simple endpoints, middleware, direct Hono features, or when you need low-latency handlers.
  • Choose async mode for webhooks, background jobs, and long-running tasks.
  • Choose sync mode when the HTTP client expects an immediate result from the flow.
  • Use plugin sub-routers for grouped webhooks (e.g., /api/webhooks) or when third-party libraries require Hono.

Best practices

  • Always register flows and plugins in the App config so routes are available.
  • Use unique path prefixes (e.g., /api/custom/, /webhooks/) to avoid conflicts with built-in routes.
  • Call setRespondingTask() on a Flow to return task output; otherwise the endpoint returns a generic success response.
  • Prefer mode: "async" for fire-and-forget work and mode: "sync" for endpoints that must return processed data.
  • Validate request bodies and set Content-Type; use c.json(...) in plugins to let Hono set response headers automatically.

Example use cases

  • Webhook receiver that accepts events and queues background processing (async Flow or plugin).
  • Composite endpoint that calls multiple internal APIs and returns aggregated data (Flow with FetchTask and responding task).
  • Simple status, echo, or CRUD helper endpoints implemented via plugin routes for low overhead.
  • Protected profile or admin endpoints using plugin middleware to verify auth and attach user context.
  • Sub-router for multiple vendor webhooks (Stripe, GitHub) mounted under /api/webhooks.

FAQ

What if my Flow endpoint returns only {"success": true} and no data?

Set a responding task with flow.setRespondingTask(task) so the task output becomes the HTTP response.

When should I use async mode?

Use async for webhooks, long-running jobs, or when the client does not need immediate processing results; async returns immediately with success and continues work in the background.

How do I access request body and headers?

In Flow tasks the execute(input: Request) receives the raw Request; use input.json(), input.headers.get(...). In plugins use c.req.json(), c.req.header(...), c.req.query(...), and c.req.param(...).