Integrating Cycles with Express
This guide shows how to add budget governance to an Express.js application using reusable middleware.
Prerequisites
- A running Cycles stack with a tenant, API key, and budget (Deploy the Full Stack)
- Node.js 20+
Installation
bash
npm install runcycles expressPattern overview
Two patterns work well with Express:
- Middleware pattern — for routes where every request needs budget governance (e.g., chat endpoints). The middleware reserves budget and attaches a handle to
res.locals. - Inline pattern — for routes where budget governance is conditional or has custom logic. Use
withCyclesdirectly in the route handler.
Middleware pattern
Create a reusable middleware that reserves budget for each request:
typescript
// middleware/cycles-guard.ts
import type { Request, Response, NextFunction } from "express";
import {
CyclesClient,
reserveForStream,
BudgetExceededError,
} from "runcycles";
interface CyclesGuardOptions {
client: CyclesClient;
actionKind: string;
actionName: string;
estimateFn: (req: Request) => number;
unit?: string;
}
export function cyclesGuard(options: CyclesGuardOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
const estimate = options.estimateFn(req);
try {
const handle = await reserveForStream({
client: options.client,
estimate,
unit: options.unit ?? "USD_MICROCENTS",
actionKind: options.actionKind,
actionName: options.actionName,
});
// Attach the handle so route handlers can commit/release
res.locals.cyclesHandle = handle;
// Release budget if the client disconnects
res.on("close", async () => {
if (!res.locals.cyclesCommitted) {
await handle.release("client_disconnect");
}
});
next();
} catch (err) {
if (err instanceof BudgetExceededError) {
res.status(402).json({
error: "budget_exceeded",
message: "Budget exhausted for this operation.",
});
return;
}
next(err);
}
};
}Use the middleware on a route:
typescript
// server.ts
import express from "express";
import { CyclesClient, CyclesConfig } from "runcycles";
import { cyclesGuard } from "./middleware/cycles-guard.js";
const app = express();
app.use(express.json());
const cyclesClient = new CyclesClient(CyclesConfig.fromEnv());
// Protect the chat route with budget governance
app.post(
"/api/chat",
cyclesGuard({
client: cyclesClient,
actionKind: "llm.completion",
actionName: "gpt-4o",
estimateFn: (req) => {
const messages = req.body?.messages ?? [];
const chars = messages.reduce(
(sum: number, m: { content?: string }) =>
sum + (typeof m.content === "string" ? m.content.length : 0),
0,
);
const inputTokens = Math.ceil(chars / 4);
return Math.ceil(inputTokens * 250 + inputTokens * 2 * 1000);
},
}),
async (req, res) => {
const handle = res.locals.cyclesHandle;
try {
// Your LLM call here
const response = await callOpenAI(req.body.messages);
// Commit actual cost
const actualCost = calculateActualCost(response.usage);
await handle.commit(actualCost, {
tokensInput: response.usage.prompt_tokens,
tokensOutput: response.usage.completion_tokens,
});
res.locals.cyclesCommitted = true;
res.json({ message: response.content });
} catch (err) {
await handle.release("handler_error");
res.locals.cyclesCommitted = true;
throw err;
}
},
);
app.listen(3000);Inline pattern with withCycles
For simpler routes, use withCycles directly:
typescript
import { withCycles, CyclesClient, CyclesConfig, setDefaultClient } from "runcycles";
const cyclesClient = new CyclesClient(CyclesConfig.fromEnv());
setDefaultClient(cyclesClient);
const summarize = withCycles(
{ estimate: 3000000, actionKind: "llm.completion", actionName: "gpt-4o-mini" },
async (text: string) => {
return await callOpenAI([{ role: "user", content: `Summarize: ${text}` }]);
},
);
app.post("/api/summarize", async (req, res) => {
try {
const result = await summarize(req.body.text);
res.json({ summary: result });
} catch (err) {
if (err instanceof BudgetExceededError) {
res.status(402).json({ error: "budget_exceeded" });
return;
}
throw err;
}
});Budget observability endpoint
Add an endpoint to check current budget status:
typescript
app.get("/api/balance", async (_req, res) => {
const balances = await cyclesClient.getBalances({
tenant: cyclesClient.config.tenant!,
});
res.json(balances.body);
});Per-tenant middleware
For multi-tenant applications, resolve the tenant from the request:
typescript
app.post(
"/api/chat",
cyclesGuard({
client: cyclesClient,
actionKind: "llm.completion",
actionName: "gpt-4o",
estimateFn: (req) => Math.ceil(req.body.text.length / 4 * 1250),
// Tenant resolved per-request from auth middleware
tenantFn: (req) => req.auth.tenantId,
}),
chatHandler,
);Next steps
- Choosing the Right Integration Pattern — when to use middleware vs inline
- Cost Estimation Cheat Sheet — how much to reserve per model
- Error Handling in TypeScript — handling Cycles errors
