Files
pmc-funder-tool/netlify/functions/agent-proxy.js
T
2026-05-11 22:27:54 -04:00

159 lines
5.4 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'
// ---------------------------------------------------------------------------
// 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 })
}
if (!process.env.ANTHROPIC_API_KEY) {
return new Response('Server is missing ANTHROPIC_API_KEY', { status: 500 })
}
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
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: 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',
}