From a9cab72426a4d6719bf6fa2bfea1c1da1cb4beda Mon Sep 17 00:00:00 2001 From: Ezra Date: Wed, 4 Mar 2026 11:45:09 -0800 Subject: [PATCH] feat: add POST /api/v1/chat/async endpoint for fire-and-forget messages (#329) --- src/api/server.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++++- src/api/types.ts | 11 +++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/api/server.ts b/src/api/server.ts index 0d655b3..dc7fdcd 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -6,7 +6,7 @@ import * as http from 'http'; import * as fs from 'fs'; import { validateApiKey } from './auth.js'; -import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js'; +import type { SendMessageRequest, SendMessageResponse, SendFileResponse, ChatRequest, ChatResponse, AsyncChatResponse, PairingListResponse, PairingApproveRequest, PairingApproveResponse } from './types.js'; import { listPairingRequests, approvePairingCode } from '../pairing/store.js'; import { parseMultipart } from './multipart.js'; import type { AgentRouter } from '../core/interfaces.js'; @@ -226,6 +226,77 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): return; } + // Route: POST /api/v1/chat/async (fire-and-forget: returns 202, processes in background) + if (req.url === '/api/v1/chat/async' && 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; + } + + 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] Async chat request for agent "${resolvedName}": ${chatReq.message.slice(0, 100)}...`); + + // Return 202 immediately + const asyncRes: AsyncChatResponse = { + success: true, + status: 'queued', + agentName: resolvedName, + }; + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(asyncRes)); + + // Process in background (detached promise) + const context = { type: 'webhook' as const, outputMode: 'silent' as const }; + deliverer.sendToAgent(agentName, chatReq.message, context).catch((error: any) => { + console.error(`[API] Async chat background error for agent "${resolvedName}":`, error); + }); + } catch (error: any) { + console.error('[API] Async chat error:', error); + const asyncRes: AsyncChatResponse = { + success: false, + status: 'error', + error: error.message || 'Internal server error', + }; + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(asyncRes)); + } + return; + } + // Route: GET /api/v1/pairing/:channel - List pending pairing requests const pairingListMatch = req.url?.match(/^\/api\/v1\/pairing\/([a-z0-9-]+)$/); if (pairingListMatch && req.method === 'GET') { diff --git a/src/api/types.ts b/src/api/types.ts index b9b3051..d6bf018 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -49,6 +49,17 @@ export interface ChatResponse { error?: string; } +/** + * POST /api/v1/chat/async - Fire-and-forget message to the agent + * Returns 202 immediately; agent processes in background. + */ +export interface AsyncChatResponse { + success: boolean; + status: 'queued' | 'error'; + agentName?: string; + error?: string; +} + /** * GET /api/v1/pairing/:channel - List pending pairing requests */