Examples

TypeScript (Express)

Build a Knowledge Base Manager API with Express and the Aether TypeScript SDK.

This example implements the full Knowledge Base Manager as an Express server. It covers document management, semantic search, RAG-powered chat with Anthropic Claude, and a usage stats endpoint — all through a clean REST API that returns JSON.


Quick start

Get the app running in three steps:

  1. Install dependencies and build:
Bash
npm install
npx tsc
  1. Run the server:
Bash
ANTHROPIC_API_KEY=sk-ant-... node dist/server.js

The server starts on http://localhost:3000. The chat endpoint requires an Anthropic API key — every other feature works without one.


Project setup

Initialize the project and install the required packages:

Bash
mkdir kb-manager-ts && cd kb-manager-ts
npm init -y
npm install express @aether-ai/sdk @anthropic-ai/sdk
npm install -D typescript @types/express @types/node
npx tsc --init --outDir dist --rootDir src --strict --esModuleInterop

Create a single source file:

text
kb-manager-ts/
  src/
    server.ts
  tsconfig.json
  package.json

Full source code

The entire application lives in src/server.ts. It's broken into four sections below, with commentary between each.

Server setup

Start with the imports, client initialization, and middleware. The async handler wrapper keeps error handling clean across all routes without repeating try/catch blocks.

TypeScript
import express, { Request, Response, NextFunction } from "express";
import { AetherClient } from "@aether-ai/sdk";
import Anthropic from "@anthropic-ai/sdk";

const app = express();
app.use(express.json());

const aether = new AetherClient({ apiKey: process.env.AETHER_API_KEY });

// Wrap async route handlers so rejected promises forward to the error handler
function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
}

Environment variables

  • AETHER_API_KEY — your Aether API key, passed to the client (required).
  • ANTHROPIC_API_KEY — Anthropic API key for the chat endpoint (optional).
  • PORT — Server port (defaults to 3000).

Document endpoints

These five routes handle the full document lifecycle: listing, inserting, viewing, soft-deleting, and restoring. The GET /api/documents/:id route fetches both metadata and content in parallel so the response includes everything the client needs.

TypeScript
// List documents
app.get(
  "/api/documents",
  asyncHandler(async (req: Request, res: Response) => {
    const offset = parseInt(req.query.offset as string) || 0;
    const limit = parseInt(req.query.limit as string) || 20;
    const result = await aether.list({ offset, limit });
    res.json(result);
  })
);

// Insert a document
app.post(
  "/api/documents",
  asyncHandler(async (req: Request, res: Response) => {
    const { text, title } = req.body;
    if (!text) {
      res.status(400).json({ error: "Missing required field: text" });
      return;
    }
    const doc = await aether.insertText(text, title);
    res.status(201).json(doc);
  })
);

// Get document metadata and content
app.get(
  "/api/documents/:id",
  asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;
    const [metadata, content] = await Promise.all([
      aether.get(id),
      aether.downloadText(id),
    ]);
    res.json({ ...metadata, content });
  })
);

// Soft-delete a document
app.delete(
  "/api/documents/:id",
  asyncHandler(async (req: Request, res: Response) => {
    await aether.delete(req.params.id);
    res.json({ deleted: true });
  })
);

// Restore a soft-deleted document
app.post(
  "/api/documents/:id/restore",
  asyncHandler(async (req: Request, res: Response) => {
    await aether.restore(req.params.id);
    res.json({ restored: true });
  })
);

Search and chat

The search endpoint wraps aether.search() with query-string parameters. The chat endpoint implements the full RAG pattern: retrieve relevant passages from Aether, inject them as context, and generate an answer with Claude. If no API key is configured, it returns a clear error instead of crashing.

TypeScript
// Semantic search
app.get(
  "/api/search",
  asyncHandler(async (req: Request, res: Response) => {
    const q = req.query.q as string;
    if (!q) {
      res.status(400).json({ error: "Missing required query parameter: q" });
      return;
    }
    const k = parseInt(req.query.k as string) || 5;
    const results = await aether.search(q, k);
    res.json({ query: q, results });
  })
);

// RAG chat: retrieve context from Aether, generate answer with Claude
app.post(
  "/api/chat",
  asyncHandler(async (req: Request, res: Response) => {
    const { message } = req.body;
    if (!message) {
      res.status(400).json({ error: "Missing required field: message" });
      return;
    }

    if (!process.env.ANTHROPIC_API_KEY) {
      res.status(503).json({
        error: "Chat unavailable: ANTHROPIC_API_KEY is not set",
      });
      return;
    }

    // Retrieve relevant passages from the knowledge base
    const passages = await aether.retrieve(message, 5);
    const context = passages
      .map((p, i) => `[Source ${i + 1}]\n${p.content}`)
      .join("\n\n");

    // Generate a grounded answer
    const anthropic = new Anthropic();
    const response = await anthropic.messages.create({
      model: "claude-sonnet-4-20250514",
      max_tokens: 1024,
      system: `You are a helpful assistant. Answer the user's question using only the following context. If the context doesn't contain enough information, say so. Cite source numbers.\n\n${context}`,
      messages: [{ role: "user", content: message }],
    });

    const answer =
      response.content[0].type === "text" ? response.content[0].text : "";

    res.json({
      answer,
      sources: passages.map((p) => ({
        doc_id: p.doc_id,
        title: p.title,
      })),
    });
  })
);

Stats endpoint and startup

The stats endpoint reports the total document count. list() returns pagination metadata with every page, so requesting a single document is enough to read the total. Finally, the error handler catches anything that slipped through and the server starts listening.

TypeScript
// Usage stats
app.get(
  "/api/stats",
  asyncHandler(async (req: Request, res: Response) => {
    const { total } = await aether.list({ limit: 1 });
    res.json({ total_documents: total });
  })
);

// Global error handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error("Unhandled error:", err.message);
  res.status(500).json({ error: err.message });
});

const port = parseInt(process.env.PORT || "3000");
app.listen(port, () => {
  console.log(`Knowledge Base Manager listening on http://localhost:${port}`);
});

Testing the API

Once the server is running, try a few requests:

Bash
# Insert a document
curl -X POST http://localhost:3000/api/documents \
  -H "Content-Type: application/json" \
  -d '{"text": "Employees accrue 20 days of PTO per year.", "title": "PTO Policy"}'

# List documents
curl http://localhost:3000/api/documents

# Search
curl "http://localhost:3000/api/search?q=vacation%20days&k=3"

# Chat (requires ANTHROPIC_API_KEY)
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "How many vacation days do I get?"}'

# Check usage stats
curl http://localhost:3000/api/stats

Next steps