AI Fantasy Football Trade Analyzer
Build a full-stack AI trade analyzer with React, Express, and Claude.
Introduction
30 Second Summary
Fantasy football trades are stressful. You never know if you're giving up too much or getting a steal, and asking your league group chat usually gets you ten conflicting opinions.
In this project, you will build a full-stack trade analyzer that sends player data to Claude and returns a structured analysis with a verdict, fairness score, reasoning, pros and cons, positional impact, and risk assessment.
What You'll Build
You'll enter any two-sided trade into your app, hit Analyze, and get back an instant AI-powered breakdown that tells you who wins, why, and whether the risk is worth it.
By the end of this project, you'll have:
- A trade input UI where you enter players into Giving and Receiving panels, add optional roster context for positional-need analysis, and select your league scoring format.
- A structured prompt engineering pipeline that forces Claude to return validated JSON instead of unparseable free-text prose.
- A full-stack TypeScript application with a React frontend and Express backend communicating through a REST API.
- Secret Mission: Extend the prompt to also return a persuasive trade negotiation message and ranked counteroffer suggestions in the same API call.
Are there any prerequisites?
This project assumes familiarity with React, TypeScript, and Express. You'll need Node.js 20+ installed and an Anthropic API key ready to go.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Monorepo
Your AI trade analyzer needs two halves: a React frontend where users enter player names and trade details, and an Express backend that sends those details to Claude. Keeping both in one project folder makes development simple.
In this step, you'll use Vite to scaffold the React frontend and manually configure an Express backend with the Anthropic SDK pre-installed.
In this step, get ready to:
- Scaffold a React + TypeScript frontend with Vite.
- Create an Express backend with the Anthropic SDK installed.
- Verify both dev servers start and can communicate.
Scaffold the frontend with Vite and create the backend folder
You need a terminal to run the scaffolding commands. The integrated terminal in VS Code keeps everything in one window.
- Press Cmd+` (macOS) or Ctrl+` (Windows) to open the integrated terminal in VS Code.
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir trade-analyzer
cd trade-analyzer
- Scaffold the React frontend by running this command:
npm create vite@latest client -- --template react-ts
What will Vite ask you?
Because you specified both the project name and template on the command line, Vite skips the framework and variant prompts. However, it may ask you to choose a linter (pick Oxlint) and whether to install and start now (choose No). You will install manually in the next step.
If you do not see these prompts, that is fine. The command created your client/ folder either way.
- Confirm the scaffold worked by running:
ls
You should see a client folder listed in the output.
Don't see a client folder?
- Make sure you included the double dash -- before --template. Without it, npm does not pass the flag to Vite.
- If you see a permission error, try running the command again. Sometimes npm needs to download create-vite first.
Why isn't my Vite scaffold command creating a project?
- Install the frontend dependencies by running these commands:
cd client
npm install
cd ..
You should see output confirming packages were added. You are now back in the trade-analyzer root folder.
Now you need the backend. You will create the server config files in VS Code, then install the backend packages.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the trade-analyzer folder, then click Open.
The VS Code Explorer sidebar now shows the client/ folder you just scaffolded.
- Create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
- Save it immediately with Cmd+S (macOS) or Ctrl+S (Windows) and name it server/package.json. VS Code creates the server/ directory for you.
- Paste the following content into server/package.json:
{
"name": "trade-analyzer-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "npx tsx --watch src/index.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"cors": "^2.8.6",
"express": "^5.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.23.5",
"typescript": "^5.6.0"
}
}
What does this file do?
- The "type": "module" field enables ES module import/export syntax instead of CommonJS require().
- The dev script uses tsx to run TypeScript directly without a separate compile step. The --watch flag auto-restarts on file changes.
- Three main dependencies: express (the web framework), cors (allows the frontend to call the backend), and @anthropic-ai/sdk (the Claude API client you will use in a later step).
- Save server/package.json.
- Create another new file and save it as server/tsconfig.json. Paste the following content:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true
},
"include": ["src"]
}
What does tsconfig.json control?
This tells TypeScript how to compile your code. The key setting is "strict": true which catches type errors before they become runtime bugs. The "moduleResolution": "bundler" setting matches the ES module style you enabled in package.json.
- Save server/tsconfig.json.
- Install all backend dependencies by running this command in the terminal:
cd server
npm install
You should see output listing the installed packages including express, cors, and @anthropic-ai/sdk.
Seeing npm install errors?
- Check that your server/package.json is valid JSON. A missing comma or bracket breaks the install.
- Make sure you ran cd server first. Running npm install from the wrong directory installs into the wrong node_modules.
Why is npm install failing in my server folder?
Configure the Express server with CORS and a health-check route
The backend needs an entry point that starts Express, enables cross-origin requests from the React app, and exposes a route you can test immediately.
- Create a new file and save it as server/src/index.ts. VS Code creates the src/ subdirectory automatically.
- Paste the following code into server/src/index.ts:
import express from "express";
import cors from "cors";
// Create the Express app and set the port
const app = express();
const port = 3001;
// Allow requests from the Vite dev server
app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());
// Health-check route to verify the server is running
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
// Start listening for requests
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
What does this code do?
- The cors({ origin: "http://localhost:5173" }) line tells Express to accept requests from your React dev server. Without this, browser security blocks the frontend from calling the backend.
- The /health route returns a simple JSON object. This gives you a quick way to confirm the server is alive.
- The express.json() middleware parses incoming JSON request bodies. You will need this when the frontend sends trade data in later steps.
- Save server/src/index.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three server files: package.json, tsconfig.json, and src/index.ts.
ⓧ I'd like to double check the full code
Here is the complete server/src/index.ts file:
import express from "express";
import cors from "cors";
const app = express();
const port = 3001;
app.use(cors({ origin: "http://localhost:5173" }));
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Run both dev servers and verify they work
You need two terminal sessions: one for the React frontend, one for the Express backend. They run simultaneously so you can develop both sides at once.
- In the VS Code terminal, navigate back to the project root by running:
cd ..
- Start the React dev server by running these commands:
cd client
npm run dev
Vite prints a URL (http://localhost:5173) in the terminal. Open it in your browser. You should see the default Vite + React welcome page.
- Open a second terminal panel by clicking the + icon in the terminal toolbar at the top right of the terminal panel.
- Start the Express backend by running these commands in the new terminal:
cd ~/Desktop/trade-analyzer/server
npm run dev
You should see the message Server running on port 3001 printed in this terminal.
Before you test the health route, what do you think the response will look like when you hit /health?
- Open a third terminal panel with the + icon and test the health endpoint by running:
curl http://localhost:3001/health
You should see {"status":"ok"} printed in the terminal. Both servers are running and the backend responds to requests.
Server not responding?
- Check that you see Server running on port 3001 in the second terminal. If the server crashed, look for a syntax error in server/src/index.ts.
- If curl returns "connection refused", confirm you are running the command from the third terminal. The server terminal must stay open.
- On Windows, if curl is not recognized, open http://localhost:3001/health in your browser instead. You should see the JSON response.
Why is my Express server not starting or not responding to curl?
Your monorepo is up and running. Next up, you will build the trade input UI so users can enter players on each side of a trade.
Build the Trade Input UI
Your servers are running and healthy. Now you need a way to capture trade data before sending it to the AI.
In this step, you will build the form that lets you enter players on each side of a trade, provide optional roster context, and select your league scoring format. This is the baby version of the app: a working input layer that collects everything Claude needs to analyze a trade.
In this step, get ready to:
- Define TypeScript types for trade input and analysis response.
- Build a reusable PlayerPanel component for entering and removing players.
- Assemble the main TradeAnalyzer component with two panels, roster context, league selector, and styles.
Define TypeScript types for trade input and analysis response
Before writing any UI code, you need types that describe the shape of data flowing through the app. One interface defines what goes INTO the analysis (players, league format, roster context). The other defines what comes BACK from Claude (verdict, score, reasoning, and more).
- In VS Code, create a new file at client/src/types.ts and add the following type definitions:
export interface TradeInput {
givingPlayers: string[];
receivingPlayers: string[];
leagueFormat: "PPR" | "Half-PPR" | "Standard";
myRoster?: string;
theirRoster?: string;
}
export interface PositionalImpact {
giving: string;
receiving: string;
}
export interface TradeAnalysis {
verdict: string;
fairnessScore: number;
reasoning: string;
pros: string[];
cons: string[];
positionalImpact: PositionalImpact;
riskLevel: "Low" | "Medium" | "High";
}
What do these types do?
TradeInput defines what the user submits: the players they are giving and receiving, the league scoring format, and optional roster context for positional-need analysis.
TradeAnalysis defines what Claude returns: a verdict, a fairness score (1-10), reasoning, pros/cons, positional impact, and a risk level. Every field is typed so the frontend knows exactly what to render.
- Save client/src/types.ts.
- Check the terminal running your Vite dev server. It should show no errors.
Build the PlayerPanel component for entering players
Each side of a trade needs its own input panel where you can add player names and remove them. You will build a reusable PlayerPanel component that accepts a title ("Giving" or "Receiving") and manages its own input field.
- Create the folder client/src/components/ if it does not exist.
- Create a new file at client/src/components/PlayerPanel.tsx and add the component logic:
import { useState } from "react";
interface PlayerPanelProps {
title: string;
players: string[];
onChange: (players: string[]) => void;
}
export function PlayerPanel({ title, players, onChange }: PlayerPanelProps) {
const [inputValue, setInputValue] = useState("");
const addPlayer = () => {
const trimmed = inputValue.trim();
if (trimmed) {
onChange([...players, trimmed]);
setInputValue("");
}
};
const removePlayer = (index: number) => {
onChange(players.filter((_, i) => i !== index));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
addPlayer();
}
};
What does this code do?
PlayerPanelProps defines the component's contract: a title string, the current list of players, and a callback to update that list.
addPlayer trims whitespace and appends a new name. removePlayer filters out the player at the given index. handleKeyDown lets the user press Enter to add a player instead of clicking the button.
- Now add the JSX return statement directly below the handleKeyDown function:
return (
<div className="player-panel">
<h2>{title}</h2>
<div className="input-row">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter player name"
/>
<button onClick={addPlayer} disabled={!inputValue.trim()}>
Add
</button>
</div>
<ul className="player-list">
{players.map((player, index) => (
<li key={index}>
<span>{player}</span>
<button onClick={() => removePlayer(index)}>Remove</button>
</li>
))}
</ul>
</div>
);
}
What does this JSX render?
The return renders a panel with a heading (the title prop), an input row with an Add button that disables when the input is empty, and an unordered list of player names. Each player in the list gets a Remove button next to it.
- Save client/src/components/PlayerPanel.tsx.
- Check the Vite dev server terminal. It should still show no errors.
Seeing a TypeScript error in the terminal?
Make sure the closing } of the function appears at the very end of the file (after the return's closing parenthesis). A missing brace is the most common cause of parse errors here.
help me fix a TypeScript error in my PlayerPanel component
Build the main TradeAnalyzer component with panels, roster context, and league selector
The TradeAnalyzer component is the orchestrator. It holds all the state (players, league format, roster context) and renders the full form layout.
- Create a new file at client/src/components/TradeAnalyzer.tsx and add the imports and state declarations:
import { useState } from "react";
import { PlayerPanel } from "./PlayerPanel";
import type { TradeAnalysis, TradeInput } from "../types";
export function TradeAnalyzer() {
const [givingPlayers, setGivingPlayers] = useState<string[]>([]);
const [receivingPlayers, setReceivingPlayers] = useState<string[]>([]);
const [leagueFormat, setLeagueFormat] =
useState<TradeInput["leagueFormat"]>("PPR");
const [myRoster, setMyRoster] = useState("");
const [theirRoster, setTheirRoster] = useState("");
const [showRosterContext, setShowRosterContext] = useState(false);
const [analysis, setAnalysis] = useState<TradeAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const canAnalyze =
givingPlayers.length > 0 && receivingPlayers.length > 0 && !loading;
What does this state manage?
Each piece of state maps to one input the user controls: the players on each side, the league format dropdown, the optional roster text areas, and a toggle for showing/hiding roster context.
canAnalyze is a derived boolean that enables the Analyze button only when both panels have at least one player and no request is in progress.
- Add the JSX return statement directly below canAnalyze:
return (
<div className="trade-analyzer">
<h1>Fantasy Football Trade Analyzer</h1>
<div className="panels">
<PlayerPanel title="Giving" players={givingPlayers} onChange={setGivingPlayers} />
<PlayerPanel title="Receiving" players={receivingPlayers} onChange={setReceivingPlayers} />
</div>
<div className="roster-context">
<button className="toggle-roster" onClick={() => setShowRosterContext(!showRosterContext)}>
{showRosterContext ? "Hide" : "Show"} Roster Context (optional)
</button>
{showRosterContext && (
<div className="roster-fields">
<div>
<label>My Roster (comma-separated)</label>
<textarea value={myRoster} onChange={(e) => setMyRoster(e.target.value)} placeholder="e.g., Josh Allen, Saquon Barkley..." />
</div>
<div>
<label>Their Roster (comma-separated)</label>
<textarea value={theirRoster} onChange={(e) => setTheirRoster(e.target.value)} placeholder="e.g., Lamar Jackson, Derrick Henry..." />
</div>
</div>
)}
</div>
What does this JSX render?
Two PlayerPanel components sit side by side in a grid. Below them, a toggle button shows or hides the roster context section with two text areas for entering each team's remaining roster.
- Add the controls section and closing tags directly below the roster context </div>:
<div className="controls">
<label>
League Format:
<select value={leagueFormat} onChange={(e) => setLeagueFormat(e.target.value as TradeInput["leagueFormat"])}>
<option value="PPR">PPR</option>
<option value="Half-PPR">Half-PPR</option>
<option value="Standard">Standard</option>
</select>
</label>
<button disabled={!canAnalyze}>
{loading ? "Analyzing..." : "Analyze Trade"}
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
);
}
What does this section do?
The controls section renders a league format dropdown (PPR, Half-PPR, Standard) and the Analyze Trade button. The button stays disabled until both panels have at least one player. The error div only renders if an error message exists in state.
- Save client/src/components/TradeAnalyzer.tsx.
Now wire the TradeAnalyzer into your app's root component.
- Open client/src/App.tsx and replace its entire contents with:
import { TradeAnalyzer } from "./components/TradeAnalyzer";
import "./App.css";
function App() {
return (
<div className="app">
<TradeAnalyzer />
</div>
);
}
export default App;
- Save client/src/App.tsx.
- Check your browser at localhost:5173. You should see an unstyled page with a "Fantasy Football Trade Analyzer" heading, two panels labeled "Giving" and "Receiving" with input fields, a "Show Roster Context" button, a league format dropdown, and a disabled "Analyze Trade" button.
Browser showing a blank page or error?
Check the Vite terminal for errors. The most common issue is a missing import. Make sure TradeAnalyzer.tsx imports PlayerPanel from "./PlayerPanel" and the types from "../types".
help me fix why my Trade Analyzer page is blank or showing an error
The UI works but looks rough without styling. Time to add the CSS.
- Open client/src/App.css and replace its entire contents with the layout and panel styles:
.app {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
.trade-analyzer h1 {
text-align: center;
margin-bottom: 2rem;
}
.panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 1.5rem;
}
.player-panel {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
.player-panel h2 {
margin-top: 0;
}
What do these styles do?
The .app class centers the content at 900px max width. .panels uses CSS Grid to place the two player panels side by side. Each .player-panel gets a border and padding to look like a card.
- Below the .player-panel h2 block, add the input and player list styles:
.input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.input-row input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.input-row button {
padding: 0.5rem 1rem;
background: #4a90d9;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.input-row button:disabled {
background: #ccc;
cursor: not-allowed;
}
.player-list {
list-style: none;
padding: 0;
}
- Save App.css and check your browser. The panels should now display as two cards side by side with a blue Add button in each.
- Continue adding styles below for the player list items and roster context:
.player-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
.player-list li button {
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
}
.roster-context {
margin-bottom: 1.5rem;
}
.toggle-roster {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
color: #555;
}
- Add the roster fields and controls styles next:
.roster-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
.roster-fields label {
display: block;
font-size: 0.85rem;
color: #555;
margin-bottom: 0.25rem;
}
.roster-fields textarea {
width: 100%;
min-height: 60px;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
font-family: inherit;
}
.controls {
display: flex;
align-items: center;
gap: 1rem;
justify-content: center;
margin-bottom: 2rem;
}
- Save and refresh. The roster toggle and controls section should now be properly spaced and centered.
- Add the remaining control, error, and results display styles:
.controls select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #ccc;
}
.controls > button {
padding: 0.75rem 1.5rem;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.controls > button:disabled {
background: #ccc;
cursor: not-allowed;
}
.error {
background: #fdecea;
color: #e74c3c;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
- Add styles for the trade result display that you will use in a later step:
.trade-result {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
}
.verdict {
font-size: 1.2rem;
font-weight: bold;
color: #2c3e50;
}
.fairness-score {
margin: 1rem 0;
}
.fairness-score .score {
font-size: 2rem;
font-weight: bold;
color: #4a90d9;
}
.pros-cons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
.pros h3 {
color: #27ae60;
}
.cons h3 {
color: #e74c3c;
}
- Finally, add the remaining result and negotiation styles:
.impact-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.risk-level span {
font-size: 1.2rem;
font-weight: bold;
}
.negotiation-tools {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-top: 1.5rem;
background: #f8f9fa;
}
.message-block {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1rem;
position: relative;
}
.message-block button {
margin-top: 0.5rem;
background: #4a90d9;
color: white;
border: none;
border-radius: 4px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font-size: 0.85rem;
}
.counteroffers ol {
padding-left: 1.25rem;
}
- Add the final counteroffer list item style at the bottom of the file:
.counteroffers li {
margin-bottom: 0.75rem;
}
.counteroffers li p {
margin: 0.25rem 0 0;
color: #555;
font-size: 0.9rem;
}
- Save client/src/App.css.
Before you check the browser, what do you expect to see? Think about which panels and controls should be visible.
- Refresh your browser at localhost:5173. You should see a centered page with a "Fantasy Football Trade Analyzer" heading, two bordered panels labeled "Giving" and "Receiving" side by side, a "Show Roster Context" toggle, a league format dropdown set to PPR, and a grayed-out "Analyze Trade" button.
- Try typing a player name (like Ja'Marr Chase) into the Giving panel and clicking Add. The name should appear in a list below with a red Remove button.
- Add a player to the Receiving panel as well. The Analyze Trade button should turn green and become clickable.
Panels stacked vertically instead of side by side?
Make sure the .panels CSS rule includes display: grid and grid-template-columns: 1fr 1fr. If the file saved but the layout did not change, try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
help me fix my CSS grid layout for the trade analyzer panels
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: types.ts, PlayerPanel.tsx, TradeAnalyzer.tsx, App.tsx, and App.css.
ⓧ I'd like to double check the full code
Scroll back through the substeps above and confirm each chunk is present in your files, in order:
- types.ts: TradeInput, PositionalImpact, and TradeAnalysis interfaces.
- PlayerPanel.tsx: imports, interface, component function with addPlayer, removePlayer, handleKeyDown, and the full JSX return.
- TradeAnalyzer.tsx: imports, all state declarations, canAnalyze, and the full return with panels, roster context, controls, and error display.
- App.tsx: imports TradeAnalyzer and renders it inside a .app div.
- App.css: all style blocks from .app through .counteroffers li p.
Your trade form is collecting player names, roster context, and league format. Next up, you will connect this UI to Claude and discover why a naive prompt does not give you the structured data you need.
Send a Naive Prompt and Hit the Problem
You have a UI that captures trade data, but the Analyze Trade button does nothing yet. The backend has no route to process the trade, and the frontend has no logic to call one.
In this step, you will wire up the simplest possible pipeline: send the trade data to Claude with a plain-language question and see what comes back. The result will expose a critical problem that shapes the rest of the project.
In this step, get ready to:
- Create the /analyze POST endpoint that calls Claude with a naive prompt.
- Wire the frontend to call the endpoint and attempt to parse the response.
- Observe what happens when Claude responds without structured instructions.
Create the /analyze POST endpoint with a naive prompt
The Anthropic SDK is already installed in your server dependencies. You need to import it, create a client instance, and add a route that sends a simple question to Claude.
- In VS Code, open server/src/index.ts.
- Find the existing import lines at the top of the file:
import express from "express";
import cors from "cors";
- Add the Anthropic import directly below them, so the top of your file looks like this:
import express from "express";
import cors from "cors";
import Anthropic from "@anthropic-ai/sdk";
- Now find the app.use(express.json()); line. Add the Anthropic client and the /analyze route below it, before the existing /health route. Copy the following code:
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
- Then add the /analyze POST route below the /health route by copying this code:
app.post("/analyze", async (req, res) => {
const { givingPlayers, receivingPlayers, leagueFormat, myRoster, theirRoster } = req.body;
if (!givingPlayers?.length || !receivingPlayers?.length || !leagueFormat) {
res.status(400).json({ error: "Missing required fields" });
return;
}
try {
// Send a simple, conversational question to Claude
const userMessage = `Analyze this fantasy football trade: I'm giving ${givingPlayers.join(", ")} and receiving ${receivingPlayers.join(", ")} in a ${leagueFormat} league. Who wins?`;
const message = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
messages: [{ role: "user", content: userMessage }],
});
const textBlock = message.content.find((block) => block.type === "text");
if (!textBlock || textBlock.type !== "text") {
res.status(500).json({ error: "No text response from Claude" });
return;
}
console.log("Claude response:", textBlock.text);
res.json(textBlock.text);
} catch (err) {
console.error("Claude API error:", err);
res.status(500).json({ error: "AI analysis failed" });
}
});
What does this code do?
- The route destructures the trade data from the request body and checks that both player arrays are non-empty.
- It constructs a plain-language question with the player names and league format, then sends it to Claude using anthropic.messages.create().
- The response text is logged to the console and returned directly to the frontend.
- There is no system prompt, no JSON instruction, and no parsing logic. Claude will answer however it wants.
- Save server/src/index.ts.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Set your Anthropic API key as an environment variable and restart the server by running:
export ANTHROPIC_API_KEY=your-api-key-here
npm run dev
- Replace your-api-key-here with your actual Anthropic API key.
- You should see Server running on port 3001 in the terminal with no errors.
Seeing an error on startup?
- If you see a TypeScript error about the Anthropic import, confirm you installed @anthropic-ai/sdk in the previous step. Run npm install in the server/ folder.
- If you see a "Cannot find module" error, make sure the import uses the exact string @anthropic-ai/sdk with no typos.
Help me debug my server startup error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Your server/src/index.ts should now contain, in order:
- Three imports at the top: express, cors, and Anthropic.
- The app setup (express(), port, CORS, JSON middleware).
- The anthropic client instantiation reading from process.env.ANTHROPIC_API_KEY.
- The GET /health route.
- The POST /analyze route with the naive prompt and console.log.
- The app.listen() call at the bottom.
Wire the frontend to call the /analyze endpoint
The backend is ready to receive trade data. Now you need the frontend to actually send it when the learner clicks Analyze Trade.
- Open client/src/components/TradeAnalyzer.tsx.
- Find the line that reads const canAnalyze = and its continuation. Add the analyzeTrade function directly below it by copying this code:
const analyzeTrade = async () => {
setLoading(true);
setError(null);
setAnalysis(null);
try {
// Build the request body from component state
const body: TradeInput = { givingPlayers, receivingPlayers, leagueFormat };
if (myRoster.trim()) body.myRoster = myRoster.trim();
if (theirRoster.trim()) body.theirRoster = theirRoster.trim();
const response = await fetch("http://localhost:3001/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`Server error: ${response.status}`);
// Attempt to parse the response as our TradeAnalysis type
const text = await response.text();
const data = JSON.parse(text) as TradeAnalysis;
setAnalysis(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Analysis failed");
} finally {
setLoading(false);
}
};
What does this code do?
- It sets loading state and clears any previous analysis or error before making the request.
- It constructs the request body from the component's state, including optional roster fields only when the user filled them in.
- It reads the response as raw text, then immediately tries JSON.parse() to cast it to the TradeAnalysis type. If the text is not valid JSON, this throws an error.
- Now find the Analyze Trade button in the return statement. It currently looks like this:
<button disabled={!canAnalyze}>
{loading ? "Analyzing..." : "Analyze Trade"}
</button>
- Add onClick={analyzeTrade} to the button so it now reads:
<button onClick={analyzeTrade} disabled={!canAnalyze}>
{loading ? "Analyzing..." : "Analyze Trade"}
</button>
- Save client/src/components/TradeAnalyzer.tsx.
Your browser will hot-reload. The UI looks the same, but the button is now wired to call the backend.
✔️ Awesome, I've got everything!
Great. Double check you saved the file before continuing.
ⓧ I'd like to double check the full code
Your TradeAnalyzer.tsx should now contain, in order:
- Imports for useState, PlayerPanel, TradeAnalysis, and TradeInput.
- All state declarations (givingPlayers, receivingPlayers, leagueFormat, roster fields, analysis, loading, error).
- The canAnalyze computed value.
- The new analyzeTrade async function with fetch, JSON.parse, and error handling.
- The JSX return with both panels, roster context, controls (including onClick={analyzeTrade} on the button), and the error display.
Observe the failure: Claude returns prose, not JSON
Everything is connected. Time to test it. Before you click the button, think about what you expect to happen.
- Switch to your browser at localhost:5173.
- In the Giving panel, add a player name (for example, Saquon Barkley).
- In the Receiving panel, add a player name (for example, Davante Adams).
The Analyze Trade button should now be enabled. Before you click it: do you think the frontend will successfully display a structured analysis, or will something go wrong?
- Click Analyze Trade.
You will see the button text change to "Analyzing..." while the request is in flight. After a few seconds, the UI displays a red error box with a message like "Analysis failed" or a JSON parse error.
- Switch to the terminal running your server. Look at the console output.
You will see Claude's full response logged. It is conversational prose. Something like a paragraph discussing who might win the trade, with opinions, caveats, and full sentences. It is not JSON.
Why did this happen?
You asked Claude a casual question ("Who wins?") with no instructions about response format. Claude did exactly what a conversational AI should do: it wrote a helpful, conversational answer.
The frontend tried to JSON.parse() that prose and got a syntax error. Your TradeAnalysis TypeScript interface expects fields like verdict, fairnessScore, and pros. Claude returned none of them.
This failure is intentional. Without structured instructions, an LLM will always return free-text. Your TypeScript types cannot parse free-text. This is the core problem that prompt engineering solves. In the next step, you will fix this with a system prompt that forces Claude to respond with valid, structured JSON.
You have proven the problem firsthand. A naive prompt produces unparseable responses. Next up, you will engineer a prompt that forces Claude to return exactly the JSON your types expect.
Fix It with a Structured System Prompt
You saw the problem firsthand. Claude returned conversational prose, and your frontend crashed trying to parse it as JSON.
The fix is prompt engineering. A detailed system prompt with an explicit JSON schema, field descriptions, and constraints forces Claude to return parseable, consistent output every time. In this step, you will add that system prompt, build safe parsing logic, and create the UI to display the results.
In this step, get ready to:
- Add a system prompt that instructs Claude to respond with valid JSON matching an exact schema.
- Build safe JSON parsing and validation to handle Claude's response reliably.
- Create a results display component that renders the structured analysis in the browser.
Add the system prompt constant
A system prompt runs before the user message and constrains how the model behaves for the entire conversation. You will use it to tell Claude exactly what JSON shape to produce.
- In server/src/index.ts, find the app.get("/health", ...) route.
- Add the following SYSTEM_PROMPT constant directly below the health route:
const SYSTEM_PROMPT = `You are a fantasy football trade analyst. You analyze trades based ONLY on the player names, league format, and any roster context provided. You do NOT have access to live rankings, injury reports, ADP data, or current-season statistics. Your analysis is based on general knowledge of player value and positional importance in fantasy football.
When roster context is provided, factor each team's positional needs into your analysis. A player's value changes significantly based on what positions a team already has covered.
You MUST respond with ONLY valid JSON matching this exact schema. Do not include any text before or after the JSON object.
{
"verdict": "A one-sentence summary of who wins the trade and why",
"fairnessScore": <number from 1 to 10, where 5 is perfectly fair, below 5 favors the giving side, above 5 favors the receiving side. This is an AI-generated assessment, not an objective statistical measurement>,
"reasoning": "A 2-3 sentence detailed explanation of the trade dynamics",
"pros": ["Array of 2-3 reasons to accept this trade"],
"cons": ["Array of 2-3 reasons to reject this trade"],
"positionalImpact": {
"giving": "Description of positional value being given up",
"receiving": "Description of positional value being gained"
},
"riskLevel": "Low" | "Medium" | "High"
}`;
What does this system prompt do?
The prompt has three jobs. First, it tells Claude to only use the information you provide (no hallucinating live stats). Second, it demands JSON-only output with zero surrounding text. Third, it defines the exact field names, types, and allowed values so your TypeScript types will match perfectly.
Notice the fairnessScore description labels it as an "AI-generated assessment." This is important for transparency. The number is an opinion, not a statistical measurement.
- Save server/src/index.ts.
- Check your terminal where the server is running. It should restart automatically with no errors.
Seeing a syntax error in the terminal?
Make sure the template literal uses backticks (`) at both ends, not regular quotes. Also check that the closing backtick is followed by a semicolon.
help me fix a syntax error in my SYSTEM_PROMPT template literal
Add safe JSON parsing with validation
Even with a strong system prompt, Claude might occasionally wrap its JSON in markdown code fences. You need a parser that handles this gracefully and a validator that confirms every required field exists with the correct type.
- Below the SYSTEM_PROMPT constant, add the parseClaudeResponse function:
function parseClaudeResponse(text: string): Record<string, unknown> | null {
// Remove any markdown code fences Claude might add
let cleaned = text.trim();
const fenceMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fenceMatch) {
cleaned = fenceMatch[1].trim();
}
try {
return JSON.parse(cleaned);
} catch {
return null;
}
}
Why strip markdown fences?
LLMs sometimes wrap JSON in ```json ... ``` code fences, even when told not to. The regex detects these fences and extracts just the JSON content inside them. If no fences are present, the original text passes through unchanged.
- Directly below parseClaudeResponse, add the validateAnalysis type guard function:
function validateAnalysis(data: unknown): data is {
verdict: string;
fairnessScore: number;
reasoning: string;
pros: string[];
cons: string[];
positionalImpact: { giving: string; receiving: string };
riskLevel: string;
} {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.verdict === "string" &&
typeof obj.fairnessScore === "number" &&
typeof obj.reasoning === "string" &&
Array.isArray(obj.pros) &&
Array.isArray(obj.cons) &&
typeof obj.positionalImpact === "object" &&
obj.positionalImpact !== null &&
typeof (obj.positionalImpact as Record<string, unknown>).giving ===
"string" &&
typeof (obj.positionalImpact as Record<string, unknown>).receiving ===
"string" &&
typeof obj.riskLevel === "string"
);
}
What is a type guard?
The data is { ... } return type tells TypeScript that if this function returns true, the input matches the declared shape. This lets you safely access fields like data.verdict without type errors downstream.
Each check confirms a field exists and has the right type. If Claude ever returns malformed data, the function returns false and the server sends a clean error response instead of crashing.
- Save server/src/index.ts.
- Check your server terminal. It should restart with no errors. The unused functions are fine for now.
Rewrite the /analyze endpoint with structured messaging
Now you will replace the naive endpoint with one that uses your system prompt, constructs a clear user message, and validates the response before sending it to the frontend.
- In server/src/index.ts, find the existing app.post("/analyze", ...) endpoint and delete it entirely (from app.post through the closing });).
- Replace it with this new endpoint that constructs a structured user message and calls Claude with the system prompt:
app.post("/analyze", async (req, res) => {
const { givingPlayers, receivingPlayers, leagueFormat, myRoster, theirRoster } =
req.body;
if (!givingPlayers?.length || !receivingPlayers?.length || !leagueFormat) {
res.status(400).json({ error: "Missing required fields" });
return;
}
try {
let userMessage = `Analyze this fantasy football trade in a ${leagueFormat} league:
Giving: ${givingPlayers.join(", ")}
Receiving: ${receivingPlayers.join(", ")}`;
if (myRoster) {
userMessage += `\n\nMy remaining roster: ${myRoster}`;
}
if (theirRoster) {
userMessage += `\nTheir remaining roster: ${theirRoster}`;
}
userMessage += `\n\nProvide your analysis as the JSON object described in your instructions.`;
const message = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMessage }],
});
- Immediately below that code (still inside the try block), add the response handling that parses and validates Claude's output:
const textBlock = message.content.find((block) => block.type === "text");
if (!textBlock || textBlock.type !== "text") {
res.status(500).json({ error: "No text response from Claude" });
return;
}
const parsed = parseClaudeResponse(textBlock.text);
if (!parsed || !validateAnalysis(parsed)) {
console.error("Failed to parse Claude response:", textBlock.text);
res.status(500).json({ error: "Failed to parse AI response" });
return;
}
res.json(parsed);
} catch (err) {
console.error("Claude API error:", err);
res.status(500).json({ error: "AI analysis failed" });
}
});
What changed from the naive version?
Three things. First, the API call now includes system: SYSTEM_PROMPT which constrains Claude's output format. Second, the user message is structured with clear labels ("Giving:", "Receiving:") plus optional roster context instead of a vague question. Third, the response goes through parseClaudeResponse and validateAnalysis before reaching the frontend.
The endpoint now returns res.json(parsed) which sends a proper JSON object. The old version sent a raw string that the frontend could not parse.
- Save server/src/index.ts.
- Before you test from the browser, predict: will the response now be valid JSON or will it still fail?
- Test the backend directly by running this in a new terminal:
curl -X POST http://localhost:3001/analyze \
-H "Content-Type: application/json" \
-d '{"givingPlayers":["Davante Adams"],"receivingPlayers":["Saquon Barkley"],"leagueFormat":"PPR"}'
You should see a JSON object with verdict, fairnessScore, reasoning, pros, cons, positionalImpact, and riskLevel fields. The system prompt worked.
Getting an empty response or API error?
Make sure your ANTHROPIC_API_KEY environment variable is set in the terminal where your server is running. The SDK reads it automatically from the environment.
If you see Failed to parse AI response, check your server logs for the raw Claude output. It may contain extra text outside the JSON that the system prompt did not suppress on this attempt.
help me debug why my Claude API call is not returning valid JSON
Build the analysis results display
The backend now returns structured data. You need a React component to render the verdict, score, reasoning, pros/cons, positional impact, and risk level in a readable layout.
- Create a new file client/src/components/TradeResult.tsx and add the component header with the verdict and fairness score sections:
import { TradeAnalysis } from "../types";
interface TradeResultProps {
analysis: TradeAnalysis;
}
export function TradeResult({ analysis }: TradeResultProps) {
// Map risk level to a display color
const riskColor =
analysis.riskLevel === "Low"
? "green"
: analysis.riskLevel === "Medium"
? "orange"
: "red";
return (
<div className="trade-result">
<h2>Trade Verdict</h2>
<p className="verdict">{analysis.verdict}</p>
<div className="fairness-score">
<h3>AI-Generated Fairness Assessment (1-10)</h3>
<span className="score">{analysis.fairnessScore}</span>
</div>
<div className="reasoning">
<h3>Reasoning</h3>
<p>{analysis.reasoning}</p>
</div>
- Continue in the same file by adding the pros and cons section below the reasoning:
<div className="pros-cons">
<div className="pros">
<h3>Pros</h3>
<ul>
{analysis.pros.map((pro, i) => (
<li key={i}>{pro}</li>
))}
</ul>
</div>
<div className="cons">
<h3>Cons</h3>
<ul>
{analysis.cons.map((con, i) => (
<li key={i}>{con}</li>
))}
</ul>
</div>
</div>
- Finish the component by adding the positional impact and risk level sections, then close the return:
<div className="positional-impact">
<h3>Positional Impact</h3>
<div className="impact-columns">
<div>
<h4>Giving Up</h4>
<p>{analysis.positionalImpact.giving}</p>
</div>
<div>
<h4>Receiving</h4>
<p>{analysis.positionalImpact.receiving}</p>
</div>
</div>
</div>
<div className="risk-level">
<h3>Risk Level</h3>
<span style={{ color: riskColor }}>{analysis.riskLevel}</span>
</div>
</div>
);
}
What does the TradeResult component render?
The component takes the validated TradeAnalysis object and renders each field in its own section. The fairness score is clearly labeled as an AI-generated assessment. Pros and cons appear as bullet lists. Positional impact uses a two-column layout so the learner can compare what they lose versus gain.
The risk level displays in green, orange, or red based on the riskLevel value. This gives an instant visual signal about trade safety.
- Save client/src/components/TradeResult.tsx.
Now you need to wire the new component into TradeAnalyzer so it renders when analysis data arrives.
- In client/src/components/TradeAnalyzer.tsx, find the import lines at the top. Add this import below the PlayerPanel import:
import { TradeResult } from "./TradeResult";
- Next, find the response handling inside the analyzeTrade function. Locate these lines:
const text = await response.text();
const data = JSON.parse(text) as TradeAnalysis;
setAnalysis(data);
- Replace those three lines with this updated version that uses .json() and checks for server errors:
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setAnalysis(data);
- Finally, find the closing line of the return JSX. Locate this line:
{error && <div className="error">{error}</div>}
- Add this line directly below it to render the results when analysis data exists:
{analysis && <TradeResult analysis={analysis} />}
- Save client/src/components/TradeAnalyzer.tsx.
- Switch to your browser at http://localhost:5173.
- Add a player to each panel (for example, "Davante Adams" in Giving and "Saquon Barkley" in Receiving).
- Before you click Analyze Trade, predict: will you see a structured analysis or another parse error?
- Click Analyze Trade.
You should see the full structured analysis rendered below the controls: a verdict, a fairness score labeled "AI-Generated Fairness Assessment (1-10)", reasoning text, bullet lists of pros and cons, a two-column positional impact section, and a color-coded risk level.
Still seeing an error message in the UI?
Open DevTools (F12) and check the Console tab for errors. If you see a network error, confirm both servers are still running in their respective terminals.
If the server returns Failed to parse AI response, check the server terminal logs for Claude's raw output. Try clicking Analyze Trade again. Occasional formatting inconsistencies resolve on retry.
help me debug why my trade analysis is not rendering in the browser
✔️ Awesome, I've got everything!
Great. Double check you have saved both server/src/index.ts and client/src/components/TradeResult.tsx.
ⓧ I'd like to double check the full code
Your server/src/index.ts should now contain these sections in order: imports, app setup, anthropic client, health route, SYSTEM_PROMPT constant, parseClaudeResponse function, validateAnalysis function, the /analyze POST endpoint, and app.listen. Scroll through your file and confirm each section is present.
Your client/src/components/TradeResult.tsx should have the import, interface, function with riskColor logic, and the return with sections for verdict, fairness-score, reasoning, pros-cons, positional-impact, and risk-level.
Your client/src/components/TradeAnalyzer.tsx should import TradeResult and render {analysis && <TradeResult analysis={analysis} />} below the error div.
The core loop is complete. You enter players, Claude analyzes the trade using your structured prompt, the backend validates the JSON, and the frontend renders every field. Next up, you will tackle the Secret Mission and extend this analysis with negotiation tools.
Secret mission
Negotiation Message and Counteroffers
Your trade analyzer tells you who wins a trade. But what do you do with that information? Extend the prompt to also return a persuasive trade message you can send your league-mate, plus ranked counteroffer suggestions that improve the deal. All from the same API call.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine. The only ongoing cost is the Anthropic API, which charges per token each time you click Analyze Trade.
Cost warning
Each trade analysis costs roughly $0.01-0.05 depending on response length. You are only charged when you actively make API calls. No charges accrue when your servers are idle or stopped.
Resources you used:
- Two local dev servers (Vite frontend on port 5173, Express backend on port 3001).
- Local project files in client/ and server/ directories on your Desktop.
- Anthropic API key stored as the ANTHROPIC_API_KEY environment variable in your terminal session.
- Installed npm packages (node_modules in both client/ and server/).
Keep everything running
No action needed. Choose this if you are still actively testing trades or want to keep experimenting.
- Both dev servers stay running in their terminal tabs.
- You are only charged when you click Analyze Trade, so an idle app costs nothing.
- Your ANTHROPIC_API_KEY environment variable remains set for the current terminal session.
Pause - I'll come back to this later
Shut down both running servers to free up terminal sessions and memory. Your project files remain on disk, ready to restart.
- Switch to the terminal running your Express server and press Ctrl+C to stop it.
- Switch to the terminal running your Vite dev server and press Ctrl+C to stop it.
- To restart later, open two terminals, cd into server/ and run npm run dev in one, then cd into client/ and run npm run dev in the other.
Delete - I don't want to use this again
Remove all project files from your machine. No cloud resources need to be deleted since everything runs locally.
- Stop both dev servers by pressing Ctrl+C in each terminal.
- Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf client server
Windows users
If you are on Windows, open PowerShell and run:
cd ~/Desktop
Remove-Item -Recurse -Force client, server
- Your ANTHROPIC_API_KEY environment variable only lasts for the current terminal session. Closing your terminal clears it automatically.
- The Anthropic API is pay-per-use only. No subscription or recurring charges exist to cancel.
Nice Work!
Nice Work!
Nice work! You just built a full-stack AI trade analyzer that takes any fantasy football trade scenario, sends it to Claude, and returns a typed, validated JSON analysis.
You've learned how to:
- Built a full-stack React + Express application with end-to-end TypeScript type safety, from frontend interfaces through backend validation.
- Engineered a system prompt that reliably produces structured JSON from an LLM, complete with safe parsing and runtime validation to catch malformed responses.
- Experienced the problem-first approach to prompt engineering: saw a naive prompt return unparseable prose, then fixed it with an explicit JSON schema and field constraints.
- Secret Mission: Extended the prompt schema to include a trade negotiation message and ranked counteroffer suggestions in a single API call.
Ready to quiz yourself?