import Anthropic from '@anthropic-ai/sdk' /** * Netlify Functions v2 handler. * * Proxies a request from the React frontend to a Claude Console-defined * Managed Agent via the /v1/sessions endpoints. The agent's model, system * prompt, tools, MCP servers, and skills are configured in the Console and * referenced here by ID. We open the SSE event stream first, then send a * `user.message` event, and pipe each `agent.message` text block to the * client through a ReadableStream so we never hit Netlify's 26-second * synchronous execution limit. */ // All Managed Agents endpoints require this beta header. const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01' // --------------------------------------------------------------------------- // Anthropic client (reused across warm Lambda invocations) // --------------------------------------------------------------------------- let _client function getClient() { if (_client) return _client if (!process.env.ANTHROPIC_API_KEY) { throw new Error('Server is missing ANTHROPIC_API_KEY') } _client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, defaultHeaders: { 'anthropic-beta': MANAGED_AGENTS_BETA }, }) return _client } // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- export default async (req /*, context */) => { if (req.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }) } let payload try { payload = await req.json() } catch { return new Response('Invalid JSON body', { status: 400 }) } const { stationName = '', stationLocation = '', stationWebsite = '', } = payload || {} // Server-side URL validation (mirrors the client-side regex). const URL_REGEX = /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i if (!URL_REGEX.test(stationWebsite)) { return new Response('Invalid station website URL', { status: 400 }) } // Read env vars at request time (Netlify makes them available per-invocation). const AGENT_ID = process.env.ANTHROPIC_AGENT_ID const ENVIRONMENT_ID = process.env.ANTHROPIC_ENVIRONMENT_ID const VAULT_IDS = (process.env.ANTHROPIC_VAULT_IDS || '') .split(',') .map((s) => s.trim()) .filter(Boolean) const missing = [] if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID') if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID') if (missing.length) { return new Response(`Server is missing env var(s): ${missing.join(', ')}`, { status: 500, }) } let client try { client = getClient() } catch (err) { return new Response( `Failed to initialize agent: ${err.message || err}`, { status: 500 }, ) } const userMessage = `Here is a new public media station intake: - Station Name: ${stationName} - Station Location: ${stationLocation} - Station Website: ${stationWebsite} Please follow your instructions to produce the funding outlook brief.` // ----- Create the session ----- let session try { const sessionParams = { agent: AGENT_ID, environment_id: ENVIRONMENT_ID, } if (VAULT_IDS.length) sessionParams.vault_ids = VAULT_IDS session = await client.beta.sessions.create(sessionParams) } catch (err) { return new Response( `Failed to create session: ${err.message || err}`, { status: 500 }, ) } // ----- Open the event stream BEFORE sending the user message ----- // (Stream-first ensures we don't miss any events the agent emits.) let upstream try { upstream = await client.beta.sessions.events.stream(session.id) } catch (err) { return new Response( `Failed to open session event stream: ${err.message || err}`, { status: 500 }, ) } // ----- Send the kickoff user.message event ----- try { await client.beta.sessions.events.send(session.id, { events: [ { type: 'user.message', content: [{ type: 'text', text: userMessage }], }, ], }) } catch (err) { return new Response( `Failed to send user message: ${err.message || err}`, { status: 500 }, ) } // ----- Pipe agent.message text blocks to the client ----- const encoder = new TextEncoder() const seenEventIds = new Set() const stream = new ReadableStream({ async start(controller) { try { for await (const event of upstream) { if (event.id) { if (seenEventIds.has(event.id)) { // Still need to evaluate terminal events even if seen. } else { seenEventIds.add(event.id) if (event.type === 'agent.message' && Array.isArray(event.content)) { for (const block of event.content) { if (block?.type === 'text' && block.text) { controller.enqueue(encoder.encode(block.text)) } } } else if (event.type === 'session.error') { const msg = event.error?.message || 'unknown session error' controller.enqueue(encoder.encode(`\n\n[session error: ${msg}]`)) } } } // Terminal-state gate. if (event.type === 'session.status_terminated') break if ( event.type === 'session.status_idle' && event.stop_reason?.type !== 'requires_action' ) { break } } controller.close() } catch (err) { controller.enqueue( encoder.encode(`\n\n[stream error: ${err.message || err}]`), ) controller.close() } }, cancel() { // If the client disconnects, abort the upstream stream. try { upstream.controller?.abort?.() } catch { /* ignore */ } }, }) return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no', }, }) } export const config = { path: '/.netlify/functions/agent-proxy', }