Files
pmc-funder-tool/netlify/functions/agent-proxy.js
T
2026-05-11 23:21:45 -04:00

230 lines
7.8 KiB
JavaScript

import Anthropic from '@anthropic-ai/sdk'
/**
* Netlify Functions v2 handler.
*
* Proxies a request from the React frontend to the Anthropic Messages API,
* configured for a Managed Agent workflow. We pipe `content_block_delta`
* events directly to the client through a ReadableStream so we never hit
* Netlify's 26-second synchronous execution limit.
*/
// ---------------------------------------------------------------------------
// Managed Agent configuration
// ---------------------------------------------------------------------------
// If you have provisioned a Managed Agent in the Anthropic console, set its
// ID here (or via the AGENT_ID env var). When AGENT_ID is present the call
// is routed to that agent. Otherwise we fall back to a system-prompt-driven
// "virtual agent" that defines the persona inline.
const AGENT_ID = process.env.ANTHROPIC_AGENT_ID || 'YOUR_AGENT_ID_HERE'
const SYSTEM_PROMPT = `You are the "PMC Funder Discovery Agent", an expert
fundraising consultant for U.S. public media stations (NPR/PBS member
stations, community radio, and independent public broadcasters).
Your job is to take a brief intake form about a public media station and
produce a tailored funding outlook that includes:
1. A short profile of the station based on the supplied details.
2. 3-5 likely foundation funders that match the station's mission and
geography (CPB, Knight, MacArthur, Wyncote, regional community
foundations, etc.).
3. 2-3 corporate underwriting angles relevant to the station's market.
4. Concrete next steps for the development director.
Write in clear, direct prose. Use Markdown headings and bullet lists. If
you need additional information, you may emit a tool_use block requesting
it; otherwise produce the brief in one pass. Avoid speculation about
specific dollar amounts.`
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-5'
// File uploaded to the Anthropic Console / Files API that the agent should
// consult on every call.
const KNOWLEDGE_FILE_ID =
process.env.ANTHROPIC_KNOWLEDGE_FILE_ID || 'file_011Cawv1SPNaknuMmyDtM1S7'
// ---------------------------------------------------------------------------
// Module-scoped singletons (reused across warm Lambda invocations)
// ---------------------------------------------------------------------------
// Netlify keeps the function process alive between invocations as long as it
// stays warm, so anything declared at module scope persists. We lazily build
// the Anthropic client and download the knowledge file exactly once per
// cold start, then reuse them for the lifetime of the container.
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,
// Required while the Files API is in beta.
defaultHeaders: { 'anthropic-beta': 'files-api-2025-04-14' },
})
return _client
}
// Cache the in-flight promise so concurrent requests during a cold start
// share a single download instead of fetching the CSV N times.
let _knowledgeFilePromise
function loadKnowledgeFile() {
if (_knowledgeFilePromise) return _knowledgeFilePromise
_knowledgeFilePromise = (async () => {
const resp = await getClient().beta.files.download(KNOWLEDGE_FILE_ID)
return await resp.text()
})().catch((err) => {
// Reset on failure so the next request can retry.
_knowledgeFilePromise = undefined
throw err
})
return _knowledgeFilePromise
}
// ---------------------------------------------------------------------------
// 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 {
userName = '',
userEmail = '',
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 })
}
let client
let knowledgeFileText
try {
client = getClient()
// First call after a cold start awaits the download; subsequent calls
// resolve immediately from the cached promise.
knowledgeFileText = await loadKnowledgeFile()
} catch (err) {
return new Response(
`Failed to initialize agent: ${err.message || err}`,
{ status: 500 },
)
}
const userMessage = `Here is a new public media station intake:
- Submitter Name: ${userName}
- Submitter Email: ${userEmail}
- Station Name: ${stationName}
- Station Location: ${stationLocation}
- Station Website: ${stationWebsite}
Please draft the funding outlook brief described in your instructions.`
// ----- Build the streaming request -----
// Note: when calling a Managed Agent, the Anthropic SDK accepts an
// `agent_id` field on the messages.stream() call. We include it
// conditionally so the same code path also works with a plain system
// prompt while you're wiring things up.
const streamArgs = {
model: MODEL,
max_tokens: 10000,
system: SYSTEM_PROMPT,
messages: [
{
role: 'user',
content: [
{
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: knowledgeFileText,
},
title: 'Funder knowledge base (CSV)',
citations: { enabled: true },
// Cache the tokenized CSV on Anthropic's side for ~5 minutes
// so repeat requests pay ~10% of input-token cost for it.
cache_control: { type: 'ephemeral' },
},
{ type: 'text', text: userMessage },
],
},
],
}
if (AGENT_ID && AGENT_ID !== 'YOUR_AGENT_ID_HERE') {
streamArgs.agent_id = AGENT_ID
}
const upstream = client.messages.stream(streamArgs)
// ----- Pipe content_block_delta events straight to the client -----
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
try {
for await (const event of upstream) {
if (
event.type === 'content_block_delta' &&
event.delta?.type === 'text_delta' &&
event.delta.text
) {
controller.enqueue(encoder.encode(event.delta.text))
}
// Surface an explicit message if the agent decides to call a tool.
// (Managed Agents may emit `input_json_delta` or `tool_use` blocks.)
if (
event.type === 'content_block_delta' &&
event.delta?.type === 'input_json_delta' &&
event.delta.partial_json
) {
controller.enqueue(
encoder.encode(
`\n\n_[agent requested tool input: ${event.delta.partial_json}]_\n\n`,
),
)
}
}
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 Anthropic request.
upstream.controller?.abort?.()
},
})
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',
}