Server-side handler and platform adapters for ChatCops. Handles streaming responses, rate limiting, webhooks, and analytics.
npm install @chatcops/server @chatcops/coreimport express from 'express';
import { chatcopsMiddleware } from '@chatcops/server';
const app = express();
app.use(express.json());
// app.use, so /chat/session, where the widget gets its session token, reaches it too.
app.use('/chat', chatcopsMiddleware({
provider: {
type: 'claude',
apiKey: process.env.ANTHROPIC_API_KEY,
},
systemPrompt: 'You are a helpful assistant for our website.',
allowedOrigins: ['*'],
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET], // openssl rand -base64 32
}));
app.listen(3001);import { chatcopsVercelHandler } from '@chatcops/server';
const handler = chatcopsVercelHandler({
provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
systemPrompt: 'You are a helpful assistant.',
allowedOrigins: ['*'],
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET],
});
export const POST = (req: Request) => handler(req);
export const OPTIONS = (req: Request) => handler(req);
export const config = { runtime: 'edge' };Export the same handler from the /session route next to it (app/api/chat/session/route.ts for a chat at /api/chat), where the widget gets its session token.
import { chatcopsCloudflareHandler } from '@chatcops/server';
let handler: ReturnType<typeof chatcopsCloudflareHandler> | undefined;
function getHandler(env: Env) {
handler ??= chatcopsCloudflareHandler({
provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY },
systemPrompt: 'You are a helpful assistant.',
allowedOrigins: ['*'],
sessionSecrets: [env.CHATCOPS_SESSION_SECRET],
});
return handler;
}
export default {
async fetch(request: Request, env: Env) {
return getHandler(env)(request);
},
};import { createChatHandler } from '@chatcops/server';
import { LeadCaptureTool, FAQKnowledgeSource } from '@chatcops/core';
const handler = createChatHandler({
// AI provider (required)
provider: {
type: 'claude', // 'claude' | 'openai' | 'gemini'
apiKey: 'sk-...',
model: 'claude-haiku-5-5',
},
// System prompt (required)
systemPrompt: 'You are a helpful support assistant.',
// Tools (optional)
tools: [
new LeadCaptureTool((lead) => {
console.log('New lead:', lead.email);
}),
],
// Knowledge base (optional)
knowledge: [
new FAQKnowledgeSource([
{ question: 'What do you do?', answer: 'We build software.' },
]),
],
// Rate limiting (optional, default: 30 req/60s)
rateLimit: { maxRequests: 10, windowMs: 60_000 },
// Webhooks (optional)
webhooks: [
{ url: 'https://hooks.example.com/leads', events: ['lead:captured'] },
],
// Analytics (optional)
analytics: true,
// Conversation storage (optional, default: in-memory)
storage: { conversations: new RedisConversationStore(redisClient) },
// Session tokens: required unless allowAnonymous: true
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET],
// CORS origin (optional, default: '*')
allowedOrigins: ['*'],
});All adapters produce identical Server-Sent Events:
data: {"content":"Hello"}
data: {"content":" there!"}
data: {"done":true}
data: [DONE]
On error:
data: {"error":"rate_limit","retryAfter":42}
data: [DONE]
Error codes: invalid_request, rate_limit, provider_error, internal_error.
In-memory, per-IP. Suitable for single-instance deployments.
import { RateLimiter } from '@chatcops/server';
const limiter = new RateLimiter({ maxRequests: 30, windowMs: 60_000 });
const result = limiter.check('127.0.0.1');
// { allowed: true } or { allowed: false, retryAfter: 42 }HMAC-SHA256 signed, with retries (up to 3 attempts).
import { WebhookDispatcher } from '@chatcops/server';
const dispatcher = new WebhookDispatcher([
{ url: 'https://hooks.example.com/chat', events: ['*'], secret: 'my-secret' },
]);
await dispatcher.dispatch('lead:captured', { email: 'user@example.com' });Each delivery carries X-ChatCops-Signature: t=<seconds>,v1=<hmac>, signed over the raw body. Check it on the receiving side with verifyWebhook:
import { verifyWebhook } from '@chatcops/server';
const valid = await verifyWebhook({ body: rawBody, signature: request.headers.get('X-ChatCops-Signature'), secret: 'my-secret' });The body is { id, type, created, data }; id stays the same across retries. 429 and 5xx responses are retried, redirects aren't followed, and URLs must be https on a public host unless the webhook sets allowPrivateNetwork: true. See the server setup docs for the full format.
By default, conversations are held in memory (MemoryStore). You can persist conversations across server restarts or share them across instances by implementing ConversationStore from @chatcops/core:
import type { ConversationStore, Conversation } from '@chatcops/core';
class RedisConversationStore implements ConversationStore {
constructor(private redis: RedisClient) {}
async get(id: string): Promise<Conversation | null> {
const data = await this.redis.get(`conversation:${id}`);
return data ? JSON.parse(data) : null;
}
async save(conversation: Conversation): Promise<void> {
await this.redis.set(`conversation:${conversation.id}`, JSON.stringify(conversation));
}
async delete(id: string): Promise<void> {
await this.redis.del(`conversation:${id}`);
}
}Pass the store when initializing the handler:
const handler = createChatHandler({
provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY },
systemPrompt: 'You are a helpful assistant.',
storage: { conversations: new RedisConversationStore(redisClient) },
allowAnonymous: true,
});For a runtime with the Fetch API's Request and Response (Next.js, Hono, Bun, Deno, Workers), pass requests to chatcopsVercelHandler(config): it's a plain (Request) => Promise<Response>. For Node's req and res (node:http, Fastify, Koa), turn the request into a Request and stream the Response back. The docs have the code: Next.js, Hono, Bun and Deno. Route both the chat path and <chat path>/session to it, since the widget gets its session token there.
The handler does CORS, allowedOrigins, body limits, pings, session tokens and the 0.5 format for old widgets, so bridging to it is better than calling createChatHandler and writing the HTTP side yourself.
MIT