A deployable Next.js web app called Tension Map. It takes any argumentative text, calls an LLM API via a server-side proxy route (so the API key never touches the client), and renders the argument's claims, assumptions, and tensions as an interactive D3 force-directed graph. Clicking a tension node opens a panel showing both poles, the shared assumption, and one open question addressed to the reader — but no verdict, no score, no resolution.
Three pre-loaded example texts work instantly with no API call (mock graphs are bundled). Custom text calls the server route.
The project will be open-sourced on GitHub with no secrets in the repo. API keys live only in Vercel environment variables.
- Next.js 14 with the App Router
- React (client components where needed)
- D3 v7 for the force-directed graph
- No CSS framework — all styles are inline or in
globals.css - No database — fully stateless
- API key: stored in a Vercel environment variable, used only in the server-side API route
tension-map/
├── app/
│ ├── layout.jsx ← root layout, sets metadata and globals
│ ├── page.jsx ← imports and renders TensionMap component
│ ├── globals.css ← minimal reset only
│ └── api/
│ └── extract/
│ └── route.js ← server-side POST handler, calls LLM with API key
├── components/
│ └── TensionMap.jsx ← THE MAIN COMPONENT (provided — copy from folder)
├── .env.local ← NOT committed; holds API key for local dev
├── .env.example ← committed; shows required var names with empty values
├── .gitignore
├── next.config.js
├── package.json
└── README.md
npx create-next-app@latest tension-map --app --js --no-tailwind --no-eslint --no-src-dir
cd tension-map
npm install d3When prompted, accept the defaults. The --app flag enables the App Router.
Copy TensionMap.jsx (provided in this folder) into components/TensionMap.jsx. Do not modify it yet — get the scaffold working first.
import TensionMap from '@/components/TensionMap';
export const metadata = {
title: 'Tension Map',
description: 'Paste any argument — see what it isn\'t resolving',
};
export default function Home() {
return <TensionMap />;
}import './globals.css';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body style={{ margin: 0, padding: 0 }}>{children}</body>
</html>
);
}*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }This is the only file that touches the API key. The key is read from process.env.LLM_API_KEY at runtime on the server. It is never exposed to the browser.
import { NextResponse } from 'next/server';
const EXTRACTION_PROMPT = `Analyze this argument and extract its logical structure. Return ONLY valid JSON, no preamble, no markdown fences.
Extract:
- 5-7 "nodes" of type "claim" (concrete assertions, max 12 words) or "assumption" (unstated premises, max 10 words)
- 3-5 "edges" with types: "supports", "contradicts", or "presupposes"
- 2-3 "tensions" where claims or assumptions genuinely pull against each other
Each tension must include: pole_a and pole_b (node ids in conflict), shared_assumption (1 sentence — the premise making both poles seem valid simultaneously), open_question (1 sentence starting "What would have to be true…" addressed to the reader).
Schema:
{"nodes":[{"id":"c1","type":"claim","text":"Short text"},{"id":"a1","type":"assumption","text":"Unstated premise"}],"edges":[{"from":"c1","to":"c2","type":"supports"}],"tensions":[{"id":"t1","pole_a":"c1","pole_b":"c2","shared_assumption":"One sentence.","open_question":"What would have to be true…?"}]}
Text:
`;
export async function POST(request) {
try {
const { text } = await request.json();
if (!text || text.trim().length < 50) {
return NextResponse.json({ error: 'Text too short — paste a full paragraph.' }, { status: 400 });
}
// --- Anthropic ---
// To use Anthropic instead of DeepSeek: swap the URL and headers below.
// Anthropic URL: https://api.anthropic.com/v1/messages
// Anthropic headers: { 'x-api-key': process.env.LLM_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }
// Anthropic body model: "claude-sonnet-4-6"
// --- DeepSeek (default — cheaper for the grant period) ---
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
max_tokens: 1200,
messages: [
{ role: 'user', content: EXTRACTION_PROMPT + text }
],
}),
});
if (!response.ok) {
const err = await response.text();
console.error('LLM API error:', err);
return NextResponse.json({ error: 'LLM API call failed.' }, { status: 502 });
}
const data = await response.json();
// DeepSeek returns choices[0].message.content
// Anthropic returns content[0].text — swap this line if switching
const raw = data.choices?.[0]?.message?.content || '';
const clean = raw.replace(/```json|```/g, '').trim();
const parsed = JSON.parse(clean);
// Validate minimum shape
if (!parsed.nodes || !parsed.tensions) {
throw new Error('Unexpected response shape');
}
return NextResponse.json(parsed);
} catch (e) {
console.error('Extract error:', e);
return NextResponse.json({ error: 'Could not parse argument structure.' }, { status: 500 });
}
}LLM_API_KEY=your_deepseek_or_anthropic_key_here
LLM_API_KEY=
.env.local
.env*.local
node_modules/
.next/
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;npm run devOpen http://localhost:3000. The three example buttons should work instantly (no API call). Pasting custom text should call the API route. Check the terminal for any errors from the route handler.
npm install -g vercel
vercel login
vercelFollow the prompts. When asked about environment variables, skip for now — you'll add them in the dashboard.
Go to vercel.com → your project → Settings → Environment Variables and add:
| Name | Value | Environment |
|---|---|---|
LLM_API_KEY |
your actual key | Production, Preview, Development |
Then redeploy:
vercel --prod- Push the repo to GitHub (no secrets in the repo —
.env.localis gitignored). - Go to vercel.com → New Project → Import from GitHub.
- Add the
LLM_API_KEYenvironment variable in the Vercel UI before deploying. - Deploy. Vercel detects Next.js automatically.
In app/api/extract/route.js, the comments mark exactly what to swap. The only differences are:
- The URL (
api.deepseek.comvsapi.anthropic.com) - The auth header (
Authorization: Bearervsx-api-key) - One extra header for Anthropic (
anthropic-version: 2023-06-01) - How you read the response (
choices[0].message.contentvscontent[0].text) - The model name (
deepseek-chatvsclaude-sonnet-4-6)
The 90-day plan, in order:
Month 1 (now → end of July)
- Polish the extraction schema — better prompts, better short-text fallbacks
- Add a shareable URL: encode the graph as a compressed URL param so a tension map can be sent as a link
- Clean up the graph layout: pin tension nodes more centrally, improve label positioning for dense graphs
Month 2 (August)
- Tension annotation: user can type their own answer to the open question directly in the panel; it persists in localStorage
- "Resolved by me" toggle: mark a tension as resolved with a note; the node visually changes state
- Export: download the tension map as JSON (the schema) or as a PNG
Month 3 (September)
- Design partners: 10+ external users; document their cases
- "What did the AI decide for you?" mode: paste an AI response specifically; the tool surfaces the value-choices the model made without declaring them
- Open repo write-up: the extraction schema published as a standalone spec, with contribution guide
- User accounts, authentication, a database
- Saving maps server-side
- Mobile-native layout (the current layout works on mobile but is not optimised — that's month 2 scope)
- Any pricing or paywall
- The
TensionMap.jsxcomponent is a client component ('use client'at top). Do not move its logic to a server component. - D3's
forceLinkrequiressourceandtargetproperties on link objects. The data usesfrom/to— the component maps these before passing to D3. Do not remove this mapping. - All styles are inline. Do not add Tailwind or a CSS framework — keep it portable.
- The API route is the only place the API key is used. Never import
process.env.LLM_API_KEYin client components. - The mock graphs in the component are intentional — they make the three example texts work instantly without an API round-trip. Keep them.