This commit is contained in:
2026-05-11 22:27:54 -04:00
commit ea05c521c3
14 changed files with 3791 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.netlify
.env
.env.local
.DS_Store
+47
View File
@@ -0,0 +1,47 @@
# PMC Funder Discovery Tool
A React + Vite frontend (Tailwind, Noto Serif) that collects public media
station details and streams an Anthropic Managed Agent response back to the
browser through a Netlify Functions v2 proxy.
## Setup
```bash
npm install
```
Add your Anthropic credentials (in `.env` for local dev, in the Netlify UI
for production):
```
ANTHROPIC_API_KEY=sk-ant-...
# Optional - if you've provisioned a Managed Agent in the Anthropic console:
ANTHROPIC_AGENT_ID=agent_...
# Optional - override the model:
ANTHROPIC_MODEL=claude-sonnet-4-5
```
## Run locally
```bash
npm run dev # netlify dev (proxies Vite + functions on :8888)
```
Open http://localhost:8888.
## How streaming works
1. The browser POSTs the form to `/.netlify/functions/agent-proxy`.
2. The Netlify v2 function calls `anthropic.messages.stream(...)` and wraps
the upstream iterator in a `ReadableStream`. Each
`content_block_delta` text chunk is enqueued as plain UTF8 bytes.
3. The React app reads `response.body.getReader()` and decodes chunks with
`TextDecoder`, appending them to the result state.
4. A `Thinking` flag stays `true` until the first chunk arrives, then flips
to a streaming state with a pulsing cursor.
## Files
- [src/App.jsx](src/App.jsx) — form, streaming reader, UI states.
- [netlify/functions/agent-proxy.js](netlify/functions/agent-proxy.js) —
Managed Agent proxy with `ReadableStream`.
+94
View File
@@ -0,0 +1,94 @@
Implementation Plan: React + Anthropic Managed Agent (Streaming)
1. Project Overview
Build a React frontend that collects user and public media station data. This data is sent to a Netlify Serverless Function (v2), which proxies a request to an Anthropic Managed Agent. The response must be streamed to bypass Netlify's 26-second execution limit and provide real-time UI feedback.
2. Technical Requirements
Frontend: React (Vite/CRA), Tailwind CSS (for UI).
Backend: Netlify Functions v2 (ESM).
API: Anthropic SDK (specifically using the Agent/Messages streaming endpoint).
State Management: React useState and ReadableStream API.
3. Step-by-Step Task List
Step 1: React Form Component
Create a form to capture the following fields:
userName (string)
userEmail (string)
stationName (string)
stationLocation (string)
stationWebsite (url)
Logic: Use a handleSubmit function that prevents default behavior and initializes the streaming fetch request.
Step 2: Netlify Function (The Proxy)
Create a file at netlify/functions/agent-proxy.js.
Configuration: Use Netlify v2 syntax (export default async (req, context) => { ... }).
Anthropic Integration: Initialize the Anthropic client with an environment variable ANTHROPIC_API_KEY.
Agent Call: Use the Anthropic Messages API, targeting the specific Agent ID (if using a pre-configured Managed Agent) or passing the system prompt that defines the agent's persona.
Stream Implementation: * Use anthropic.messages.stream.
Wrap the output in a ReadableStream.
Return a Response object with headers: { "Content-Type": "text/plain; charset=utf-8" }.
Step 3: Frontend Streaming Logic
Implement a readStream function inside the React component:
Use fetch() to call /.netlify/functions/agent-proxy.
Access response.body.getReader().
Use a while(true) loop to read chunks.
Update a result state variable by appending new chunks: setResult(prev => prev + chunk).
Step 4: UI/UX Indicators
Initial Loading: Display a "Connecting to Agent..." spinner immediately upon submission.
Thinking State: Show a "Claude is thinking..." status until the first byte of data is received.
Streaming State: Render the incoming markdown/text in a dedicated container. Use a "pulse" icon at the end of the text to indicate the stream is still active.
4. Specific Prompt for Code Generation
When you hand this to an LLM, use the following prompt:
"Please generate the code for a React application and a Netlify v2 Function based on the provided plan.
Ensure the Netlify function uses the ReadableStream API to pipe Anthropic's content_block_delta events directly to the frontend.
In the React app, use a TextDecoder to handle the stream chunks.
Add a 'Thinking' state that is true while waiting for the first chunk and false once data starts flowing.
Ensure the Anthropic call is configured for a Managed Agent workflow (include a placeholder for the Agent ID or System Prompt)."
5. Architectural Diagram
The following diagram illustrates how the streaming data flows from Anthropic through the serverless "pipe" to your user's screen without hitting the timeout wall.
6. Security & Configuration
Environment Variables: Remind the developer to set ANTHROPIC_API_KEY in the Netlify UI.
CORS: Ensure the Netlify function handles the request within the same origin to avoid CORS issues.
Validation: Add basic regex validation for the station's website URL before sending it to the API.
Implementation Note for the Managed Agent
Since "Managed Agents" in Anthropic often involve specific tools or long-running threads, the code should be prepared to handle tool_use blocks or input_json if the agent needs to "call back" for more information about the station.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
<title>PMC Funder Tool</title>
</head>
<body class="font-serif">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
[build]
command = "npm run build"
publish = "dist"
functions = "netlify/functions"
[functions]
node_bundler = "esbuild"
[dev]
framework = "vite"
targetPort = 5173
port = 8888
+158
View File
@@ -0,0 +1,158 @@
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',
}
+3006
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pmc-funder-tool",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "netlify dev",
"vite": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+261
View File
@@ -0,0 +1,261 @@
import { useState, useRef, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const URL_REGEX =
/^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i
const initialForm = {
userName: '',
userEmail: '',
stationName: '',
stationLocation: '',
stationWebsite: '',
}
export default function App() {
const [form, setForm] = useState(initialForm)
const [errors, setErrors] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isThinking, setIsThinking] = useState(false)
const [isStreaming, setIsStreaming] = useState(false)
const [result, setResult] = useState('')
const [streamError, setStreamError] = useState(null)
const resultRef = useRef(null)
useEffect(() => {
if (resultRef.current) {
resultRef.current.scrollTop = resultRef.current.scrollHeight
}
}, [result])
const handleChange = (e) => {
const { name, value } = e.target
setForm((prev) => ({ ...prev, [name]: value }))
}
const validate = () => {
const next = {}
if (!form.userName.trim()) next.userName = 'Required'
if (!form.userEmail.trim() || !/^\S+@\S+\.\S+$/.test(form.userEmail))
next.userEmail = 'Valid email required'
if (!form.stationName.trim()) next.stationName = 'Required'
if (!form.stationLocation.trim()) next.stationLocation = 'Required'
if (!form.stationWebsite.trim() || !URL_REGEX.test(form.stationWebsite))
next.stationWebsite = 'Valid URL required'
setErrors(next)
return Object.keys(next).length === 0
}
const readStream = async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let firstChunk = true
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
if (firstChunk) {
// Once data starts flowing, flip 'thinking' off and 'streaming' on.
setIsThinking(false)
setIsStreaming(true)
firstChunk = false
}
setResult((prev) => prev + chunk)
}
// Flush any remaining buffered bytes
const tail = decoder.decode()
if (tail) setResult((prev) => prev + tail)
setIsStreaming(false)
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!validate()) return
setResult('')
setStreamError(null)
setIsSubmitting(true)
setIsThinking(true)
setIsStreaming(false)
try {
const response = await fetch('/.netlify/functions/agent-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!response.ok || !response.body) {
throw new Error(
`Request failed: ${response.status} ${response.statusText}`,
)
}
await readStream(response)
} catch (err) {
console.error(err)
setStreamError(err.message || 'Something went wrong while streaming.')
} finally {
setIsSubmitting(false)
setIsThinking(false)
setIsStreaming(false)
}
}
return (
<div className="min-h-screen w-full font-serif">
<div className="mx-auto max-w-3xl px-6 py-12">
<header className="mb-10">
<h1 className="text-4xl font-semibold tracking-tight">
PMC Funder Discovery Tool
</h1>
<p className="mt-2 text-stone-600">
Tell us about your public media station and we'll have an
Anthropic-powered agent draft a tailored funding outlook.
</p>
</header>
<form
onSubmit={handleSubmit}
className="space-y-5 rounded-xl border border-stone-300 bg-white/70 p-6 shadow-sm"
>
<Field
label="Your Name"
name="userName"
value={form.userName}
onChange={handleChange}
error={errors.userName}
/>
<Field
label="Your Email"
name="userEmail"
type="email"
value={form.userEmail}
onChange={handleChange}
error={errors.userEmail}
/>
<Field
label="Station Name"
name="stationName"
value={form.stationName}
onChange={handleChange}
error={errors.stationName}
/>
<Field
label="Station Location"
name="stationLocation"
value={form.stationLocation}
onChange={handleChange}
error={errors.stationLocation}
/>
<Field
label="Station Website"
name="stationWebsite"
type="url"
placeholder="https://example.org"
value={form.stationWebsite}
onChange={handleChange}
error={errors.stationWebsite}
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-stone-900 px-4 py-3 text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{isSubmitting ? 'Working' : 'Generate Funding Brief'}
</button>
</form>
<section className="mt-10">
{isSubmitting && !isStreaming && (
<StatusBanner>
{isThinking ? (
<>
<Spinner /> Claude is thinking…
</>
) : (
<>
<Spinner /> Connecting to Agent…
</>
)}
</StatusBanner>
)}
{streamError && (
<div className="rounded-md border border-red-300 bg-red-50 p-4 text-red-800">
{streamError}
</div>
)}
{(result || isStreaming) && (
<article
ref={resultRef}
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{result}
</ReactMarkdown>
{isStreaming && <span className="cursor-pulse" />}
</article>
)}
</section>
</div>
</div>
)
}
function Field({ label, name, type = 'text', value, onChange, error, placeholder }) {
return (
<label className="block">
<span className="mb-1 block text-sm font-medium text-stone-800">
{label}
</span>
<input
type={type}
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
className={`w-full rounded-md border bg-white px-3 py-2 outline-none transition focus:ring-2 focus:ring-stone-700 ${
error ? 'border-red-400' : 'border-stone-300'
}`}
/>
{error && <span className="mt-1 block text-sm text-red-600">{error}</span>}
</label>
)
}
function StatusBanner({ children }) {
return (
<div className="mb-4 flex items-center gap-3 rounded-md border border-stone-300 bg-stone-100 px-4 py-3 text-stone-700">
{children}
</div>
)
}
function Spinner() {
return (
<svg
className="h-4 w-4 animate-spin text-stone-700"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
)
}
+126
View File
@@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family: 'Noto Serif', serif;
background-color: #f7f4ee;
color: #1f1b16;
}
@keyframes pulse-cursor {
0%,
100% {
opacity: 0.2;
}
50% {
opacity: 1;
}
}
.cursor-pulse {
display: inline-block;
width: 0.5rem;
height: 1.1rem;
margin-left: 0.25rem;
background: currentColor;
vertical-align: middle;
animation: pulse-cursor 1s infinite ease-in-out;
}
/* ---- Markdown rendering for streamed agent output ---- */
.markdown {
font-family: 'Noto Serif', serif;
color: #1f1b16;
word-wrap: break-word;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
font-weight: 600;
line-height: 1.25;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.markdown h1 { font-size: 1.875rem; }
.markdown h2 { font-size: 1.5rem; }
.markdown h3 { font-size: 1.25rem; }
.markdown h4 { font-size: 1.1rem; }
.markdown h1:first-child,
.markdown h2:first-child,
.markdown h3:first-child {
margin-top: 0;
}
.markdown p {
margin: 0.75em 0;
}
.markdown ul,
.markdown ol {
margin: 0.75em 0;
padding-left: 1.5em;
}
.markdown ul { list-style: disc; }
.markdown ol { list-style: decimal; }
.markdown li {
margin: 0.25em 0;
}
.markdown li > p { margin: 0.25em 0; }
.markdown a {
color: #1d4ed8;
text-decoration: underline;
}
.markdown a:hover { color: #1e40af; }
.markdown strong { font-weight: 600; }
.markdown em { font-style: italic; }
.markdown blockquote {
border-left: 3px solid #d6d3d1;
padding-left: 1em;
color: #57534e;
margin: 1em 0;
font-style: italic;
}
.markdown code {
background: #f5f5f4;
padding: 0.1em 0.35em;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
.markdown pre {
background: #1f1b16;
color: #f5f5f4;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: 0.875em;
}
.markdown hr {
border: none;
border-top: 1px solid #d6d3d1;
margin: 1.5em 0;
}
.markdown table {
border-collapse: collapse;
margin: 1em 0;
width: 100%;
}
.markdown th,
.markdown td {
border: 1px solid #d6d3d1;
padding: 0.5em 0.75em;
text-align: left;
}
.markdown th {
background: #f5f5f4;
font-weight: 600;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
fontFamily: {
serif: ['"Noto Serif"', 'serif'],
},
},
},
plugins: [],
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
})