Skip to content

Latest commit

 

History

History

server

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@chatcops/server

Server-side handler and platform adapters for ChatCops. Handles streaming responses, rate limiting, webhooks, and analytics.

Install

npm install @chatcops/server @chatcops/core

Quick Start

Express

import express from 'express';
import { chatcopsMiddleware } from '@chatcops/server';

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

app.all('/chat', chatcopsMiddleware({
  provider: {
    type: 'claude',
    apiKey: process.env.ANTHROPIC_API_KEY,
  },
  systemPrompt: 'You are a helpful assistant for our website.',
  allowedOrigins: ['*'],
}));

app.listen(3001);

Vercel Edge

import { chatcopsVercelHandler } from '@chatcops/server';

const handler = chatcopsVercelHandler({
  provider: { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
  systemPrompt: 'You are a helpful assistant.',
  allowedOrigins: ['*'],
});

export const POST = (req: Request) => handler(req);
export const OPTIONS = (req: Request) => handler(req);
export const config = { runtime: 'edge' };

Cloudflare Workers

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: ['*'],
  });
  return handler;
}

export default {
  async fetch(request: Request, env: Env) {
    return getHandler(env)(request);
  },
};

Configuration

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)
  conversationStore: new RedisConversationStore(redisClient),

  // CORS origin (optional, default: '*')
  allowedOrigins: ['*'],
});

SSE Stream Format

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.

Rate Limiting

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 }

Webhooks

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.

Custom Conversation Store

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.',
  conversationStore: new RedisConversationStore(redisClient),
});

Custom integrations

createChatHandler(config) returns handleChat(body, clientIp, options), an async generator of SSE data payloads. The built-in adapters use it; call it directly for other runtimes. Pass options.signal and abort it when the visitor disconnects: the provider request is cancelled, nothing more is yielded, and the partial reply isn't saved.

const abort = new AbortController();
const stream = new ReadableStream({
  async start(controller) {
    for await (const chunk of handler.handleChat(body, clientIp, { signal: abort.signal })) {
      if (abort.signal.aborted) break;
      controller.enqueue(new TextEncoder().encode(`data: ${chunk}\n\n`));
    }
    if (!abort.signal.aborted) controller.close();
  },
  cancel() {
    abort.abort(); // the runtime cancels the response when the client goes away
  },
});

License

MIT