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:
- Install dependencies and build:
npm install
npx tsc
- Run the server:
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:
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:
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.
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 to3000).
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.
// 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.
// 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.
// 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:
# 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
- API Reference: Documents — full details on every document operation
- API Reference: Search & Retrieval — tuning search parameters and filters
- Anthropic Claude integration — deeper dive into RAG patterns
- Python (FastAPI) — the same app in Python for comparison