Files
lettabot/src/api/server.ts
Cameron deb1c4532a feat: add POST /api/v1/chat endpoint for agent messaging (#242)
* feat: add POST /api/v1/chat endpoint for sending messages to agents

Adds an HTTP endpoint that accepts a JSON message, sends it to the
lettabot agent via sendToAgent(), and returns the agent's response.
This enables external systems (e.g. server-side tools in other agents)
to communicate with lettabot programmatically.

- Add ChatRequest/ChatResponse types
- Add AgentRouter interface extending MessageDeliverer with sendToAgent()
- Implement AgentRouter on LettaGateway with agent-name routing
- Add POST /api/v1/chat route with auth, validation, and JSON body parsing

Written by Cameron ◯ Letta Code

"The most profound technologies are those that disappear." -- Mark Weiser

* feat: add SSE streaming support to /api/v1/chat endpoint

When the client sends Accept: text/event-stream, the chat endpoint
streams SDK messages as SSE events instead of waiting for the full
response. Each event is a JSON StreamMsg (assistant, tool_call,
tool_result, reasoning, result). The result event signals end-of-stream.

- Export StreamMsg type from bot.ts
- Add streamToAgent() to AgentSession interface and LettaBot
- Wire streamToAgent() through LettaGateway with agent-name routing
- Add SSE path in chat route (Accept header content negotiation)
- Handle client disconnect mid-stream gracefully

Written by Cameron ◯ Letta Code

"Any sufficiently advanced technology is indistinguishable from magic." -- Arthur C. Clarke

* test+docs: add chat endpoint tests and API documentation

- 10 tests for POST /api/v1/chat: auth, validation, sync response,
  agent routing, SSE streaming, stream error handling
- 6 tests for gateway sendToAgent/streamToAgent routing
- Fix timingSafeEqual crash on mismatched key lengths (return 401, not 500)
- Document chat endpoint in configuration.md with sync and SSE examples
- Add Chat API link to docs/README.md index

Written by Cameron ◯ Letta Code

"First, solve the problem. Then, write the code." -- John Johnson
2026-02-09 16:53:31 -08:00

303 lines
9.6 KiB
TypeScript

/**
* HTTP API server for LettaBot
* Provides endpoints for CLI to send messages across Docker boundaries
*/
import * as http from 'http';
import * as fs from 'fs';
import { validateApiKey } from './auth.js';
import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse } from './types.js';
import { parseMultipart } from './multipart.js';
import type { AgentRouter } from '../core/interfaces.js';
import type { ChannelId } from '../core/types.js';
const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal'];
const MAX_BODY_SIZE = 10 * 1024; // 10KB
const MAX_TEXT_LENGTH = 10000; // 10k chars
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
interface ServerOptions {
port: number;
apiKey: string;
host?: string; // Bind address (default: 127.0.0.1 for security)
corsOrigin?: string; // CORS origin (default: same-origin only)
}
/**
* Create and start the HTTP API server
*/
export function createApiServer(deliverer: AgentRouter, options: ServerOptions): http.Server {
const server = http.createServer(async (req, res) => {
// Set CORS headers (configurable origin, defaults to same-origin for security)
const corsOrigin = options.corsOrigin || req.headers.origin || 'null';
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
// Handle OPTIONS preflight
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// Route: GET /health or GET /
if ((req.url === '/health' || req.url === '/') && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
return;
}
// Route: POST /api/v1/messages (unified: supports both text and files)
if (req.url === '/api/v1/messages' && req.method === 'POST') {
try {
// Validate authentication
if (!validateApiKey(req.headers, options.apiKey)) {
sendError(res, 401, 'Unauthorized');
return;
}
const contentType = req.headers['content-type'] || '';
// Parse multipart/form-data (supports both text-only and file uploads)
if (!contentType.includes('multipart/form-data')) {
sendError(res, 400, 'Content-Type must be multipart/form-data');
return;
}
// Parse multipart data
const { fields, files } = await parseMultipart(req, MAX_FILE_SIZE);
// Validate required fields
if (!fields.channel || !fields.chatId) {
sendError(res, 400, 'Missing required fields: channel, chatId');
return;
}
if (!VALID_CHANNELS.includes(fields.channel as ChannelId)) {
sendError(res, 400, `Invalid channel: ${fields.channel}`, 'channel');
return;
}
// Validate that either text or file is provided
if (!fields.text && files.length === 0) {
sendError(res, 400, 'Either text or file must be provided');
return;
}
const file = files.length > 0 ? files[0] : undefined;
// Send via unified deliverer method
const messageId = await deliverer.deliverToChannel(
fields.channel as ChannelId,
fields.chatId,
{
text: fields.text,
filePath: file?.tempPath,
kind: fields.kind as 'image' | 'file' | undefined,
}
);
// Cleanup temp file if any
if (file) {
try {
fs.unlinkSync(file.tempPath);
} catch (err) {
console.warn('[API] Failed to cleanup temp file:', err);
}
}
// Success response
const response: SendMessageResponse = {
success: true,
messageId,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
} catch (error: any) {
console.error('[API] Error handling request:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// Route: POST /api/v1/chat (send a message to the agent, get response)
if (req.url === '/api/v1/chat' && req.method === 'POST') {
try {
if (!validateApiKey(req.headers, options.apiKey)) {
sendError(res, 401, 'Unauthorized');
return;
}
const contentType = req.headers['content-type'] || '';
if (!contentType.includes('application/json')) {
sendError(res, 400, 'Content-Type must be application/json');
return;
}
const body = await readBody(req, MAX_BODY_SIZE);
let chatReq: ChatRequest;
try {
chatReq = JSON.parse(body);
} catch {
sendError(res, 400, 'Invalid JSON body');
return;
}
if (!chatReq.message || typeof chatReq.message !== 'string') {
sendError(res, 400, 'Missing required field: message');
return;
}
if (chatReq.message.length > MAX_TEXT_LENGTH) {
sendError(res, 400, `Message too long (max ${MAX_TEXT_LENGTH} chars)`);
return;
}
// Resolve agent name (defaults to first agent)
const agentName = chatReq.agent;
const agentNames = deliverer.getAgentNames();
const resolvedName = agentName || agentNames[0];
if (agentName && !agentNames.includes(agentName)) {
sendError(res, 404, `Agent not found: ${agentName}. Available: ${agentNames.join(', ')}`);
return;
}
console.log(`[API] Chat request for agent "${resolvedName}": ${chatReq.message.slice(0, 100)}...`);
const context = { type: 'webhook' as const, outputMode: 'silent' as const };
const wantsStream = (req.headers.accept || '').includes('text/event-stream');
if (wantsStream) {
// SSE streaming: forward SDK stream chunks as events
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
let clientDisconnected = false;
req.on('close', () => { clientDisconnected = true; });
try {
for await (const msg of deliverer.streamToAgent(agentName, chatReq.message, context)) {
if (clientDisconnected) break;
res.write(`data: ${JSON.stringify(msg)}\n\n`);
if (msg.type === 'result') break;
}
} catch (streamError: any) {
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ type: 'error', error: streamError.message })}\n\n`);
}
}
res.end();
} else {
// Sync: wait for full response
const response = await deliverer.sendToAgent(agentName, chatReq.message, context);
const chatRes: ChatResponse = {
success: true,
response,
agentName: resolvedName,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(chatRes));
}
} catch (error: any) {
console.error('[API] Chat error:', error);
const chatRes: ChatResponse = {
success: false,
error: error.message || 'Internal server error',
};
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(chatRes));
}
return;
}
// Route: 404 Not Found
sendError(res, 404, 'Not found');
});
// Bind to localhost by default for security (prevents network exposure on bare metal)
// Use API_HOST=0.0.0.0 in Docker to expose on all interfaces
const host = options.host || '127.0.0.1';
server.listen(options.port, host, () => {
console.log(`[API] Server listening on ${host}:${options.port}`);
});
return server;
}
/**
* Read request body with size limit
*/
function readBody(req: http.IncomingMessage, maxSize: number): Promise<string> {
return new Promise((resolve, reject) => {
let body = '';
let size = 0;
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > maxSize) {
reject(new Error(`Request body too large (max ${maxSize} bytes)`));
return;
}
body += chunk.toString();
});
req.on('end', () => {
resolve(body);
});
req.on('error', (error) => {
reject(error);
});
});
}
/**
* Validate send message request
*/
function validateRequest(request: SendMessageRequest): { message: string; field?: string } | null {
if (!request.channel) {
return { message: 'Missing required field: channel', field: 'channel' };
}
if (!request.chatId) {
return { message: 'Missing required field: chatId', field: 'chatId' };
}
if (!request.text) {
return { message: 'Missing required field: text', field: 'text' };
}
if (!VALID_CHANNELS.includes(request.channel as ChannelId)) {
return { message: `Invalid channel: ${request.channel}`, field: 'channel' };
}
if (typeof request.text !== 'string') {
return { message: 'Field "text" must be a string', field: 'text' };
}
if (request.text.length > MAX_TEXT_LENGTH) {
return { message: `Text too long (max ${MAX_TEXT_LENGTH} chars)`, field: 'text' };
}
return null;
}
/**
* Send error response
*/
function sendError(res: http.ServerResponse, status: number, message: string, field?: string): void {
const response: SendMessageResponse = {
success: false,
error: message,
field,
};
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
}