Build a Multi-Agent AI Idea Engine
Deploy a LangGraph multi-agent app on AWS Lambda with a Next.js frontend.
Introduction
30 Second Summary
Every developer hits the same wall. You want to build something meaningful, but browsing for project ideas leads to the same generic todo apps and weather dashboards that won't impress anyone.
In this project, you will build IdeaForge, a full-stack multi-agent AI application that mines real community discussions on Reddit and HackerNews to discover validated, personalized project ideas backed by real evidence of demand.
What You'll Build
IdeaForge is a production-grade AI product with a FastAPI backend running 3 specialized LangGraph agents on AWS Lambda, a themed Next.js frontend on Vercel, and Supabase-powered authentication and data persistence.
By the end of this project, you'll have:
- A deployed multi-agent backend with Scout, Analyst, and Architect agents collaborating via a LangGraph state machine on AWS Lambda.
- A polished Next.js frontend with a distinctive explorer/nautical brand identity, user authentication, and saved idea sessions deployed on Vercel.
- A complete CI/CD pipeline using GitHub Actions for automated deployments to both AWS and Vercel.
- Secret Mission: Add GitHub repository scanning so the agent analyzes users' public repos to infer their tech stack and personalize ideas accordingly.
Are there any prerequisites?
This is an advanced project. You should have Python 3.11+, Docker, Node.js 18+, and Git installed on your machine.
You also need a Google account (for the Gemini API key), a GitHub account, and a Reddit account. The project walks you through setting up AWS, Supabase, and Vercel accounts in the first steps.
Set Up Accounts and API Keys
IdeaForge's multi-agent system calls three external services: Google's Gemini AI for language intelligence, Reddit's API for community signal mining, and Supabase for user authentication and data storage.
Before writing any code, you need credentials for each service. In this step, you will create accounts and collect the API keys that your backend will use to connect to these services.
In this step, get ready to:
- Generate a free-tier Gemini API key from Google AI Studio.
- Register a Reddit application and obtain OAuth client credentials.
- Create a Supabase project and note your project URL and anon key.
Generate your Gemini API key
Google's Gemini 2.5 Flash model powers all of IdeaForge's language understanding. The free tier provides free input and output tokens with no billing required.
- Open your browser and navigate to aistudio.google.com.
- Sign in with your Google account.
- If this is your first visit, accept the Terms of Service and confirm your region.
- In the left sidebar, click Get API key.
- Click Create API key.
- Select Create key in new project (this is the fastest option for a new user).
- Copy the generated key and paste it into a secure note or password manager. Treat this key like a password.
What is this key for?
This key authenticates your backend when it sends prompts to Gemini. Later, you will store it as the GEMINI_API_KEY environment variable in your Lambda function.
The free tier gives you enough quota for prototyping and hobby use. No credit card or billing setup is needed.
Key stays visible in AI Studio
Unlike some other providers, Google AI Studio lets you view existing keys at any time from the API Keys page. You can always return to aistudio.google.com/app/apikey to re-copy it.
Seeing a permissions error?
If you see a message about not having permission to create a key, your Google account may lack the necessary project permissions. Try clicking Create key in new project instead of selecting an existing project. This creates a fresh Google Cloud project automatically.
help me fix a permissions error when creating a Gemini API key in Google AI Studio
Register a Reddit application
IdeaForge's Scout agent searches Reddit for real community discussions and demand signals. Reddit requires OAuth2 credentials for all API access. You will register a "script" type application, which gives you a client_id and client_secret for server-to-server authentication.
- Sign in to your Reddit account.
- Navigate to reddit.com/prefs/apps in your browser.
- Scroll to the bottom of the page and click create app (or are you a developer? create an app... if this is your first app).
- Fill in the form:
- Click create app.
- After creation, note the two credentials displayed on the page:
- Copy both values into your secure note. You will use them as REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET later.
Why the script type?
The "script" type is designed for server-side applications that authenticate without a user login flow. IdeaForge uses the client_credentials OAuth grant to read public Reddit data on behalf of your application, not on behalf of a specific Reddit user.
Reddit's free tier allows 100 queries per minute per OAuth client ID. This is more than enough for a hobby project.
Don't see the create app button?
Make sure you are signed in to Reddit and visiting reddit.com/prefs/apps (not new.reddit.com). If the page looks empty, try old.reddit.com/prefs/apps instead. Some newer Reddit accounts may also need to accept the Responsible Builder Policy before credentials work.
help me register a Reddit application at reddit.com/prefs/apps
Create a Supabase project
Supabase provides the database and authentication layer for IdeaForge. Users sign in via Google OAuth, and their saved idea "voyages" are stored in a PostgreSQL database with Row Level Security.
- Open supabase.com/dashboard in your browser.
- Sign in with your GitHub or Google account (GitHub is recommended for fast setup).
- Click New Project.
- Choose your organization (personal is fine).
- Enter a project name: ideaforge.
- Set a strong database password and save it in your password manager.
- Select a region close to your users (e.g., US East).
- Leave the plan as Free and click Create new project.
- Wait about 2 minutes for provisioning to complete.
Once your project is ready, you need to copy two values.
- In the left sidebar, click Settings (the gear icon).
- Click API under Configuration.
- Copy the Project URL (starts with https:// and ends with .supabase.co). This is your SUPABASE_URL.
- Copy the anon public key (a long string starting with eyJ). This is your SUPABASE_ANON_KEY.
- Store both values in your secure note alongside the other credentials.
What is the anon key?
The anon key is a client-safe key that respects Row Level Security policies. It allows your frontend to interact with the database without exposing admin-level access. The service_role key (also shown on this page) bypasses all security and should never be used in client code.
Supabase's free tier includes 50K monthly active users and 500MB of database storage. No credit card is required.
Can't find the API settings?
Make sure you are inside the correct project in the Supabase dashboard. Look for the gear icon (Settings) in the left sidebar, then select API from the configuration list. If the page shows no keys, your project may still be provisioning. Wait another minute and refresh.
help me find my Supabase project URL and anon key in the dashboard
What you should have now
At this point you have five credentials stored securely: GEMINI_API_KEY, REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, SUPABASE_URL, and SUPABASE_ANON_KEY. Keep these in a password manager or a secure local note. You will use them in the next step when you scaffold the project and create your .env file.
Your credentials are ready. Next up, you will scaffold the monorepo project structure and wire these keys into your development environment.
Scaffold the Monorepo
IdeaForge has two major halves: a Python backend that runs the AI agents, and a Next.js frontend that users interact with. Keeping both in a single repository (a monorepo) makes it easier to manage, deploy, and share with recruiters.
In this step, you'll create the project folder structure, install backend dependencies, and scaffold the frontend app with Tailwind CSS ready to go.
In this step, get ready to:
- Create the monorepo structure with backend/ and frontend/ directories.
- Set up a Python virtual environment and install backend dependencies.
- Initialize the Next.js frontend with Tailwind CSS configured.
Create the monorepo folder structure
A monorepo keeps your backend and frontend code in one place. This makes it simpler to coordinate deployments and gives anyone reviewing your project one link to see the full system.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir ideaforge
cd ideaforge
- Create the backend/ and frontend/ directories by running:
mkdir backend frontend
- Confirm the folders exist by running:
ls
You should see backend and frontend listed in the output.
Why a monorepo?
Startups and production teams often use monorepos for full-stack apps because CI/CD pipelines can test and deploy both halves from a single commit. It also means one GitHub link shows everything you built.
Set up the Python backend environment
The backend needs its own isolated Python environment so dependencies don't conflict with other projects on your machine.
- Move into the backend/ directory by running:
cd backend
- Create a Python virtual environment by running:
python3 -m venv venv
- Activate the virtual environment:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activate command from inside the backend/ folder. If you see a "file not found" error, confirm the venv folder was created by running ls inside backend/.
Help me activate my Python virtual environment
- Create a requirements.txt file that lists all the backend dependencies by running:
cat > requirements.txt << 'EOF'
fastapi
langgraph
langchain-google-genai
mangum
pydantic
httpx
uvicorn
python-dotenv
EOF
What are these packages?
- fastapi is the web framework for building the API.
- langgraph provides graph-based multi-agent orchestration.
- langchain-google-genai connects LangGraph to Google's Gemini models.
- mangum adapts the FastAPI app to run on AWS Lambda.
- pydantic handles request and response validation with type safety.
- httpx is an async HTTP client for calling external APIs like Reddit.
- uvicorn runs the FastAPI server locally during development.
- python-dotenv loads environment variables from a .env file.
- Install all the dependencies by running:
pip install -r requirements.txt
You should see pip downloading and installing each package. The process takes about 30-60 seconds.
Seeing installation errors?
If you see permission errors, confirm your virtual environment is active (you should see (venv) in your prompt). If a specific package fails to build, make sure you are running Python 3.11 or higher by checking with python3 --version.
Help me fix pip install errors in my virtual environment
- Create a .env file to store your API credentials by running:
cat > .env << 'EOF'
GEMINI_API_KEY=your-gemini-api-key-here
REDDIT_CLIENT_ID=your-reddit-client-id-here
REDDIT_CLIENT_SECRET=your-reddit-client-secret-here
SUPABASE_URL=your-supabase-url-here
SUPABASE_ANON_KEY=your-supabase-anon-key-here
EOF
- Open the .env file in your code editor and replace each placeholder with the real values you saved in Step 1.
Why a .env file?
The .env file keeps secrets out of your code. The python-dotenv package loads these values at runtime so your application can access them without hardcoding credentials in source files.
- Verify your backend setup by running:
python3 -c "import fastapi; import langgraph; print('Backend dependencies OK')"
You should see Backend dependencies OK printed. This confirms all packages installed correctly.
Initialize the Next.js frontend
The frontend uses Next.js with Tailwind CSS for styling. The create-next-app CLI scaffolds the entire project with TypeScript, Tailwind, and the App Router pre-configured.
- Navigate back to the project root by running:
cd ~/Desktop/ideaforge
- Scaffold the Next.js app inside the frontend/ directory by running:
npx create-next-app@latest frontend --yes --use-npm
What does this command do?
The --yes flag accepts all recommended defaults: TypeScript, ESLint, Tailwind CSS, and App Router. The --use-npm flag ensures npm is the package manager.
Since the frontend/ directory already exists, the CLI installs directly into it.
Seeing a Node.js version error?
The latest Next.js requires Node.js 20.9.0 or higher. Check your version with node --version. If you are on an older version, update Node.js before running this command again.
Help me update my Node.js version for Next.js
The CLI downloads dependencies and scaffolds the project. This takes 1-2 minutes depending on your internet speed.
- Verify the frontend was created by running:
ls frontend/
You should see files like package.json, tsconfig.json, postcss.config.mjs, and an app/ directory listed.
Where is the Tailwind configuration?
Tailwind CSS v4 uses a CSS-first configuration approach. Instead of a separate tailwind.config.js file, all Tailwind configuration lives inside your globals.css file via @import "tailwindcss". You will customize it there in a later step.
✔️ Awesome, I've got everything!
Great. Double check you have the .env file saved with your real credentials, then move on.
ⓧ I'd like to double check the full code
Here is what backend/requirements.txt should look like:
fastapi
langgraph
langchain-google-genai
mangum
pydantic
httpx
uvicorn
python-dotenv
Here is what backend/.env should look like (with your real values replacing the placeholders):
GEMINI_API_KEY=<your_key>
REDDIT_CLIENT_ID=<your_id>
REDDIT_CLIENT_SECRET=<your_secret>
SUPABASE_URL=<your_url>
SUPABASE_ANON_KEY=<your_key>
Your monorepo is ready. You have a Python backend with all the AI agent dependencies installed and a Next.js frontend with Tailwind CSS configured. Next up, you will define the state schema for the LangGraph agent and build the tools it uses to search Reddit and HackerNews.
Build the Agent State and Tools
Your backend dependencies are installed and your project structure is ready. Now it's time to define the data that flows through your multi-agent system and build the tools those agents use to gather real-world information.
In a LangGraph agent, "state" is the shared data container that every node reads from and writes to as it progresses through the graph. Without a clear state schema, your agents can't communicate. In this step, you'll define that schema and build two search tools that feed real data into it.
In this step, get ready to:
- Define the agent state schema as a Python TypedDict.
- Build a Reddit search tool using OAuth2 client credentials.
- Build a HackerNews search tool using the Algolia HN Search API.
Define the agent state schema
Every LangGraph graph operates on a typed state object. Your state schema declares the exact fields that agents share as data flows from one node to the next.
- Create the agents package directory by running these commands:
mkdir -p backend/app/agents
touch backend/app/agents/__init__.py
- Create the state file by running:
touch backend/app/agents/state.py
- Confirm the files exist by running:
ls backend/app/agents/
You should see __init__.py and state.py in the output.
- Open backend/app/agents/state.py in your code editor and add the following state definition:
from typing import TypedDict
class AgentState(TypedDict):
"""Shared state container for the multi-agent graph.
Each field represents a stage of the idea discovery pipeline:
- user_interests: what the user cares about
- search_queries: dynamically generated queries for external APIs
- raw_findings: unprocessed results from Reddit and HackerNews
- analyzed_signals: scored and filtered demand signals
- final_ideas: synthesized, actionable project ideas
"""
user_interests: list[str]
search_queries: list[str]
raw_findings: list[dict]
analyzed_signals: list[dict]
final_ideas: list[dict]
What does this code do?
This TypedDict declares the exact shape of data your agent graph operates on.
- user_interests stores the skills and topics the user provides.
- search_queries holds the dynamic queries the Scout agent generates based on user input.
- raw_findings collects unprocessed results from Reddit and HackerNews.
- analyzed_signals stores demand signals after the Analyst scores and filters them.
- final_ideas contains the polished project ideas the Architect synthesizes.
- Save backend/app/agents/state.py.
Why TypedDict instead of a Pydantic model?
LangGraph state schemas use TypedDict because they represent plain dictionary data that flows between graph nodes. Pydantic models add validation overhead that is unnecessary here since LangGraph manages the state lifecycle internally.
Build the Reddit search tool
The Reddit tool authenticates using OAuth2 client credentials, then searches subreddits for posts matching a query. This gives your Scout agent access to real community discussions where people describe problems they want solved.
- Create the tools file by running:
touch backend/app/agents/tools.py
- Open backend/app/agents/tools.py and add the imports and Reddit authentication helper:
import os
import httpx
# Reddit OAuth2 token endpoint
REDDIT_TOKEN_URL = "https://www.reddit.com/api/v1/access_token"
# Reddit API base for authenticated requests
REDDIT_API_BASE = "https://oauth.reddit.com"
async def _get_reddit_token() -> str:
"""Obtain a Reddit OAuth2 access token using client credentials flow."""
client_id = os.getenv("REDDIT_CLIENT_ID", "")
client_secret = os.getenv("REDDIT_CLIENT_SECRET", "")
async with httpx.AsyncClient() as client:
# POST with HTTP Basic Auth (client_id:client_secret)
response = await client.post(
REDDIT_TOKEN_URL,
data={"grant_type": "client_credentials"},
auth=(client_id, client_secret),
headers={"User-Agent": "IdeaForge:v1.0.0 (by /u/IdeaForgeBot)"},
)
response.raise_for_status()
return response.json()["access_token"]
What does this code do?
The _get_reddit_token function performs the OAuth2 client credentials flow. It sends a POST request with your client ID and secret as HTTP Basic Auth to Reddit's token endpoint.
Reddit requires a custom User-Agent header in the format <platform>:<app ID>:<version string> (by /u/<reddit username>). Traffic without a proper User-Agent will be blocked.
- Below the token helper, add the Reddit search function:
async def reddit_search(query: str, subreddits: list[str]) -> list[dict]:
"""Search Reddit for posts matching a query across specified subreddits."""
token = await _get_reddit_token()
headers = {
"Authorization": f"Bearer {token}",
"User-Agent": "IdeaForge:v1.0.0 (by /u/IdeaForgeBot)",
}
results = []
async with httpx.AsyncClient() as client:
for subreddit in subreddits:
# Search within each subreddit for relevant posts
response = await client.get(
f"{REDDIT_API_BASE}/r/{subreddit}/search",
headers=headers,
params={
"q": query,
"sort": "relevance",
"limit": 10,
"restrict_sr": "true",
},
)
if response.status_code == 200:
posts = response.json().get("data", {}).get("children", [])
for post in posts:
post_data = post.get("data", {})
results.append({
"source": "reddit",
"subreddit": subreddit,
"title": post_data.get("title", ""),
"selftext": post_data.get("selftext", "")[:500],
"score": post_data.get("score", 0),
"num_comments": post_data.get("num_comments", 0),
"url": post_data.get("url", ""),
})
return results
What does this code do?
The reddit_search function takes a search query and a list of subreddit names. It loops through each subreddit and hits Reddit's search endpoint with the Bearer token obtained from the helper above.
Each post result is normalized into a dictionary with source, title, text snippet, score, comment count, and URL. The restrict_sr parameter ensures results come only from the specified subreddit, not site-wide.
- Save backend/app/agents/tools.py.
Getting a 401 or 403 from Reddit?
- Double check that REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET are set correctly in your backend/.env file.
- Make sure your Reddit app at reddit.com/prefs/apps is registered as a "script" or "web app" type with a valid secret.
- Help me debug my Reddit OAuth2 credentials
Build the HackerNews search tool
The HackerNews Algolia Search API is free and requires no authentication. It gives your agent access to technical discussions, "Show HN" posts, and "Ask HN" threads where developers describe the tools and workflows they want.
- In backend/app/agents/tools.py, add the HackerNews search function below the Reddit tool:
# HackerNews Algolia Search API (free, no auth required)
HN_SEARCH_URL = "http://hn.algolia.com/api/v1/search"
async def hackernews_search(query: str) -> list[dict]:
"""Search HackerNews for stories matching a query via the Algolia API."""
async with httpx.AsyncClient() as client:
response = await client.get(
HN_SEARCH_URL,
params={
"query": query,
"tags": "story",
"hitsPerPage": 10,
},
)
response.raise_for_status()
data = response.json()
results = []
for hit in data.get("hits", []):
results.append({
"source": "hackernews",
"title": hit.get("title", ""),
"url": hit.get("url", ""),
"points": hit.get("points", 0),
"num_comments": hit.get("num_comments", 0),
"object_id": hit.get("objectID", ""),
})
return results
What does this code do?
The hackernews_search function queries the Algolia-powered HN Search API at http://hn.algolia.com/api/v1/search.
- tags is set to story to filter out comments and only return top-level posts.
- hitsPerPage limits results to 10 per query to stay efficient.
- Each result is normalized into a dictionary with source, title, URL, points, and comment count.
- Save backend/app/agents/tools.py.
Getting a connection error from the HN API?
- The HN Search API uses http:// (not https://). Check that your URL matches exactly.
- Confirm your machine has internet access by opening a new terminal tab and testing the endpoint manually.
- Help me debug my HackerNews API connection
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before continuing.
ⓧ I'd like to double check the full code
Here is the complete backend/app/agents/state.py:
from typing import TypedDict
class AgentState(TypedDict):
"""Shared state container for the multi-agent graph.
Each field represents a stage of the idea discovery pipeline:
- user_interests: what the user cares about
- search_queries: dynamically generated queries for external APIs
- raw_findings: unprocessed results from Reddit and HackerNews
- analyzed_signals: scored and filtered demand signals
- final_ideas: synthesized, actionable project ideas
"""
user_interests: list[str]
search_queries: list[str]
raw_findings: list[dict]
analyzed_signals: list[dict]
final_ideas: list[dict]
Here is the complete backend/app/agents/tools.py:
import os
import httpx
# Reddit OAuth2 token endpoint
REDDIT_TOKEN_URL = "https://www.reddit.com/api/v1/access_token"
# Reddit API base for authenticated requests
REDDIT_API_BASE = "https://oauth.reddit.com"
async def _get_reddit_token() -> str:
"""Obtain a Reddit OAuth2 access token using client credentials flow."""
client_id = os.getenv("REDDIT_CLIENT_ID", "")
client_secret = os.getenv("REDDIT_CLIENT_SECRET", "")
async with httpx.AsyncClient() as client:
# POST with HTTP Basic Auth (client_id:client_secret)
response = await client.post(
REDDIT_TOKEN_URL,
data={"grant_type": "client_credentials"},
auth=(client_id, client_secret),
headers={"User-Agent": "IdeaForge:v1.0.0 (by /u/IdeaForgeBot)"},
)
response.raise_for_status()
return response.json()["access_token"]
async def reddit_search(query: str, subreddits: list[str]) -> list[dict]:
"""Search Reddit for posts matching a query across specified subreddits."""
token = await _get_reddit_token()
headers = {
"Authorization": f"Bearer {token}",
"User-Agent": "IdeaForge:v1.0.0 (by /u/IdeaForgeBot)",
}
results = []
async with httpx.AsyncClient() as client:
for subreddit in subreddits:
# Search within each subreddit for relevant posts
response = await client.get(
f"{REDDIT_API_BASE}/r/{subreddit}/search",
headers=headers,
params={
"q": query,
"sort": "relevance",
"limit": 10,
"restrict_sr": "true",
},
)
if response.status_code == 200:
posts = response.json().get("data", {}).get("children", [])
for post in posts:
post_data = post.get("data", {})
results.append({
"source": "reddit",
"subreddit": subreddit,
"title": post_data.get("title", ""),
"selftext": post_data.get("selftext", "")[:500],
"score": post_data.get("score", 0),
"num_comments": post_data.get("num_comments", 0),
"url": post_data.get("url", ""),
})
return results
# HackerNews Algolia Search API (free, no auth required)
HN_SEARCH_URL = "http://hn.algolia.com/api/v1/search"
async def hackernews_search(query: str) -> list[dict]:
"""Search HackerNews for stories matching a query via the Algolia API."""
async with httpx.AsyncClient() as client:
response = await client.get(
HN_SEARCH_URL,
params={
"query": query,
"tags": "story",
"hitsPerPage": 10,
},
)
response.raise_for_status()
data = response.json()
results = []
for hit in data.get("hits", []):
results.append({
"source": "hackernews",
"title": hit.get("title", ""),
"url": hit.get("url", ""),
"points": hit.get("points", 0),
"num_comments": hit.get("num_comments", 0),
"object_id": hit.get("objectID", ""),
})
return results
Your agent now has a typed data contract and two real-world search tools. Next up, you'll build the graph nodes that use these tools to scout, analyze, and architect project ideas.
Build the Agent Graph Nodes
Your state schema and search tools are ready. Now the agent needs intelligence. Each node in a LangGraph graph is a function that receives the current state, performs work, and returns an updated state.
In this step, you will build three specialized node functions that use Gemini to think through the idea discovery process. The Scout generates search queries, the Analyst scores findings, and the Architect synthesizes final project ideas.
In this step, get ready to:
- Implement the Scout node to generate dynamic search queries from user interests.
- Implement the Analyst node to score and filter raw findings for demand signals.
- Implement the Architect node to synthesize actionable project ideas with evidence.
Set up the nodes file with imports and the Gemini model
- Create a new file called nodes.py inside your backend/app/agents/ directory.
- Add the imports and model initialization by pasting the following code:
import os
import json
from langchain_google_genai import ChatGoogleGenerativeAI
from app.agents.state import AgentState
from app.agents.tools import reddit_search, hackernews_search
# Initialize Gemini 2.5 Flash for all node functions
llm = ChatGoogleGenerativeAI(
model="gemini-2.5-flash",
api_key=os.getenv("GEMINI_API_KEY")
)
What does this code do?
This sets up the shared LLM instance that all three nodes will use. ChatGoogleGenerativeAI is the LangChain wrapper around Google's Gemini models.
Initializing it once at module level means every node reuses the same client connection. The GEMINI_API_KEY is loaded from your .env file through the environment variable you set up earlier.
- Save nodes.py.
- You won't see a change yet. The next chunk adds the first node function that uses this model.
Implement the Scout node
The Scout is responsible for turning vague user interests into specific, targeted search queries. It also selects which subreddits to search based on the topic.
- In nodes.py, add the following scout_node function below the llm initialization:
async def scout_node(state: AgentState) -> AgentState:
"""Generate dynamic search queries and select subreddits based on user input."""
# Build a prompt that asks Gemini to create targeted search queries
prompt = (
f"Based on these user interests: {state['user_interests']}\n\n"
"Generate 5 specific search queries to find project ideas and demand signals "
"on Reddit and HackerNews. Also suggest 3-5 relevant subreddits to search.\n\n"
"Return valid JSON with this format:\n"
'{"queries": ["query1", "query2"], "subreddits": ["subreddit1", "subreddit2"]}'
)
# Ask Gemini to generate search queries from user interests
response = await llm.ainvoke(prompt)
# Parse the generated queries from Gemini's response
try:
content = response.content
start = content.find("{")
end = content.rfind("}") + 1
data = json.loads(content[start:end])
queries = data.get("queries", [])
subreddits = data.get("subreddits", ["python", "webdev", "learnprogramming"])
except (json.JSONDecodeError, ValueError):
# Fallback: create basic queries from interests directly
queries = [f"{interest} project ideas" for interest in state["user_interests"]]
subreddits = ["python", "webdev", "learnprogramming"]
# Execute searches across Reddit and HackerNews with generated queries
raw_findings = []
for query in queries:
reddit_results = await reddit_search(query, subreddits)
hn_results = await hackernews_search(query)
raw_findings.extend(reddit_results)
raw_findings.extend(hn_results)
return {
**state,
"search_queries": queries,
"raw_findings": raw_findings,
}
What does this code do?
The Scout node takes the user's raw interests and uses Gemini to brainstorm targeted search queries. This is more effective than searching for the interests directly because the LLM understands how people phrase questions on forums.
The try/except block handles cases where Gemini returns malformed JSON. The fallback creates simple queries so the pipeline never breaks entirely. After generating queries, the node calls both search tools and collects all raw findings into the state.
- Save nodes.py.
- You won't see output yet. The Analyst node in the next chunk consumes these raw findings.
Seeing an import error?
Make sure langchain-google-genai is listed in your requirements.txt and installed in your virtual environment. Also confirm that state.py and tools.py exist in the same backend/app/agents/ directory.
Help me debug an import error in my nodes.py file.
Implement the Analyst node
The Analyst receives all the raw community discussions and uses Gemini to score each one for demand strength, pain points, and feasibility. Only high-quality signals pass through.
- In nodes.py, add the following analyst_node function below scout_node:
async def analyst_node(state: AgentState) -> AgentState:
"""Score and filter raw findings for demand signals, pain points, and feasibility."""
# Limit findings to avoid exceeding token limits
findings_text = json.dumps(state["raw_findings"][:50], indent=2)
# Build a prompt asking Gemini to analyze and score each finding
prompt = (
"Analyze these community discussions and score them for:\n"
"1. Demand signal strength (are people asking for this?)\n"
"2. Pain points mentioned\n"
"3. Feasibility for a solo developer\n\n"
f"Discussions:\n{findings_text}\n\n"
"Return a valid JSON array of analyzed signals:\n"
'[{"title": "...", "source": "...", "demand_score": 1-10, '
'"pain_points": ["..."], "feasibility": "high/medium/low", "summary": "..."}]'
)
# Ask Gemini to score and categorize the raw findings
response = await llm.ainvoke(prompt)
# Parse the analyzed signals from Gemini's response
try:
content = response.content
start = content.find("[")
end = content.rfind("]") + 1
analyzed_signals = json.loads(content[start:end])
except (json.JSONDecodeError, ValueError):
analyzed_signals = []
# Filter to keep only high-demand signals (score 6 or above)
filtered_signals = [
signal for signal in analyzed_signals
if signal.get("demand_score", 0) >= 6
]
return {
**state,
"analyzed_signals": filtered_signals if filtered_signals else analyzed_signals[:10],
}
What does this code do?
The Analyst node takes the raw findings from the Scout and asks Gemini to evaluate each discussion for real demand. A demand_score of 6 or higher means people are genuinely asking for a solution.
The filtering step at the end ensures only strong signals reach the Architect. If no findings score above 6, it keeps the top 10 as a fallback so the pipeline still produces output.
- Save nodes.py.
- You won't see output yet. The final Architect node uses these analyzed signals to build project ideas.
Worried about token limits?
The [:50] slice on raw_findings prevents sending too much data to Gemini. If you see token-related errors later during testing, reduce this number to 20 or 30.
Help me understand the token limits for Gemini 2.5 Flash and how to handle them.
Implement the Architect node
The Architect is the final brain in the pipeline. It combines the high-quality demand signals with the user's own skills to produce concrete, buildable project ideas with real evidence backing each one.
- In nodes.py, add the following architect_node function below analyst_node:
async def architect_node(state: AgentState) -> AgentState:
"""Synthesize filtered signals and user skills into actionable project ideas."""
# Prepare the analyzed signals for the prompt
signals_text = json.dumps(state["analyzed_signals"], indent=2)
# Build a prompt asking Gemini to create concrete project ideas
prompt = (
f"Based on these validated demand signals:\n{signals_text}\n\n"
f"And the user's skills/interests: {state['user_interests']}\n\n"
"Generate 3-5 concrete, actionable project ideas. Each idea must:\n"
"- Solve a real pain point from the signals\n"
"- Match the user's skill level\n"
"- Include evidence from the community discussions\n"
"- Be buildable by a solo developer in 2-8 weeks\n\n"
"Return a valid JSON array:\n"
'[{"title": "...", "description": "...", "pain_point": "...", '
'"evidence": [{"source": "...", "quote": "..."}], '
'"tech_stack": ["..."], "difficulty": "beginner/intermediate/advanced", '
'"estimated_weeks": 4, "why_this_matches": "..."}]'
)
# Ask Gemini to synthesize ideas from signals and user profile
response = await llm.ainvoke(prompt)
# Parse the final project ideas from Gemini's response
try:
content = response.content
start = content.find("[")
end = content.rfind("]") + 1
final_ideas = json.loads(content[start:end])
except (json.JSONDecodeError, ValueError):
final_ideas = []
return {
**state,
"final_ideas": final_ideas,
}
What does this code do?
The Architect node takes the filtered demand signals from the Analyst and the user's original interests. It asks Gemini to connect the dots and produce 3-5 project ideas that solve real problems people have expressed.
Each idea includes a tech_stack, time estimate, difficulty level, and direct evidence from the community discussions. This makes the output actionable rather than generic.
- Save nodes.py.
- Confirm the file exists by running:
ls backend/app/agents/nodes.py
You should see backend/app/agents/nodes.py in the output.
Seeing a syntax error in nodes.py?
Check that all f-string quotes are properly escaped. The JSON format strings inside the prompts use single quotes for JSON keys to avoid clashing with the outer double quotes of the Python string.
Help me fix a syntax error in my nodes.py Architect node function.
✔️ Awesome, I've got everything!
Great. Double check that you have saved nodes.py with all three node functions.
ⓧ I'd like to double check the full code
Here is the complete backend/app/agents/nodes.py file with all three nodes:
import os
import json
from langchain_google_genai import ChatGoogleGenerativeAI
from app.agents.state import AgentState
from app.agents.tools import reddit_search, hackernews_search
# Initialize Gemini 2.5 Flash for all node functions
llm = ChatGoogleGenerativeAI(
model="gemini-2.5-flash",
api_key=os.getenv("GEMINI_API_KEY")
)
async def scout_node(state: AgentState) -> AgentState:
"""Generate dynamic search queries and select subreddits based on user input."""
# Build a prompt that asks Gemini to create targeted search queries
prompt = (
f"Based on these user interests: {state['user_interests']}\n\n"
"Generate 5 specific search queries to find project ideas and demand signals "
"on Reddit and HackerNews. Also suggest 3-5 relevant subreddits to search.\n\n"
"Return valid JSON with this format:\n"
'{"queries": ["query1", "query2"], "subreddits": ["subreddit1", "subreddit2"]}'
)
# Ask Gemini to generate search queries from user interests
response = await llm.ainvoke(prompt)
# Parse the generated queries from Gemini's response
try:
content = response.content
start = content.find("{")
end = content.rfind("}") + 1
data = json.loads(content[start:end])
queries = data.get("queries", [])
subreddits = data.get("subreddits", ["python", "webdev", "learnprogramming"])
except (json.JSONDecodeError, ValueError):
# Fallback: create basic queries from interests directly
queries = [f"{interest} project ideas" for interest in state["user_interests"]]
subreddits = ["python", "webdev", "learnprogramming"]
# Execute searches across Reddit and HackerNews with generated queries
raw_findings = []
for query in queries:
reddit_results = await reddit_search(query, subreddits)
hn_results = await hackernews_search(query)
raw_findings.extend(reddit_results)
raw_findings.extend(hn_results)
return {
**state,
"search_queries": queries,
"raw_findings": raw_findings,
}
async def analyst_node(state: AgentState) -> AgentState:
"""Score and filter raw findings for demand signals, pain points, and feasibility."""
# Limit findings to avoid exceeding token limits
findings_text = json.dumps(state["raw_findings"][:50], indent=2)
# Build a prompt asking Gemini to analyze and score each finding
prompt = (
"Analyze these community discussions and score them for:\n"
"1. Demand signal strength (are people asking for this?)\n"
"2. Pain points mentioned\n"
"3. Feasibility for a solo developer\n\n"
f"Discussions:\n{findings_text}\n\n"
"Return a valid JSON array of analyzed signals:\n"
'[{"title": "...", "source": "...", "demand_score": 1-10, '
'"pain_points": ["..."], "feasibility": "high/medium/low", "summary": "..."}]'
)
# Ask Gemini to score and categorize the raw findings
response = await llm.ainvoke(prompt)
# Parse the analyzed signals from Gemini's response
try:
content = response.content
start = content.find("[")
end = content.rfind("]") + 1
analyzed_signals = json.loads(content[start:end])
except (json.JSONDecodeError, ValueError):
analyzed_signals = []
# Filter to keep only high-demand signals (score 6 or above)
filtered_signals = [
signal for signal in analyzed_signals
if signal.get("demand_score", 0) >= 6
]
return {
**state,
"analyzed_signals": filtered_signals if filtered_signals else analyzed_signals[:10],
}
async def architect_node(state: AgentState) -> AgentState:
"""Synthesize filtered signals and user skills into actionable project ideas."""
# Prepare the analyzed signals for the prompt
signals_text = json.dumps(state["analyzed_signals"], indent=2)
# Build a prompt asking Gemini to create concrete project ideas
prompt = (
f"Based on these validated demand signals:\n{signals_text}\n\n"
f"And the user's skills/interests: {state['user_interests']}\n\n"
"Generate 3-5 concrete, actionable project ideas. Each idea must:\n"
"- Solve a real pain point from the signals\n"
"- Match the user's skill level\n"
"- Include evidence from the community discussions\n"
"- Be buildable by a solo developer in 2-8 weeks\n\n"
"Return a valid JSON array:\n"
'[{"title": "...", "description": "...", "pain_point": "...", '
'"evidence": [{"source": "...", "quote": "..."}], '
'"tech_stack": ["..."], "difficulty": "beginner/intermediate/advanced", '
'"estimated_weeks": 4, "why_this_matches": "..."}]'
)
# Ask Gemini to synthesize ideas from signals and user profile
response = await llm.ainvoke(prompt)
# Parse the final project ideas from Gemini's response
try:
content = response.content
start = content.find("[")
end = content.rfind("]") + 1
final_ideas = json.loads(content[start:end])
except (json.JSONDecodeError, ValueError):
final_ideas = []
return {
**state,
"final_ideas": final_ideas,
}
Your three agent brains are ready. Next up, you will wire them together into a proper StateGraph with conditional edges so the agents collaborate in sequence.
Assemble and Test the Agent Graph
You have three specialized agent nodes ready to go. The Scout finds relevant community discussions, the Analyst scores them for demand signals, and the Architect turns those signals into project ideas.
Now you need to wire these nodes into a single executable pipeline. LangGraph uses a StateGraph that defines exactly how data flows between nodes and under what conditions the graph should loop back or finish.
In this step, get ready to:
- Wire Scout, Analyst, and Architect into a StateGraph with conditional edges.
- Add a reflection loop that sends the graph back to Scout when findings are insufficient.
- Test the compiled graph locally with sample inputs.
Wire nodes into a StateGraph with conditional edges
A StateGraph is a directed graph where each node is a function that reads and writes to a shared state dictionary. Edges define which node runs next. Conditional edges let you choose the next node at runtime based on the current state.
- Create a new file called graph.py inside your backend/app/agents/ folder.
- Add the following code to define the graph assembly and conditional routing:
from typing import Literal
from langgraph.graph import StateGraph, START, END
from app.agents.state import AgentState
from app.agents.nodes import scout_node, analyst_node, architect_node
# Minimum number of analyzed signals before proceeding to Architect
MIN_SIGNALS_THRESHOLD = 3
def should_reflect(state: AgentState) -> Literal["scout", "architect"]:
"""Route back to Scout if Analyst found too few demand signals."""
signals = state.get("analyzed_signals", [])
if len(signals) < MIN_SIGNALS_THRESHOLD:
return "scout"
return "architect"
def build_graph() -> StateGraph:
"""Assemble the multi-agent graph with conditional reflection."""
# Initialize the graph with our shared state schema
graph = StateGraph(AgentState)
# Register each agent as a named node
graph.add_node("scout", scout_node)
graph.add_node("analyst", analyst_node)
graph.add_node("architect", architect_node)
# Define the flow: START -> Scout -> Analyst
graph.add_edge(START, "scout")
graph.add_edge("scout", "analyst")
# Conditional edge: Analyst decides next step based on signal quality
graph.add_conditional_edges(
"analyst",
should_reflect,
{"scout": "scout", "architect": "architect"},
)
# Architect is the final node
graph.add_edge("architect", END)
return graph
# Compile the graph into a runnable
idea_graph = build_graph().compile()
What does this code do?
The graph defines a pipeline: Scout searches for community discussions, then Analyst scores the findings. If fewer than 3 quality signals are found, the graph loops back to Scout with updated queries. Otherwise it moves on to Architect to synthesize final project ideas.
The add_conditional_edges call takes the source node, a routing function, and a dictionary mapping return values to target node names. This is what creates the reflection loop.
Why a reflection loop instead of a single pass?
Real community data is noisy. A single search query might return irrelevant or low-quality results. The reflection loop gives the Scout a second chance to refine its queries and look in different subreddits, producing better raw material for the Architect.
The MIN_SIGNALS_THRESHOLD constant (set to 3) is the quality gate. You can tune this number later based on how your agent performs.
Add recursion limits and error handling
The reflection loop can theoretically cycle forever if the Scout never finds enough signals. LangGraph prevents this with a recursion_limit setting that caps how many node transitions the graph can execute in a single run.
The recursion limit is passed as a runtime config when you invoke the graph. The default is 25, but for a 3-node graph with a single loop, a limit of 10 is generous enough to allow 2-3 reflection cycles while preventing runaway execution.
- At the bottom of graph.py, add a helper function that invokes the graph with the recursion limit baked in:
# Maximum graph transitions allowed per run
RECURSION_LIMIT = 10
async def run_idea_graph(user_interests: list[str]) -> dict:
"""Execute the idea generation graph with safety limits."""
initial_state: AgentState = {
"user_interests": user_interests,
"search_queries": [],
"raw_findings": [],
"analyzed_signals": [],
"final_ideas": [],
}
# Pass recursion_limit in the config to prevent runaway loops
result = await idea_graph.ainvoke(
initial_state,
{"recursion_limit": RECURSION_LIMIT},
)
return result
What does this code do?
The run_idea_graph function is the public entry point other parts of the app will call. It constructs the initial empty state from the user's interests, then invokes the compiled graph asynchronously.
The recursion_limit config caps total graph steps at 10. If the graph hits this limit, LangGraph raises a GraphRecursionError instead of looping forever. The calling code can catch this and return partial results.
- Save graph.py.
Seeing an import error?
Make sure your virtual environment is activated and that langgraph is installed. Check requirements.txt includes langraph and run pip install -r requirements.txt again if needed.
help me fix my LangGraph import error
Test the compiled graph locally with sample inputs
Before connecting the graph to the API layer, run it directly from the terminal to confirm the full Scout-Analyst-Architect pipeline produces valid output.
- Create a test script by running this command from the backend/ directory:
touch test_graph.py
- Add the following test code to test_graph.py:
import asyncio
from dotenv import load_dotenv
from app.agents.graph import run_idea_graph
# Load environment variables from .env
load_dotenv()
async def main():
"""Run the graph with sample interests and print results."""
print("Starting idea generation graph...")
print("="* 50)
result = await run_idea_graph(
user_interests=["machine learning", "web development", "Python"]
)
print(f"\nSearch queries generated: {len(result['search_queries'])}")
print(f"Raw findings collected: {len(result['raw_findings'])}")
print(f"Analyzed signals: {len(result['analyzed_signals'])}")
print(f"Final ideas generated: {len(result['final_ideas'])}")
print("\n" + "=" * 50)
for i, idea in enumerate(result["final_ideas"], 1):
print(f"\nIdea {i}: {idea.get('title', 'Untitled')}")
print(f" Summary: {idea.get('summary', 'No summary')}")
if __name__ == "__main__":
asyncio.run(main())
What does this test do?
This script loads your API keys from .env, invokes the full multi-agent pipeline with sample interests, and prints a summary of what each stage produced. It exercises the entire graph end-to-end.
- Run the test script by executing this command from the backend/ directory:
python test_graph.py
The script takes 15-30 seconds to complete because it calls the Gemini API and Reddit/HackerNews endpoints. You should see output showing search queries generated, findings collected, and 3-5 final project ideas printed with titles.
Graph hits recursion limit?
If the Analyst consistently finds fewer than 3 signals, the graph loops until it hits the recursion limit. This usually means the Reddit or HackerNews search tools are returning empty results.
Check that your REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in .env are correct and that the Reddit OAuth token endpoint is reachable.
help me debug why my agent graph hits the recursion limit
✔️ Awesome, I've got everything!
Great. Double check you have saved graph.py and that your test produced valid ideas.
ⓧ I'd like to double check the full code
Here is the complete backend/app/agents/graph.py file:
from typing import Literal
from langgraph.graph import StateGraph, START, END
from app.agents.state import AgentState
from app.agents.nodes import scout_node, analyst_node, architect_node
# Minimum number of analyzed signals before proceeding to Architect
MIN_SIGNALS_THRESHOLD = 3
def should_reflect(state: AgentState) -> Literal["scout", "architect"]:
"""Route back to Scout if Analyst found too few demand signals."""
signals = state.get("analyzed_signals", [])
if len(signals) < MIN_SIGNALS_THRESHOLD:
return "scout"
return "architect"
def build_graph() -> StateGraph:
"""Assemble the multi-agent graph with conditional reflection."""
# Initialize the graph with our shared state schema
graph = StateGraph(AgentState)
# Register each agent as a named node
graph.add_node("scout", scout_node)
graph.add_node("analyst", analyst_node)
graph.add_node("architect", architect_node)
# Define the flow: START -> Scout -> Analyst
graph.add_edge(START, "scout")
graph.add_edge("scout", "analyst")
# Conditional edge: Analyst decides next step based on signal quality
graph.add_conditional_edges(
"analyst",
should_reflect,
{"scout": "scout", "architect": "architect"},
)
# Architect is the final node
graph.add_edge("architect", END)
return graph
# Compile the graph into a runnable
idea_graph = build_graph().compile()
# Maximum graph transitions allowed per run
RECURSION_LIMIT = 10
async def run_idea_graph(user_interests: list[str]) -> dict:
"""Execute the idea generation graph with safety limits."""
initial_state: AgentState = {
"user_interests": user_interests,
"search_queries": [],
"raw_findings": [],
"analyzed_signals": [],
"final_ideas": [],
}
# Pass recursion_limit in the config to prevent runaway loops
result = await idea_graph.ainvoke(
initial_state,
{"recursion_limit": RECURSION_LIMIT},
)
return result
Your multi-agent pipeline is assembled and proven to work end-to-end. Next up, you will wrap this graph in a FastAPI application with proper request/response models and a polling endpoint for progressive result delivery.
Build the FastAPI Application
Your multi-agent graph is compiled and tested. It can take user interests and return validated project ideas. But right now, the only way to use it is by running a Python script locally.
To turn this into a real product, you need a web API that any frontend can talk to. In this step, you'll wrap the LangGraph agent in a FastAPI application with proper project structure, typed request/response models, and a polling endpoint that lets the frontend track progress while agents work.
In this step, get ready to:
- Create the FastAPI app with a clean project structure (routers, schemas).
- Define Pydantic v2 models and implement the idea generation and polling endpoints.
- Add the Mangum handler for AWS Lambda compatibility and test locally.
Create the project structure and Pydantic schemas
A well-structured FastAPI project separates concerns into distinct directories. Routers hold endpoint definitions. Schemas hold data models. This keeps each file focused on one responsibility.
- Create the new directories and files by running these commands from your backend folder:
mkdir -p app/routers app/schemas
touch app/routers/__init__.py app/schemas/__init__.py
touch app/routers/ideas.py app/schemas/ideas.py
touch app/main.py
- Confirm the new files exist by running:
ls app/routers app/schemas
You should see __init__.py and ideas.py listed under both directories.
Why separate routers and schemas?
As your API grows, keeping endpoints in one file becomes unmanageable. FastAPI's APIRouter lets you define groups of related endpoints in separate files, then plug them into the main app.
Schemas (Pydantic models) go in their own directory so both routers and services can import them without circular dependencies.
Now you'll define the data shapes that your API accepts and returns. Pydantic v2 models give you automatic request validation and serialization with zero boilerplate.
- Open backend/app/schemas/ideas.py in your code editor and add the following code:
from pydantic import BaseModel
# Request model for the idea generation endpoint
class GenerateIdeasRequest(BaseModel):
interests: list[str]
tech_stack: list[str]
# Response model returned by both endpoints
class IdeaResponse(BaseModel):
job_id: str
status: str
stage: str | None = None
ideas: list[dict] | None = None
What does this code do?
- GenerateIdeasRequest defines what the frontend sends: a list of interests and a list of tech stack items. FastAPI automatically rejects requests missing these fields.
- IdeaResponse defines what the API returns: a job ID for tracking, the current status, the processing stage, and the final ideas once ready.
- The | None = None syntax makes fields optional. The polling endpoint returns stage and ideas only when they're available.
- Save backend/app/schemas/ideas.py.
Seeing a syntax error on list[str]?
The list[str] syntax requires Python 3.9+. The str | None union syntax requires Python 3.10+. Make sure you're running Python 3.11 or higher in your virtual environment.
help me fix Python version issues with my Pydantic models
Implement the idea generation and polling endpoints
The generate-ideas endpoint kicks off the LangGraph agent in the background and returns a job ID immediately. The polling endpoint lets the frontend check progress every few seconds until results are ready.
- Open backend/app/routers/ideas.py and add the following code:
import uuid
import asyncio
from fastapi import APIRouter
from app.schemas.ideas import GenerateIdeasRequest, IdeaResponse
from app.agents.graph import graph
router = APIRouter()
# In-memory job store for tracking generation progress
jobs: dict[str, dict] = {}
What does this code do?
- APIRouter() creates a mini-app that holds related endpoints. You'll plug this into the main FastAPI app later.
- The jobs dictionary acts as an in-memory store tracking each generation request's progress. In production, you'd use Redis or DynamoDB, but this works for Lambda's single-invocation model.
- The import of graph from app.agents.graph is the compiled LangGraph you built in the previous step.
Now add the background task helper and the POST endpoint that triggers idea generation.
- Below the jobs dictionary, add the background runner and POST endpoint:
async def run_graph(job_id: str, request: GenerateIdeasRequest):
"""Run the LangGraph agent in the background and update job status."""
try:
jobs[job_id]["stage"] = "scouting"
result = await graph.ainvoke({
"user_interests": request.interests,
"search_queries": [],
"raw_findings": [],
"analyzed_signals": [],
"final_ideas": []
})
jobs[job_id]["status"] = "complete"
jobs[job_id]["stage"] = "complete"
jobs[job_id]["ideas"] = result["final_ideas"]
except Exception as e:
jobs[job_id]["status"] = "error"
jobs[job_id]["stage"] = str(e)
@router.post("/api/generate-ideas", response_model=IdeaResponse)
async def generate_ideas(request: GenerateIdeasRequest):
"""Start idea generation and return a job ID for polling."""
# Create a unique job ID for tracking
job_id = str(uuid.uuid4())
jobs[job_id] = {"status": "running", "stage": "scouting", "ideas": None}
# Launch the graph in the background
asyncio.create_task(run_graph(job_id, request))
return IdeaResponse(job_id=job_id, status="running", stage="scouting")
What does this code do?
- run_graph invokes the compiled LangGraph asynchronously. It populates the initial state with the user's interests and empty lists for the graph to fill in as it runs through Scout, Analyst, and Architect nodes.
- The POST endpoint creates a job entry, fires off the graph in the background with asyncio.create_task, and immediately returns the job ID. The frontend never waits for the full agent run to finish.
- If the graph raises an exception, the job status is set to error so the frontend can display a meaningful message.
The final piece is the GET endpoint that the frontend polls to track progress.
- Below the generate_ideas function, add the polling endpoint:
@router.get("/api/status/{job_id}", response_model=IdeaResponse)
async def get_status(job_id: str):
"""Return current job progress for frontend polling."""
job = jobs.get(job_id)
if not job:
return IdeaResponse(job_id=job_id, status="not_found")
return IdeaResponse(
job_id=job_id,
status=job["status"],
stage=job.get("stage"),
ideas=job.get("ideas")
)
What does this code do?
- The GET endpoint looks up the job by its ID in the in-memory store. If found, it returns the current status, stage, and any completed ideas.
- The frontend calls this every 1.5 seconds. Each response tells it whether the agents are still scouting, analyzing, or if ideas are ready to display.
Why polling instead of streaming?
Mangum (the adapter that lets FastAPI run on Lambda) does not support streaming responses. A polling pattern works perfectly with Lambda's request-response model. The frontend sends a request, gets a quick answer, and asks again a moment later.
- Save backend/app/routers/ideas.py.
Import error on app.agents.graph?
Make sure your backend/app/agents/ directory has an __init__.py file. Without it, Python can't resolve the import path.
help me fix import errors in my FastAPI router
Add the Mangum handler and test locally
The main application file ties everything together. It creates the FastAPI instance, includes your router, and wraps the app with Mangum so AWS Lambda can invoke it.
- Open backend/app/main.py and add the following code:
from fastapi import FastAPI
from mangum import Mangum
from app.routers import ideas
# Create the FastAPI application
app = FastAPI(title="IdeaForge API", version="1.0.0")
# Include endpoint routers
app.include_router(ideas.router)
# Mangum handler wraps the app for AWS Lambda compatibility
handler = Mangum(app, lifespan="off")
What does this code do?
- FastAPI(title="IdeaForge API", version="1.0.0") creates the app instance. The title and version appear in the auto-generated Swagger docs at /docs.
- app.include_router(ideas.router) plugs in all the endpoints you defined in the ideas router.
- Mangum(app, lifespan="off") creates the Lambda handler. It translates API Gateway events into ASGI requests that FastAPI understands. The lifespan="off" parameter disables startup/shutdown lifecycle events that don't apply in Lambda's execution model.
- Save backend/app/main.py.
- Test the app locally by running this command from the backend folder:
uvicorn app.main:app --reload
You should see output indicating the server is running on http://127.0.0.1:8000.
- Open your browser and navigate to http://127.0.0.1:8000/docs to see the auto-generated Swagger documentation.
You should see the IdeaForge API page with your two endpoints listed: POST /api/generate-ideas and GET /api/status/{job_id}.
Server won't start?
Make sure you're running the command from the backend directory (not backend/app). The module path app.main:app resolves relative to where you run the command.
Also confirm your virtual environment is activated and all dependencies from requirements.txt are installed.
help me debug my FastAPI server startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here's the complete code for each file you created in this step.
from pydantic import BaseModel
# Request model for the idea generation endpoint
class GenerateIdeasRequest(BaseModel):
interests: list[str]
tech_stack: list[str]
# Response model returned by both endpoints
class IdeaResponse(BaseModel):
job_id: str
status: str
stage: str | None = None
ideas: list[dict] | None = None
import uuid
import asyncio
from fastapi import APIRouter
from app.schemas.ideas import GenerateIdeasRequest, IdeaResponse
from app.agents.graph import graph
router = APIRouter()
# In-memory job store for tracking generation progress
jobs: dict[str, dict] = {}
async def run_graph(job_id: str, request: GenerateIdeasRequest):
"""Run the LangGraph agent in the background and update job status."""
try:
jobs[job_id]["stage"] = "scouting"
result = await graph.ainvoke({
"user_interests": request.interests,
"search_queries": [],
"raw_findings": [],
"analyzed_signals": [],
"final_ideas": []
})
jobs[job_id]["status"] = "complete"
jobs[job_id]["stage"] = "complete"
jobs[job_id]["ideas"] = result["final_ideas"]
except Exception as e:
jobs[job_id]["status"] = "error"
jobs[job_id]["stage"] = str(e)
@router.post("/api/generate-ideas", response_model=IdeaResponse)
async def generate_ideas(request: GenerateIdeasRequest):
"""Start idea generation and return a job ID for polling."""
# Create a unique job ID for tracking
job_id = str(uuid.uuid4())
jobs[job_id] = {"status": "running", "stage": "scouting", "ideas": None}
# Launch the graph in the background
asyncio.create_task(run_graph(job_id, request))
return IdeaResponse(job_id=job_id, status="running", stage="scouting")
@router.get("/api/status/{job_id}", response_model=IdeaResponse)
async def get_status(job_id: str):
"""Return current job progress for frontend polling."""
job = jobs.get(job_id)
if not job:
return IdeaResponse(job_id=job_id, status="not_found")
return IdeaResponse(
job_id=job_id,
status=job["status"],
stage=job.get("stage"),
ideas=job.get("ideas")
)
from fastapi import FastAPI
from mangum import Mangum
from app.routers import ideas
# Create the FastAPI application
app = FastAPI(title="IdeaForge API", version="1.0.0")
# Include endpoint routers
app.include_router(ideas.router)
# Mangum handler wraps the app for AWS Lambda compatibility
handler = Mangum(app, lifespan="off")
Your FastAPI backend is running locally with fully typed endpoints and a Lambda-ready handler. Next up, you'll package this into a Docker container and deploy it to AWS Lambda so the world can reach your agents.
Deploy the Backend to AWS Lambda
Your FastAPI backend runs locally, but a production application needs to be reachable from anywhere on the internet.
AWS Lambda lets you run your backend without managing servers, and the Always Free tier covers 1 million requests per month. In this step, you will containerize the FastAPI app with Docker, push it to Amazon ECR, create the Lambda function, and wire it up to an API Gateway HTTP API so the frontend can reach it.
In this step, get ready to:
- Write a Dockerfile that packages the backend for Lambda.
- Create the AWS infrastructure (IAM role, Lambda function, API Gateway HTTP API).
- Configure environment variables and verify the live endpoint.
Write the Dockerfile for Lambda
AWS Lambda can run container images up to 10 GB. You will use the official AWS base image for Python 3.11, which already includes the Lambda runtime API.
- Create a file called Dockerfile inside your backend/ folder by running:
touch backend/Dockerfile
- Open backend/Dockerfile in your editor and paste the following:
# Use the official AWS Lambda Python 3.11 base image
FROM public.ecr.aws/lambda/python:3.11
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY app/ ${LAMBDA_TASK_ROOT}/app/
# Set the handler to the Mangum-wrapped FastAPI app
CMD ["app.main.handler"]
What does this Dockerfile do?
It starts from the official public.ecr.aws/lambda/python:3.11 base image, which already includes the Lambda runtime interface client.
It copies your requirements.txt first and installs dependencies. Then it copies your app/ directory into the Lambda task root. The CMD tells Lambda to use app.main.handler as the entry point, which is the Mangum handler wrapping your FastAPI app.
- Save backend/Dockerfile.
- Confirm the file exists by running:
ls backend/Dockerfile
You should see backend/Dockerfile in the output.
Don't see the file?
Make sure you ran the touch command from the project root (the folder containing backend/).
Help me find my Dockerfile
Create the IAM execution role
Lambda needs an IAM execution role so it can write logs to CloudWatch. You will create a role with the AWSLambdaBasicExecutionRole managed policy attached.
- Create a trust policy file by running:
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
- Create the IAM role by running:
aws iam create-role \
--role-name ideaforge-lambda-role \
--assume-role-policy-document file://trust-policy.json
- Attach the basic execution policy by running:
aws iam attach-role-policy \
--role-name ideaforge-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
What do these commands do?
- The trust policy allows the Lambda service (lambda.amazonaws.com) to assume this role.
- The AWSLambdaBasicExecutionRole policy grants permission to write logs to CloudWatch. Without it, your function cannot log anything.
- Copy the role ARN from the create-role output. It looks like arn:aws:iam::123456789012:role/ideaforge-lambda-role. You will need it in the next substep.
Seeing an access denied error?
Your AWS CLI credentials may lack IAM permissions. Make sure the user or role you configured with aws configure has the IAMFullAccess or equivalent managed policy.
Help me fix my AWS CLI permissions
Build, push, and deploy the container image
Now you will build the Docker image, push it to Amazon ECR, create the Lambda function, set environment variables, and front it with an API Gateway HTTP API.
- Create an ECR repository by running:
aws ecr create-repository \
--repository-name ideaforge-backend \
--region [[AWS_REGION="us-east-1"]] \
--image-scanning-configuration scanOnPush=true \
--image-tag-mutability MUTABLE
- Authenticate Docker to ECR by running:
aws ecr get-login-password --region [[AWS_REGION="us-east-1"]] | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com
- Build the Docker image from the backend/ directory by running:
docker build -t ideaforge-backend backend/
- Tag and push the image to ECR by running:
docker tag ideaforge-backend:latest [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/ideaforge-backend:latest
docker push [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/ideaforge-backend:latest
What is happening here?
You are pushing your container image to a private registry (ECR) in the same region where your Lambda function will live. Lambda pulls the image from ECR when it initializes your function.
- Create the Lambda function from the container image by running:
aws lambda create-function \
--function-name ideaforge-backend \
--package-type Image \
--code ImageUri=[[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/ideaforge-backend:latest \
--role [[ROLE_ARN="arn:aws:iam::123456789012:role/ideaforge-lambda-role"]] \
--timeout 60 \
--memory-size 512
Why set timeout and memory?
The LangGraph agent calls external APIs (Reddit, HackerNews) and runs LLM inference. A 60-second timeout and 512 MB of memory give it room to complete without hitting resource limits.
- Wait about 30 seconds for the function to become active. Then configure the environment variables by running:
aws lambda update-function-configuration \
--function-name ideaforge-backend \
--environment "Variables={GEMINI_API_KEY=your-gemini-api-key,REDDIT_CLIENT_ID=your-reddit-client-id,REDDIT_CLIENT_SECRET=your-reddit-client-secret,SUPABASE_URL=your-supabase-url,SUPABASE_ANON_KEY=your-supabase-anon-key}"
Replace with your actual values
Substitute the placeholder values with the real credentials from your backend/.env file. Do not wrap values in quotes inside the Variables={} block.
- Create the API Gateway HTTP API using quick create by running:
aws apigatewayv2 create-api \
--name ideaforge-api \
--protocol-type HTTP \
--target arn:aws:lambda:[[AWS_REGION="us-east-1"]]:[[AWS_ACCOUNT_ID="123456789012"]]:function:ideaforge-backend
What does quick create do?
The --target flag tells API Gateway to use quick create mode. It automatically creates a $default catch-all route, an AWS_PROXY integration pointing at your Lambda, and a $default stage that auto-deploys changes.
- Note the ApiEndpoint value in the output. It looks like https://abc123.execute-api.us-east-1.amazonaws.com. This is your BACKEND_API_URL.
- Grant API Gateway permission to invoke the Lambda function by running:
aws lambda add-permission \
--function-name ideaforge-backend \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:[[AWS_REGION="us-east-1"]]:[[AWS_ACCOUNT_ID="123456789012"]]:[[API_ID="your-api-id"]]/*"
Why add this permission?
Lambda functions have a resource-based policy that controls which services can invoke them. This command explicitly allows API Gateway to call your function.
Without it, API Gateway returns a 500 error because it lacks the authorization to trigger the Lambda.
- Verify the endpoint is live by running:
curl [[BACKEND_API_URL="https://abc123.execute-api.us-east-1.amazonaws.com"]]/docs
You should see the FastAPI auto-generated OpenAPI JSON response confirming that your backend is deployed and reachable.
Getting a 500 Internal Server Error?
- Check that the add-permission command completed successfully. The API ID in your --source-arn must match the ApiId from the create-api output.
- Verify your Lambda function is in the Active state by running aws lambda get-function --function-name ideaforge-backend and checking the State field.
- Check CloudWatch Logs for import errors. Missing Python packages usually mean requirements.txt is incomplete.
Help me debug my API Gateway 500 error
✔️ Awesome, I've got everything!
Great. Double check you have saved your Dockerfile and that your curl command returned a valid response from the API Gateway URL.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile for comparison:
# Use the official AWS Lambda Python 3.11 base image
FROM public.ecr.aws/lambda/python:3.11
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY app/ ${LAMBDA_TASK_ROOT}/app/
# Set the handler to the Mangum-wrapped FastAPI app
CMD ["app.main.handler"]
Your backend is live on the internet. Next up, you will set up Supabase for user authentication and persistent data storage.
Set Up Supabase Auth and Database
Your backend API is live and responding through API Gateway. Now you need a way for users to sign in, save their generated ideas, and come back to them later.
In this step, you will configure Supabase as your authentication and data layer. You will enable Google sign-in so users can log in with one click, then create the database tables that store user profiles and saved "voyages" (idea sessions).
In this step, get ready to:
- Configure the Google OAuth provider in Supabase.
- Create the database schema with profiles and voyages tables.
- Enable Row Level Security so users can only access their own data.
Configure Google OAuth in Supabase
Your IdeaForge app uses Google sign-in so users authenticate with their existing Google account. Supabase handles the entire OAuth flow for you. You just need to register a Google OAuth client and connect it to your Supabase project.
- Open the Google Cloud Console and select the project you want to use (or create a new one).
- Navigate to APIs & Services in the left sidebar, then click Credentials.
- Click Create Credentials at the top, then select OAuth client ID.
- Choose Web application as the application type.
- Under Authorized redirect URIs, add your Supabase project's callback URL. You can find this URL on the Google provider page in your Supabase dashboard.
- Click Create and copy the Client ID and Client Secret.
Where do I find the callback URL?
In your Supabase dashboard, go to Authentication then Providers. Click on Google to expand its settings. The callback URL is displayed there for you to copy into Google Cloud Console.
- Return to your Supabase dashboard.
- Click Authentication in the left sidebar.
- Click Providers.
- Find Google in the list and expand it.
- Toggle the provider to Enabled.
- Paste your Google Client ID into the Client ID field.
- Paste your Google Client Secret into the Client Secret field.
- Click Save.
What is OAuth?
OAuth is a protocol that lets users sign in to your app using their existing accounts (like Google) without sharing their password with you. Supabase handles the token exchange, session management, and user creation automatically.
Don't see Google in the providers list?
Make sure you are in the correct Supabase project. The providers page is at Authentication then Providers in the sidebar. If Google does not appear, scroll down past the default email provider. Help me enable Google OAuth in Supabase
Create the database schema
IdeaForge needs two tables. The profiles table stores each user's tech stack preferences. The voyages table stores saved idea sessions with their results.
- In your Supabase dashboard, click SQL Editor in the left sidebar.
- Click New query.
- Create the profiles table by pasting this SQL and clicking Run:
-- Create the profiles table for storing user tech stack info
create table profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
tech_stack text[],
created_at timestamptz default now()
);
-- Grant privileges to Supabase roles
GRANT SELECT, INSERT, UPDATE, DELETE ON public.profiles TO authenticated;
GRANT SELECT ON public.profiles TO anon;
What does this SQL do?
- id is a unique identifier for each profile row, auto-generated as a UUID.
- user_id links each profile to a user in Supabase's built-in auth.users table.
- tech_stack is a Postgres text array that stores multiple technology names (like ["Python", "React", "AWS"]).
- created_at automatically records when the profile was created.
- The GRANT statements give the authenticated role full CRUD access, while anon can only read.
- Clear the SQL Editor, then create the voyages table by pasting this SQL and clicking Run:
-- Create the voyages table for storing saved idea sessions
create table voyages (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
interests text[],
results jsonb,
created_at timestamptz default now()
);
-- Grant privileges to Supabase roles
GRANT SELECT, INSERT, UPDATE, DELETE ON public.voyages TO authenticated;
GRANT SELECT ON public.voyages TO anon;
What does the voyages table store?
- interests is a text array capturing what the user was interested in for this session.
- results is a jsonb column. It stores the full structured output from the AI agents (idea cards, evidence links, feasibility scores) as flexible JSON.
- Using jsonb lets you store complex, nested data without needing to define separate tables for every sub-object.
- Verify both tables exist by clicking Table Editor in the left sidebar.
- You should see profiles and voyages listed alongside the default Supabase tables.
Tables not appearing in Table Editor?
Make sure each SQL query ran successfully. Look for a green success message in the SQL Editor after clicking Run. If you see a red error, check for typos in column names or missing commas. Help me fix my SQL table creation error
Enable Row Level Security
Without Row Level Security (RLS), any authenticated user could read or modify any other user's data. RLS locks each row so only the owning user can access it.
- In the SQL Editor, paste and run the following SQL to enable RLS and create access policies for the profiles table:
-- Enable RLS on profiles
alter table profiles enable row level security;
-- Users can read their own profile
create policy "Users can view own profile"
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
-- Users can insert their own profile
create policy "Users can insert own profile"
on profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- Users can update their own profile
create policy "Users can update own profile"
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
How do RLS policies work?
Each policy uses auth.uid() to get the ID of the currently signed-in user from their JWT token. The using clause filters which rows the user can see. The with check clause validates that new or updated rows belong to the user.
Once RLS is enabled, no data is accessible through the API until you create at least one policy. This is a secure-by-default approach.
- Clear the SQL Editor, then paste and run the following SQL to enable RLS and create access policies for the voyages table:
-- Enable RLS on voyages
alter table voyages enable row level security;
-- Users can read their own voyages
create policy "Users can view own voyages"
on voyages for select
to authenticated
using ( (select auth.uid()) = user_id );
-- Users can insert their own voyages
create policy "Users can insert own voyages"
on voyages for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- Users can update their own voyages
create policy "Users can update own voyages"
on voyages for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
- Verify RLS is active by clicking Authentication in the sidebar, then Policies.
- You should see your profiles and voyages tables listed with RLS enabled and the policies you just created.
Policies not showing up?
Make sure each SQL block ran without errors. A common issue is running both the table creation and the RLS policies in the same query before the table exists. Run them sequentially. Help me debug my RLS policy setup
Test auth flow and database operations
Before building the frontend, confirm that authentication and database operations work correctly through the Supabase client.
- In the Supabase dashboard, click Authentication in the left sidebar, then click Users.
- Click Add user and select Create new user.
- Enter a test email and password, then click Create user.
- Copy the new user's UUID from the users list.
- Switch to the SQL Editor and test inserting a profile row for the test user by running:
-- Test inserting a profile (replace with your test user's UUID)
insert into profiles (user_id, tech_stack)
values ('your-test-user-uuid-here', '{"Python","FastAPI","React"}');
- Verify the insert by running:
select * from profiles;
- You should see one row with your test user's UUID and the tech stack array.
- Test the voyages table the same way:
-- Test inserting a voyage
insert into voyages (user_id, interests, results)
values (
'your-test-user-uuid-here',
'{"machine learning","web scraping"}',
'{"ideas": []}'
);
select * from voyages;
- You should see one voyage row with the interests array and an empty ideas JSON object.
- Clean up the test data by running:
delete from voyages;
delete from profiles;
Why test with SQL instead of the API?
The SQL Editor runs queries as the service role, which bypasses RLS. This confirms your schema works correctly. When users interact through the frontend (using the anon key), RLS policies will filter their access automatically.
Insert failing with a foreign key error?
The user_id column references auth.users. Make sure you copied the UUID of the test user you created in the Authentication section. It must be an exact match. Help me fix a foreign key constraint error in Supabase
Your authentication and database layer is fully configured. Users can sign in with Google, and their profiles and voyages are locked down so only they can access their own data. Next up, you will build the frontend that gives IdeaForge its distinctive explorer brand identity.
Build the Frontend Layout and Branding
Your backend is deployed, your database is secured, and Google OAuth is configured. Now it is time to give IdeaForge a face.
In this step, you will build the explorer/nautical themed frontend that makes IdeaForge feel like a real product. Dark navy backgrounds, gold accents, compass rose icons, and branded navigation will set this apart from every generic AI demo out there.
In this step, get ready to:
- Extend the Tailwind config with a nautical color palette.
- Build a branded navigation sidebar with explorer iconography.
- Create the landing page with Google OAuth sign-in.
Implement the nautical theme in Tailwind
Every strong brand starts with a consistent color system. The IdeaForge theme uses dark navy as its base and gold as its accent color.
- In your code editor, open frontend/tailwind.config.js.
- Replace the file contents with the following to extend Tailwind CSS with the nautical palette:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// Dark navy base tones
navy: {
900: '#0f172a',
800: '#1e293b',
700: '#334155',
},
// Gold accent tones
gold: {
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
},
},
},
},
plugins: [],
}
What does this code do?
The extend.colors block adds custom color tokens you can use anywhere in Tailwind classes. navy-900 (#0f172a) is the deepest background. gold-500 (#f59e0b) is the primary accent for interactive elements and branding highlights.
You will use these tokens throughout the layout, navigation, and landing page to keep the theme consistent without hardcoding hex values.
- Save frontend/tailwind.config.js.
Now update the global layout to apply the dark navy background across the entire application.
- Open frontend/app/layout.tsx and replace its contents with the following:
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Navigation from '@/components/Navigation'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'IdeaForge - Discover Your Next Project',
description: 'AI-powered project idea discovery engine',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-navy-900 text-white min-h-screen`}>
{/* Sidebar navigation */}
<Navigation />
{/* Main content area offset by nav width */}
<main className="ml-16 min-h-screen">
{children}
</main>
</body>
</html>
)
}
What does this code do?
The layout applies bg-navy-900 and text-white to the <body> so every page inherits the dark theme automatically.
The Navigation component (which you will build next) sits as a fixed sidebar. The ml-16 on the main content pushes it to the right of the 4rem-wide navigation bar.
- Save frontend/app/layout.tsx.
Seeing a module not found error?
The layout imports @/components/Navigation which does not exist yet. This is expected. You will create the Navigation component in the next substep. The error disappears once that file is in place.
Help me fix this module not found error
Build the navigation sidebar with branded icons
The sidebar is the spine of the IdeaForge interface. Each icon represents a core feature using explorer metaphors: a telescope for discovering ideas, an anchor for saved voyages, and a compass for the user profile.
- Create the components directory inside frontend/ if it does not already exist.
- Create a new file at frontend/components/Navigation.tsx and add the following:
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export default function Navigation() {
const pathname = usePathname()
return (
<nav className="fixed left-0 top-0 h-full w-16 bg-navy-800 flex flex-col items-center py-6 gap-8 border-r border-navy-700">
{/* Ship wheel logo */}
<div className="text-gold-500 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="8" />
<line x1="12" y1="1" x2="12" y2="4" />
<line x1="12" y1="20" x2="12" y2="23" />
<line x1="1" y1="12" x2="4" y2="12" />
<line x1="20" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
</svg>
</div>
{/* Telescope - Discover */}
<Link
href="/discover"
className={`p-2 rounded-lg transition-colors ${
pathname === '/discover' ? 'bg-navy-700 text-gold-500' : 'text-gray-400 hover:text-gold-400'
}`}
title="Discover"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</Link>
{/* Anchor - Saved Voyages */}
<Link
href="/voyages"
className={`p-2 rounded-lg transition-colors ${
pathname === '/voyages' ? 'bg-navy-700 text-gold-500' : 'text-gray-400 hover:text-gold-400'
}`}
title="Saved Voyages"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="5" r="3" />
<line x1="12" y1="8" x2="12" y2="21" />
<path d="M5 12h14" />
<path d="M5 12a7 7 0 0014 0" />
</svg>
</Link>
{/* Compass - Profile */}
<Link
href="/profile"
className={`p-2 rounded-lg transition-colors ${
pathname === '/profile' ? 'bg-navy-700 text-gold-500' : 'text-gray-400 hover:text-gold-400'
}`}
title="Profile"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
</Link>
</nav>
)
}
What does this code do?
The Navigation component renders a fixed sidebar with a ship wheel logo at the top followed by three navigation links.
- The telescope icon links to /discover for idea generation.
- The anchor icon links to /voyages for saved sessions.
- The compass icon links to /profile for user settings.
The usePathname hook highlights the active page with gold text and a darker background.
- Save frontend/components/Navigation.tsx.
- Start the dev server by running this command from the frontend/ directory:
npm run dev
- Open http://localhost:3000 in your browser. You should see a dark navy page with a narrow sidebar on the left containing gold icons.
Not seeing the sidebar or colors?
Make sure your tailwind.config.js file includes both './app/**/*.{js,ts,jsx,tsx,mdx}' and './components/**/*.{js,ts,jsx,tsx,mdx}' in the content array. Without these, Tailwind purges the custom utility classes.
Also verify that globals.css still contains the three Tailwind directives at the top: @tailwind base;, @tailwind components;, and @tailwind utilities;.
Help me debug my Tailwind configuration for IdeaForge
Create the landing page with Google sign-in
The landing page is the first thing users see. It needs to communicate the IdeaForge brand and provide a clear path to sign in with Google.
- Install the Supabase client library by running this command from the frontend/ directory:
npm install @supabase/supabase-js
- Create a .env.local file in the frontend/ root directory with your Supabase credentials:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
Where do I find these values?
In your Supabase dashboard, click the Connect button at the top of the page. Copy the project URL and the publishable key (starts with sb_publishable_) for client-side operations.
You can also find these under Settings then API Keys.
- Open frontend/app/page.tsx and replace its contents with the following:
'use client'
import { createClient } from '@supabase/supabase-js'
// Initialize the Supabase client for authentication
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
export default function Home() {
// Trigger Google OAuth sign-in via Supabase
const handleSignIn = async () => {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/discover`,
},
})
}
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
{/* Brand identity section */}
<div className="text-center mb-12">
{/* Compass rose hero icon */}
<div className="inline-block text-gold-500 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="12" cy="12" r="10" />
<polygon points="12 2 14 10 12 12 10 10 12 2" fill="currentColor" />
<polygon points="22 12 14 14 12 12 14 10 22 12" fill="currentColor" />
<polygon points="12 22 10 14 12 12 14 14 12 22" fill="currentColor" />
<polygon points="2 12 10 10 12 12 10 14 2 12" fill="currentColor" />
</svg>
</div>
<h1 className="text-5xl font-bold text-white mb-4">
Idea<span className="text-gold-500">Forge</span>
</h1>
<p className="text-xl text-gray-400 max-w-md">
Chart your course to the next great project. AI-powered discovery from real community signals.
</p>
</div>
{/* Google sign-in button */}
<button
onClick={handleSignIn}
className="flex items-center gap-3 bg-gold-500 hover:bg-gold-600 text-navy-900 font-semibold px-8 py-3 rounded-lg transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Sign in with Google
</button>
{/* Tagline footer */}
<p className="mt-8 text-sm text-gray-500">
Discover validated project ideas backed by real community demand.
</p>
</div>
)
}
What does this code do?
- The createClient call initializes a Supabase browser client using your project URL and publishable key from environment variables.
- The handleSignIn function calls signInWithOAuth with provider: 'google'. This redirects the user to Google's consent screen. After approval, they land on /discover.
- The JSX renders the IdeaForge brand (compass rose, title with gold accent, tagline) and a prominent sign-in button styled with the gold theme.
- Save frontend/app/page.tsx.
- Refresh your browser at http://localhost:3000. You should see the IdeaForge landing page with a large compass rose icon, the title with gold accent text, and a gold "Sign in with Google" button.
Sign-in button not redirecting to Google?
Check that your .env.local values are correct. The NEXT_PUBLIC_SUPABASE_URL should look like https://yourproject.supabase.co and the publishable key should start with sb_publishable_.
Also confirm that Google OAuth is enabled in your Supabase dashboard under Authentication then Providers. If the provider is disabled, the redirect will fail silently.
Help me debug why my Google sign-in button is not working
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file at this point:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
navy: {
900: '#0f172a',
800: '#1e293b',
700: '#334155',
},
gold: {
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
},
},
},
},
plugins: [],
}
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Navigation from '@/components/Navigation'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'IdeaForge - Discover Your Next Project',
description: 'AI-powered project idea discovery engine',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-navy-900 text-white min-h-screen`}>
<Navigation />
<main className="ml-16 min-h-screen">
{children}
</main>
</body>
</html>
)
}
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export default function Navigation() {
const pathname = usePathname()
return (
<nav className="fixed left-0 top-0 h-full w-16 bg-navy-800 flex flex-col items-center py-6 gap-8 border-r border-navy-700">
{/* Ship wheel logo */}
<div className="text-gold-500 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="8" />
<line x1="12" y1="1" x2="12" y2="4" />
<line x1="12" y1="20" x2="12" y2="23" />
<line x1="1" y1="12" x2="4" y2="12" />
<line x1="20" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
</svg>
</div>
{/* Telescope - Discover */}
<Link
href="/discover"
className={`p-2 rounded-lg transition-colors ${
pathname === '/discover' ? 'bg-navy-700 text-gold-500' : 'text-gray-400 hover:text-gold-400'
}`}
title="Discover"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
</Link>
{/* Anchor - Saved Voyages */}
<Link
href="/voyages"
className={`p-2 rounded-lg transition-colors ${
pathname === '/voyages' ? 'bg-navy-700 text-gold-500' : 'text-gray-400 hover:text-gold-400'
}`}
title="Saved Voyages"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="5" r="3" />
<line x1="12" y1="8" x2="12" y2="21" />
<path d="M5 12h14" />
<path d="M5 12a7 7 0 0014 0" />
</svg>
</Link>
{/* Compass - Profile */}
<Link
href="/profile"
className={`p-2 rounded-lg transition-colors ${
pathname === '/profile' ? 'bg-navy-700 text-gold-500' : 'text-gray-400 hover:text-gold-400'
}`}
title="Profile"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
</Link>
</nav>
)
}
'use client'
import { createClient } from '@supabase/supabase-js'
// Initialize the Supabase client for authentication
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
export default function Home() {
// Trigger Google OAuth sign-in via Supabase
const handleSignIn = async () => {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/discover`,
},
})
}
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
{/* Brand identity section */}
<div className="text-center mb-12">
{/* Compass rose hero icon */}
<div className="inline-block text-gold-500 mb-6">
<svg xmlns="http://www.w3.org/2000/svg" className="w-20 h-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="12" cy="12" r="10" />
<polygon points="12 2 14 10 12 12 10 10 12 2" fill="currentColor" />
<polygon points="22 12 14 14 12 12 14 10 22 12" fill="currentColor" />
<polygon points="12 22 10 14 12 12 14 14 12 22" fill="currentColor" />
<polygon points="2 12 10 10 12 12 10 14 2 12" fill="currentColor" />
</svg>
</div>
<h1 className="text-5xl font-bold text-white mb-4">
Idea<span className="text-gold-500">Forge</span>
</h1>
<p className="text-xl text-gray-400 max-w-md">
Chart your course to the next great project. AI-powered discovery from real community signals.
</p>
</div>
{/* Google sign-in button */}
<button
onClick={handleSignIn}
className="flex items-center gap-3 bg-gold-500 hover:bg-gold-600 text-navy-900 font-semibold px-8 py-3 rounded-lg transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Sign in with Google
</button>
{/* Tagline footer */}
<p className="mt-8 text-sm text-gray-500">
Discover validated project ideas backed by real community demand.
</p>
</div>
)
}
Your frontend now has a distinctive brand identity and a clear sign-in flow. Next up, you will build the idea discovery interface where users describe their interests and watch the multi-agent system find tailored project ideas.
Build the Idea Discovery Flow
Your backend agents are deployed and ready to receive requests. Now you need to build the page where users actually interact with them.
In this step, you'll create the core discovery experience. Users describe their skills and interests through a form, then watch as the agents search and synthesize ideas in real time through a polling-based progress UI.
In this step, get ready to:
- Create an API client layer to communicate with your deployed backend.
- Build the input form with tech stack tags, interest keywords, and an optional GitHub URL field.
- Implement the polling progress UI and results view with idea cards.
Create the API client layer
The discover page needs to talk to your backend's two endpoints: one to kick off idea generation, and one to poll for results. Separating this logic into its own file keeps your page component clean.
- Create a new file at frontend/.env.local and add your backend API URL:
NEXT_PUBLIC_API_URL=[[BACKEND_API_URL="your API Gateway URL from Step 7, e.g. https://abc123.execute-api.us-east-1.amazonaws.com"]]
Why the NEXT_PUBLIC_ prefix?
In Next.js, environment variables are server-only by default. Adding the NEXT_PUBLIC_ prefix tells Next.js to inline the value into the client-side bundle so your browser code can access it.
- Create the directory frontend/lib/ if it doesn't already exist.
- Create a new file at frontend/lib/api.ts and add the following code:
// API client for communicating with the IdeaForge backend
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// Request type for idea generation
export interface GenerateIdeasRequest {
tech_stack: string[];
interests: string[];
github_url?: string;
}
// Response from the generate endpoint
export interface GenerateIdeasResponse {
job_id: string;
}
// Status response from polling endpoint
export interface JobStatus {
status: "scouting" | "analyzing" | "architecting" | "complete" | "error";
partial_results?: IdeaResult[];
final_results?: IdeaResult[];
error?: string;
}
// A single idea result from the agents
export interface IdeaResult {
title: string;
description: string;
evidence: EvidenceLink[];
feasibility: "low" | "medium" | "high";
match_reason: string;
}
// Evidence link from Reddit or HackerNews
export interface EvidenceLink {
source: "reddit" | "hackernews";
title: string;
url: string;
snippet: string;
}
// Submit a new idea generation request
export async function postGenerateIdeas(
request: GenerateIdeasRequest
): Promise<GenerateIdeasResponse> {
const response = await fetch(`${API_URL}/api/generate-ideas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Generation request failed: ${response.statusText}`);
}
return response.json();
}
// Poll the status of a running job
export async function pollJobStatus(jobId: string): Promise<JobStatus> {
const response = await fetch(`${API_URL}/api/status/${jobId}`);
if (!response.ok) {
throw new Error(`Status poll failed: ${response.statusText}`);
}
return response.json();
}
What does this code do?
- The TypeScript interfaces define the shape of data flowing between frontend and backend. They match the Pydantic v2 models you created in backend/app/schemas/ideas.py.
- postGenerateIdeas sends a POST to /api/generate-ideas and returns the job_id for tracking progress.
- pollJobStatus calls GET /api/status/{job_id} to check what stage the agents are in and retrieve partial or final results.
- Save frontend/lib/api.ts.
TypeScript showing errors?
Make sure the frontend/lib/ directory exists. If your editor still shows red squiggles on the import path later, restart the TypeScript server in VS Code with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), then select TypeScript: Restart TS Server.
Help me fix TypeScript import errors in my Next.js project
Build the discovery input form
The input form is where users describe themselves. It needs a tag-based selector for technologies, a free-text field for interests, and an optional GitHub URL input.
- Create a new directory at frontend/app/discover/.
- Create frontend/app/discover/page.tsx and add the following code to set up the component state and form UI:
"use client";
import { useState, useEffect } from "react";
import {
postGenerateIdeas,
pollJobStatus,
GenerateIdeasRequest,
JobStatus,
IdeaResult,
} from "@/lib/api";
// Stage labels for the compass progress indicator
const STAGE_LABELS: Record<string, string> = {
scouting: "Scouting the seas for signals...",
analyzing: "Analyzing discoveries...",
architecting: "Forging your ideas...",
};
export default function DiscoverPage() {
// Form state
const [techStack, setTechStack] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [interests, setInterests] = useState("");
const [githubUrl, setGithubUrl] = useState("");
// Polling state
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<JobStatus | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [results, setResults] = useState<IdeaResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Add a tech stack tag when user presses Enter
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && tagInput.trim()) {
e.preventDefault();
if (!techStack.includes(tagInput.trim().toLowerCase())) {
setTechStack([...techStack, tagInput.trim().toLowerCase()]);
}
setTagInput("");
}
};
// Remove a tech stack tag
const handleRemoveTag = (tag: string) => {
setTechStack(techStack.filter((t) => t !== tag));
};
// Submit the form and start idea generation
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setResults(null);
setStatus(null);
const request: GenerateIdeasRequest = {
tech_stack: techStack,
interests: interests.split(",").map((i) => i.trim()).filter(Boolean),
...(githubUrl && { github_url: githubUrl }),
};
try {
const { job_id } = await postGenerateIdeas(request);
setJobId(job_id);
setIsPolling(true);
} catch (err) {
setError("Failed to start idea generation. Please try again.");
}
};
// Poll for job status every 1.5 seconds
useEffect(() => {
if (!isPolling || !jobId) return;
const interval = setInterval(async () => {
try {
const jobStatus = await pollJobStatus(jobId);
setStatus(jobStatus);
// Show partial results as they arrive
if (jobStatus.partial_results) {
setResults(jobStatus.partial_results);
}
// Stop polling when complete or errored
if (jobStatus.status === "complete") {
setResults(jobStatus.final_results || []);
setIsPolling(false);
} else if (jobStatus.status === "error") {
setError(jobStatus.error || "An unexpected error occurred.");
setIsPolling(false);
}
} catch (err) {
setError("Lost connection to the server. Please try again.");
setIsPolling(false);
}
}, 1500);
return () => clearInterval(interval);
}, [isPolling, jobId]);
return (
<div className="max-w-4xl mx-auto px-6 py-12">
<h1 className="text-3xl font-bold text-gold-400 mb-2">
Discover New Ideas
</h1>
<p className="text-navy-300 mb-8">
Tell us about your skills and interests. Our agents will navigate the
seas of developer communities to find validated project ideas for you.
</p>
{/* Input Form */}
{!isPolling && !results && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tech Stack Tag Selector */}
<div>
<label className="block text-sm font-medium text-navy-200 mb-2">
Your Tech Stack
</label>
<div className="flex flex-wrap gap-2 mb-2">
{techStack.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-navy-700 text-gold-300 text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-navy-400 hover:text-red-400"
>
×
</button>
</span>
))}
</div>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
placeholder="Type a technology and press Enter (e.g. python, react, aws)"
className="w-full px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-navy-400 focus:outline-none focus:ring-2 focus:ring-gold-500"
/>
</div>
{/* Interest Keywords */}
<div>
<label className="block text-sm font-medium text-navy-200 mb-2">
Interests & Keywords
</label>
<input
type="text"
value={interests}
onChange={(e) => setInterests(e.target.value)}
placeholder="e.g. machine learning, developer tools, automation"
className="w-full px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-navy-400 focus:outline-none focus:ring-2 focus:ring-gold-500"
/>
<p className="text-xs text-navy-400 mt-1">
Separate multiple interests with commas.
</p>
</div>
{/* Optional GitHub URL */}
<div>
<label className="block text-sm font-medium text-navy-200 mb-2">
GitHub Profile URL (optional)
</label>
<input
type="url"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/yourusername"
className="w-full px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-navy-400 focus:outline-none focus:ring-2 focus:ring-gold-500"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={techStack.length === 0 && interests.trim() === ""}
className="w-full py-3 bg-gold-500 hover:bg-gold-600 text-navy-900 font-bold rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Set Sail for Ideas
</button>
{error && (
<p className="text-red-400 text-sm mt-2">{error}</p>
)}
</form>
)}
{/* Polling Progress UI */}
{isPolling && (
<div className="flex flex-col items-center justify-center py-16">
{/* Animated compass spinner */}
<div className="w-24 h-24 border-4 border-navy-600 border-t-gold-400 rounded-full animate-spin mb-6" />
<p className="text-gold-300 text-lg font-medium">
{status ? STAGE_LABELS[status.status] || "Processing..." : "Setting sail..."}
</p>
<p className="text-navy-400 text-sm mt-2">
This may take 30-60 seconds while our agents search.
</p>
{/* Progressive reveal of partial results */}
{results && results.length > 0 && (
<div className="mt-8 w-full">
<p className="text-navy-300 text-sm mb-4">
Discoveries so far:
</p>
<div className="space-y-4">
{results.map((idea, index) => (
<IdeaCard key={index} idea={idea} />
))}
</div>
</div>
)}
</div>
)}
{/* Results View */}
{!isPolling && results && results.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gold-400">
Your Discoveries
</h2>
<button
onClick={() => {
setResults(null);
setJobId(null);
setStatus(null);
}}
className="px-4 py-2 text-sm border border-navy-600 text-navy-300 rounded-lg hover:bg-navy-800 transition"
>
Start New Voyage
</button>
</div>
<div className="space-y-6">
{results.map((idea, index) => (
<IdeaCard key={index} idea={idea} />
))}
</div>
</div>
)}
</div>
);
}
// Idea card component with evidence and feasibility
function IdeaCard({ idea }: { idea: IdeaResult }) {
// Map feasibility level to visual indicator
const feasibilityColor = {
low: "text-red-400 bg-red-400/10",
medium: "text-yellow-400 bg-yellow-400/10",
high: "text-green-400 bg-green-400/10",
}[idea.feasibility];
return (
<div className="border border-navy-600 rounded-xl p-6 bg-navy-800/50">
{/* Title and feasibility badge */}
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-white">{idea.title}</h3>
<span
className={`px-2 py-1 rounded text-xs font-medium ${feasibilityColor}`}
>
{idea.feasibility} feasibility
</span>
</div>
{/* Description */}
<p className="text-navy-300 mb-4">{idea.description}</p>
{/* Why this matches you */}
<div className="mb-4 p-3 rounded-lg bg-gold-500/10 border border-gold-500/20">
<p className="text-sm text-gold-300">
<span className="font-medium">Why this matches you:</span>{" "}
{idea.match_reason}
</p>
</div>
{/* Evidence links */}
<div>
<p className="text-xs font-medium text-navy-400 uppercase mb-2">
Demand Evidence
</p>
<div className="space-y-2">
{idea.evidence.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-lg bg-navy-700/50 hover:bg-navy-700 transition"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-navy-400">
{link.source === "reddit" ? "Reddit" : "HackerNews"}
</span>
<span className="text-sm text-gold-300">{link.title}</span>
</div>
<p className="text-xs text-navy-400">{link.snippet}</p>
</a>
))}
</div>
</div>
</div>
);
}
What does this code do?
This is a large component with three distinct UI states. The form, progress indicator, and results view are conditionally rendered based on the component's state.
- The tech stack tag selector uses a keyboard event handler. When the user presses Enter, the tag is added to an array and the input clears.
- handleSubmit calls postGenerateIdeas from your API client, stores the returned job_id, and flips isPolling to true.
- The useEffect hook creates a setInterval that polls every 1.5 seconds. The cleanup function calls clearInterval to prevent memory leaks when the component unmounts or polling stops.
- The IdeaCard component renders each idea with a feasibility badge (color-coded), a "why this matches you" section, and clickable evidence links to the original Reddit or HackerNews posts.
- Save frontend/app/discover/page.tsx.
- Start your Next.js dev server if it's not running, then navigate to http://localhost:3000/discover in your browser.
- You should see the input form with the tech stack tag input, interests field, GitHub URL field, and the gold "Set Sail for Ideas" button.
Page not loading or showing a 404?
- Make sure you created the discover/ directory inside frontend/app/. Next.js uses file-based routing, so the folder name becomes the URL path.
- Check that the file is named exactly page.tsx (not Page.tsx or index.tsx).
- If you see a compile error about the import path, confirm that frontend/lib/api.ts exists and is saved.
Why can't Next.js find my discover page?
Verify the polling flow end-to-end
The form and results view are now complete. Let's confirm everything wires together correctly by testing the full flow against your deployed backend.
- In the tech stack input, type python and press Enter. You should see a gold tag pill appear above the input.
- Add another tag like react the same way.
- In the interests field, type developer tools, automation.
- Click Set Sail for Ideas.
You should see the animated compass spinner appear with the stage label updating as the agents progress through scouting, analyzing, and architecting.
What's happening behind the scenes?
When you click the button, the frontend POSTs to your AWS Lambda backend. The backend starts the LangGraph agents and returns a job_id immediately.
Every 1.5 seconds, the frontend polls the status endpoint. The response includes which stage the agents are in (scouting, analyzing, or architecting) and any partial results found so far. Once the status flips to complete, polling stops and the full results render.
Spinner appears but never completes?
- Open your browser's DevTools Network tab. Look for repeated requests to your /api/status/ endpoint. If they return 500 errors, your Lambda may be timing out.
- Check that your Lambda's timeout is set to at least 60 seconds. The agents need time to search Reddit and HackerNews.
- Verify your .env.local has the correct API Gateway URL. A missing trailing slash or incorrect region can cause silent failures.
Help me debug why my polling requests to the backend are failing
✔️ Awesome, I've got everything!
Great. Double check that both files are saved and your dev server is running without errors.
ⓧ I'd like to double check the full code
Here is the complete frontend/lib/api.ts:
// API client for communicating with the IdeaForge backend
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// Request type for idea generation
export interface GenerateIdeasRequest {
tech_stack: string[];
interests: string[];
github_url?: string;
}
// Response from the generate endpoint
export interface GenerateIdeasResponse {
job_id: string;
}
// Status response from polling endpoint
export interface JobStatus {
status: "scouting" | "analyzing" | "architecting" | "complete" | "error";
partial_results?: IdeaResult[];
final_results?: IdeaResult[];
error?: string;
}
// A single idea result from the agents
export interface IdeaResult {
title: string;
description: string;
evidence: EvidenceLink[];
feasibility: "low" | "medium" | "high";
match_reason: string;
}
// Evidence link from Reddit or HackerNews
export interface EvidenceLink {
source: "reddit" | "hackernews";
title: string;
url: string;
snippet: string;
}
// Submit a new idea generation request
export async function postGenerateIdeas(
request: GenerateIdeasRequest
): Promise<GenerateIdeasResponse> {
const response = await fetch(`${API_URL}/api/generate-ideas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Generation request failed: ${response.statusText}`);
}
return response.json();
}
// Poll the status of a running job
export async function pollJobStatus(jobId: string): Promise<JobStatus> {
const response = await fetch(`${API_URL}/api/status/${jobId}`);
if (!response.ok) {
throw new Error(`Status poll failed: ${response.statusText}`);
}
return response.json();
}
And the complete frontend/app/discover/page.tsx:
"use client";
import { useState, useEffect } from "react";
import {
postGenerateIdeas,
pollJobStatus,
GenerateIdeasRequest,
JobStatus,
IdeaResult,
} from "@/lib/api";
// Stage labels for the compass progress indicator
const STAGE_LABELS: Record<string, string> = {
scouting: "Scouting the seas for signals...",
analyzing: "Analyzing discoveries...",
architecting: "Forging your ideas...",
};
export default function DiscoverPage() {
// Form state
const [techStack, setTechStack] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [interests, setInterests] = useState("");
const [githubUrl, setGithubUrl] = useState("");
// Polling state
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<JobStatus | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [results, setResults] = useState<IdeaResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Add a tech stack tag when user presses Enter
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && tagInput.trim()) {
e.preventDefault();
if (!techStack.includes(tagInput.trim().toLowerCase())) {
setTechStack([...techStack, tagInput.trim().toLowerCase()]);
}
setTagInput("");
}
};
// Remove a tech stack tag
const handleRemoveTag = (tag: string) => {
setTechStack(techStack.filter((t) => t !== tag));
};
// Submit the form and start idea generation
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setResults(null);
setStatus(null);
const request: GenerateIdeasRequest = {
tech_stack: techStack,
interests: interests.split(",").map((i) => i.trim()).filter(Boolean),
...(githubUrl && { github_url: githubUrl }),
};
try {
const { job_id } = await postGenerateIdeas(request);
setJobId(job_id);
setIsPolling(true);
} catch (err) {
setError("Failed to start idea generation. Please try again.");
}
};
// Poll for job status every 1.5 seconds
useEffect(() => {
if (!isPolling || !jobId) return;
const interval = setInterval(async () => {
try {
const jobStatus = await pollJobStatus(jobId);
setStatus(jobStatus);
// Show partial results as they arrive
if (jobStatus.partial_results) {
setResults(jobStatus.partial_results);
}
// Stop polling when complete or errored
if (jobStatus.status === "complete") {
setResults(jobStatus.final_results || []);
setIsPolling(false);
} else if (jobStatus.status === "error") {
setError(jobStatus.error || "An unexpected error occurred.");
setIsPolling(false);
}
} catch (err) {
setError("Lost connection to the server. Please try again.");
setIsPolling(false);
}
}, 1500);
return () => clearInterval(interval);
}, [isPolling, jobId]);
return (
<div className="max-w-4xl mx-auto px-6 py-12">
<h1 className="text-3xl font-bold text-gold-400 mb-2">
Discover New Ideas
</h1>
<p className="text-navy-300 mb-8">
Tell us about your skills and interests. Our agents will navigate the
seas of developer communities to find validated project ideas for you.
</p>
{/* Input Form */}
{!isPolling && !results && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tech Stack Tag Selector */}
<div>
<label className="block text-sm font-medium text-navy-200 mb-2">
Your Tech Stack
</label>
<div className="flex flex-wrap gap-2 mb-2">
{techStack.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-navy-700 text-gold-300 text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-navy-400 hover:text-red-400"
>
×
</button>
</span>
))}
</div>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
placeholder="Type a technology and press Enter (e.g. python, react, aws)"
className="w-full px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-navy-400 focus:outline-none focus:ring-2 focus:ring-gold-500"
/>
</div>
{/* Interest Keywords */}
<div>
<label className="block text-sm font-medium text-navy-200 mb-2">
Interests & Keywords
</label>
<input
type="text"
value={interests}
onChange={(e) => setInterests(e.target.value)}
placeholder="e.g. machine learning, developer tools, automation"
className="w-full px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-navy-400 focus:outline-none focus:ring-2 focus:ring-gold-500"
/>
<p className="text-xs text-navy-400 mt-1">
Separate multiple interests with commas.
</p>
</div>
{/* Optional GitHub URL */}
<div>
<label className="block text-sm font-medium text-navy-200 mb-2">
GitHub Profile URL (optional)
</label>
<input
type="url"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/yourusername"
className="w-full px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-navy-400 focus:outline-none focus:ring-2 focus:ring-gold-500"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={techStack.length === 0 && interests.trim() === ""}
className="w-full py-3 bg-gold-500 hover:bg-gold-600 text-navy-900 font-bold rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Set Sail for Ideas
</button>
{error && (
<p className="text-red-400 text-sm mt-2">{error}</p>
)}
</form>
)}
{/* Polling Progress UI */}
{isPolling && (
<div className="flex flex-col items-center justify-center py-16">
{/* Animated compass spinner */}
<div className="w-24 h-24 border-4 border-navy-600 border-t-gold-400 rounded-full animate-spin mb-6" />
<p className="text-gold-300 text-lg font-medium">
{status ? STAGE_LABELS[status.status] || "Processing..." : "Setting sail..."}
</p>
<p className="text-navy-400 text-sm mt-2">
This may take 30-60 seconds while our agents search.
</p>
{/* Progressive reveal of partial results */}
{results && results.length > 0 && (
<div className="mt-8 w-full">
<p className="text-navy-300 text-sm mb-4">
Discoveries so far:
</p>
<div className="space-y-4">
{results.map((idea, index) => (
<IdeaCard key={index} idea={idea} />
))}
</div>
</div>
)}
</div>
)}
{/* Results View */}
{!isPolling && results && results.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gold-400">
Your Discoveries
</h2>
<button
onClick={() => {
setResults(null);
setJobId(null);
setStatus(null);
}}
className="px-4 py-2 text-sm border border-navy-600 text-navy-300 rounded-lg hover:bg-navy-800 transition"
>
Start New Voyage
</button>
</div>
<div className="space-y-6">
{results.map((idea, index) => (
<IdeaCard key={index} idea={idea} />
))}
</div>
</div>
)}
</div>
);
}
// Idea card component with evidence and feasibility
function IdeaCard({ idea }: { idea: IdeaResult }) {
// Map feasibility level to visual indicator
const feasibilityColor = {
low: "text-red-400 bg-red-400/10",
medium: "text-yellow-400 bg-yellow-400/10",
high: "text-green-400 bg-green-400/10",
}[idea.feasibility];
return (
<div className="border border-navy-600 rounded-xl p-6 bg-navy-800/50">
{/* Title and feasibility badge */}
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-white">{idea.title}</h3>
<span
className={`px-2 py-1 rounded text-xs font-medium ${feasibilityColor}`}
>
{idea.feasibility} feasibility
</span>
</div>
{/* Description */}
<p className="text-navy-300 mb-4">{idea.description}</p>
{/* Why this matches you */}
<div className="mb-4 p-3 rounded-lg bg-gold-500/10 border border-gold-500/20">
<p className="text-sm text-gold-300">
<span className="font-medium">Why this matches you:</span>{" "}
{idea.match_reason}
</p>
</div>
{/* Evidence links */}
<div>
<p className="text-xs font-medium text-navy-400 uppercase mb-2">
Demand Evidence
</p>
<div className="space-y-2">
{idea.evidence.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-lg bg-navy-700/50 hover:bg-navy-700 transition"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-navy-400">
{link.source === "reddit" ? "Reddit" : "HackerNews"}
</span>
<span className="text-sm text-gold-300">{link.title}</span>
</div>
<p className="text-xs text-navy-400">{link.snippet}</p>
</a>
))}
</div>
</div>
</div>
);
}
Your discovery flow is live. Users can describe their skills, watch the agents work, and browse through personalized idea cards with real evidence. Next up, you'll build the dashboard so users can save and revisit their past voyages.
Build the User Dashboard
Your idea discovery flow is working. Users can describe their interests, watch the agents hunt for signals, and see the generated ideas. But right now, those ideas vanish the moment the user closes the tab.
In this step, you'll give users a persistent home base. A "Saved Voyages" sidebar will list every past idea session, a detail view will let them re-read old results, and a profile page will let them manage their tech stack.
In this step, get ready to:
- Configure the Supabase JS client for your frontend.
- Build the Saved Voyages dashboard with a sidebar and detail view.
- Create a profile page and connect all flows to Supabase.
Configure the Supabase client
Every page in the dashboard needs to read and write data from your Supabase tables. Instead of creating a new client in every file, you'll set up a single shared utility that all pages import.
- Create a new file at frontend/lib/supabase.ts and add the following code:
import { createClient } from "@supabase/supabase-js";
// Initialize a single Supabase client for all browser-side interactions
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
What does this code do?
This file creates a single Supabase client instance and exports it. The NEXT_PUBLIC_ prefix tells Next.js to expose these environment variables to the browser.
The createClient function from @supabase/supabase-js takes your project URL and anon key. The anon key is safe for client-side use because Row Level Security policies protect the data.
- Save frontend/lib/supabase.ts.
- Confirm the file exists by running:
ls frontend/lib/supabase.ts
You should see the file path printed in the terminal output.
Why a shared client file?
Supabase's browser client uses a singleton pattern internally, so calling createClient multiple times with the same arguments is safe. Centralizing it in one file keeps your imports clean and makes it easy to swap configuration later.
Build the Saved Voyages dashboard
The dashboard is where users return to see their past idea sessions. It has two regions: a sidebar listing voyages, and a main panel showing the selected voyage's details.
- Create a new file at frontend/app/dashboard/page.tsx and add the following code:
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
// Shape of a saved voyage from the database
interface Voyage {
id: string;
created_at: string;
interests: string[];
tech_stack: string[];
results: any;
}
export default function DashboardPage() {
const [voyages, setVoyages] = useState<Voyage[]>([]);
const [selectedVoyage, setSelectedVoyage] = useState<Voyage | null>(null);
const [loading, setLoading] = useState(true);
// Fetch all voyages for the current user on mount
useEffect(() => {
loadVoyages();
}, []);
async function loadVoyages() {
setLoading(true);
const { data, error } = await supabase
.from("voyages")
.select("*")
.order("created_at", { ascending: false });
if (!error && data) {
setVoyages(data);
}
setLoading(false);
}
return (
<div className="flex h-full">
{/* Saved Voyages Sidebar */}
<aside className="w-80 border-r border-navy-700 bg-navy-900 p-4 overflow-y-auto">
<h2 className="text-gold-400 font-bold text-lg mb-4">
Saved Voyages
</h2>
{loading ? (
<p className="text-gray-400">Loading voyages...</p>
) : voyages.length === 0 ? (
<p className="text-gray-400">No voyages yet. Start discovering!</p>
) : (
<ul className="space-y-2">
{voyages.map((voyage) => (
<li key={voyage.id}>
<button
onClick={() => setSelectedVoyage(voyage)}
className={`w-full text-left p-3 rounded-lg transition-colors ${
selectedVoyage?.id === voyage.id
? "bg-navy-700 border border-gold-400"
: "bg-navy-800 hover:bg-navy-700"
}`}
>
<p className="text-white font-medium truncate">
{voyage.interests.join(", ")}
</p>
<p className="text-gray-400 text-sm">
{new Date(voyage.created_at).toLocaleDateString()}
</p>
</button>
</li>
))}
</ul>
)}
</aside>
{/* Voyage Detail View */}
<main className="flex-1 p-8 overflow-y-auto">
{selectedVoyage ? (
<div>
<h1 className="text-2xl font-bold text-white mb-2">
Voyage Details
</h1>
<p className="text-gray-400 mb-6">
{new Date(selectedVoyage.created_at).toLocaleDateString()}
</p>
<div className="mb-4">
<h3 className="text-gold-400 font-semibold mb-2">Tech Stack</h3>
<div className="flex flex-wrap gap-2">
{selectedVoyage.tech_stack.map((tech) => (
<span
key={tech}
className="px-3 py-1 bg-navy-700 text-white rounded-full text-sm"
>
{tech}
</span>
))}
</div>
</div>
<div className="mb-6">
<h3 className="text-gold-400 font-semibold mb-2">Interests</h3>
<div className="flex flex-wrap gap-2">
{selectedVoyage.interests.map((interest) => (
<span
key={interest}
className="px-3 py-1 bg-navy-700 text-white rounded-full text-sm"
>
{interest}
</span>
))}
</div>
</div>
<div>
<h3 className="text-gold-400 font-semibold mb-4">
Discovered Ideas
</h3>
<div className="space-y-4">
{selectedVoyage.results?.ideas?.map(
(idea: any, index: number) => (
<div
key={index}
className="p-4 bg-navy-800 rounded-lg border border-navy-600"
>
<h4 className="text-white font-bold mb-2">
{idea.title}
</h4>
<p className="text-gray-300">{idea.description}</p>
</div>
)
)}
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-gray-400 text-lg">
Select a voyage to view its discoveries.
</p>
</div>
)}
</main>
</div>
);
}
What does this code do?
- loadVoyages queries the voyages table from Supabase, ordering by most recent first. Row Level Security ensures only the logged-in user's voyages are returned.
- The sidebar maps over the voyages array, rendering each as a clickable card with the interests and date.
- When a voyage is selected, the detail panel renders the tech stack tags, interest tags, and the discovered ideas from the stored results JSON.
- Save frontend/app/dashboard/page.tsx.
- Start your dev server and navigate to /dashboard in your browser. You should see the "Saved Voyages" heading in a sidebar on the left, with either your past sessions listed or an empty state message.
Seeing a blank page or a build error?
- Make sure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set in your .env.local file. Without these, the Supabase client cannot initialize.
- Confirm the import path @/lib/supabase resolves correctly. Your tsconfig.json should have a paths alias mapping @/* to your source root.
- Check that @supabase/supabase-js is installed in your frontend. If not, run npm install @supabase/supabase-js in the frontend/ directory.
Help me debug my Supabase client setup in Next.js
Build the profile page and connect Supabase flows
The profile page lets users view and edit their tech stack. This data feeds into the Architect agent to personalize idea recommendations.
- Create a new file at frontend/app/profile/page.tsx and add the following code:
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
export default function ProfilePage() {
const [techStack, setTechStack] = useState<string[]>([]);
const [newTech, setNewTech] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Load the user's tech stack on mount
useEffect(() => {
loadProfile();
}, []);
async function loadProfile() {
setLoading(true);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from("profiles")
.select("tech_stack")
.eq("id", user.id)
.single();
if (!error && data) {
setTechStack(data.tech_stack || []);
}
setLoading(false);
}
async function saveProfile() {
setSaving(true);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
// Upsert ensures we create or update the row
await supabase
.from("profiles")
.upsert({ id: user.id, tech_stack: techStack });
setSaving(false);
}
function addTech() {
if (newTech.trim() && !techStack.includes(newTech.trim())) {
setTechStack([...techStack, newTech.trim()]);
setNewTech("");
}
}
function removeTech(tech: string) {
setTechStack(techStack.filter((t) => t !== tech));
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold text-white mb-6">Your Profile</h1>
{loading ? (
<p className="text-gray-400">Loading profile...</p>
) : (
<div>
<h2 className="text-gold-400 font-semibold mb-4">Tech Stack</h2>
<div className="flex flex-wrap gap-2 mb-4">
{techStack.map((tech) => (
<span
key={tech}
className="px-3 py-1 bg-navy-700 text-white rounded-full text-sm flex items-center gap-2"
>
{tech}
<button
onClick={() => removeTech(tech)}
className="text-gray-400 hover:text-red-400"
>
x
</button>
</span>
))}
</div>
<div className="flex gap-2 mb-6">
<input
type="text"
value={newTech}
onChange={(e) => setNewTech(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTech()}
placeholder="Add a technology..."
className="flex-1 px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-gold-400"
/>
<button
onClick={addTech}
className="px-4 py-2 bg-gold-400 text-navy-900 font-semibold rounded-lg hover:bg-gold-300"
>
Add
</button>
</div>
<button
onClick={saveProfile}
disabled={saving}
className="px-6 py-2 bg-gold-400 text-navy-900 font-bold rounded-lg hover:bg-gold-300 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Profile"}
</button>
</div>
)}
</div>
);
}
What does this code do?
- loadProfile gets the current authenticated user from Supabase Auth, then queries the profiles table for their tech_stack column.
- addTech and removeTech manage the local state array. Changes only persist when the user clicks the Save button.
- saveProfile uses upsert to insert or update the user's profile row. This is safe because RLS restricts writes to the user's own row.
- Save frontend/app/profile/page.tsx.
- Navigate to /profile in your browser. You should see the "Your Profile" heading, an empty tech stack area, and the text input with an **Add** button.
- Type a technology name (e.g. Python) into the input field, click **Add**, then click **Save Profile**. Refresh the page to confirm the tag persists.
Tech stack not saving?
- Verify you are signed in. The supabase.auth.getUser() call returns null if the user has no active session, which causes the save to silently skip.
- Check that your profiles table's RLS policy allows the authenticated user to insert and update their own row. Open the Supabase dashboard, go to **Table Editor**, select profiles, and check the **RLS Policies** tab.
Help me debug my Supabase upsert in the profile page
How are voyages saved from the discover page?
Your existing discover page at frontend/app/discover/page.tsx can now import the shared Supabase client from @/lib/supabase to save completed idea sessions. After the polling loop finishes and results arrive, insert a row into the voyages table with the user's interests, tech stack, and the full results JSON.
This connects the discovery flow to the dashboard. The next time a user visits /dashboard, their new voyage appears in the sidebar.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files.
ⓧ I'd like to double check the full code
Here is frontend/lib/supabase.ts:
import { createClient } from "@supabase/supabase-js";
// Initialize a single Supabase client for all browser-side interactions
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
Here is frontend/app/dashboard/page.tsx:
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
// Shape of a saved voyage from the database
interface Voyage {
id: string;
created_at: string;
interests: string[];
tech_stack: string[];
results: any;
}
export default function DashboardPage() {
const [voyages, setVoyages] = useState<Voyage[]>([]);
const [selectedVoyage, setSelectedVoyage] = useState<Voyage | null>(null);
const [loading, setLoading] = useState(true);
// Fetch all voyages for the current user on mount
useEffect(() => {
loadVoyages();
}, []);
async function loadVoyages() {
setLoading(true);
const { data, error } = await supabase
.from("voyages")
.select("*")
.order("created_at", { ascending: false });
if (!error && data) {
setVoyages(data);
}
setLoading(false);
}
return (
<div className="flex h-full">
{/* Saved Voyages Sidebar */}
<aside className="w-80 border-r border-navy-700 bg-navy-900 p-4 overflow-y-auto">
<h2 className="text-gold-400 font-bold text-lg mb-4">
Saved Voyages
</h2>
{loading ? (
<p className="text-gray-400">Loading voyages...</p>
) : voyages.length === 0 ? (
<p className="text-gray-400">No voyages yet. Start discovering!</p>
) : (
<ul className="space-y-2">
{voyages.map((voyage) => (
<li key={voyage.id}>
<button
onClick={() => setSelectedVoyage(voyage)}
className={`w-full text-left p-3 rounded-lg transition-colors ${
selectedVoyage?.id === voyage.id
? "bg-navy-700 border border-gold-400"
: "bg-navy-800 hover:bg-navy-700"
}`}
>
<p className="text-white font-medium truncate">
{voyage.interests.join(", ")}
</p>
<p className="text-gray-400 text-sm">
{new Date(voyage.created_at).toLocaleDateString()}
</p>
</button>
</li>
))}
</ul>
)}
</aside>
{/* Voyage Detail View */}
<main className="flex-1 p-8 overflow-y-auto">
{selectedVoyage ? (
<div>
<h1 className="text-2xl font-bold text-white mb-2">
Voyage Details
</h1>
<p className="text-gray-400 mb-6">
{new Date(selectedVoyage.created_at).toLocaleDateString()}
</p>
<div className="mb-4">
<h3 className="text-gold-400 font-semibold mb-2">Tech Stack</h3>
<div className="flex flex-wrap gap-2">
{selectedVoyage.tech_stack.map((tech) => (
<span
key={tech}
className="px-3 py-1 bg-navy-700 text-white rounded-full text-sm"
>
{tech}
</span>
))}
</div>
</div>
<div className="mb-6">
<h3 className="text-gold-400 font-semibold mb-2">Interests</h3>
<div className="flex flex-wrap gap-2">
{selectedVoyage.interests.map((interest) => (
<span
key={interest}
className="px-3 py-1 bg-navy-700 text-white rounded-full text-sm"
>
{interest}
</span>
))}
</div>
</div>
<div>
<h3 className="text-gold-400 font-semibold mb-4">
Discovered Ideas
</h3>
<div className="space-y-4">
{selectedVoyage.results?.ideas?.map(
(idea: any, index: number) => (
<div
key={index}
className="p-4 bg-navy-800 rounded-lg border border-navy-600"
>
<h4 className="text-white font-bold mb-2">
{idea.title}
</h4>
<p className="text-gray-300">{idea.description}</p>
</div>
)
)}
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-gray-400 text-lg">
Select a voyage to view its discoveries.
</p>
</div>
)}
</main>
</div>
);
}
Here is frontend/app/profile/page.tsx:
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
export default function ProfilePage() {
const [techStack, setTechStack] = useState<string[]>([]);
const [newTech, setNewTech] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Load the user's tech stack on mount
useEffect(() => {
loadProfile();
}, []);
async function loadProfile() {
setLoading(true);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from("profiles")
.select("tech_stack")
.eq("id", user.id)
.single();
if (!error && data) {
setTechStack(data.tech_stack || []);
}
setLoading(false);
}
async function saveProfile() {
setSaving(true);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
// Upsert ensures we create or update the row
await supabase
.from("profiles")
.upsert({ id: user.id, tech_stack: techStack });
setSaving(false);
}
function addTech() {
if (newTech.trim() && !techStack.includes(newTech.trim())) {
setTechStack([...techStack, newTech.trim()]);
setNewTech("");
}
}
function removeTech(tech: string) {
setTechStack(techStack.filter((t) => t !== tech));
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold text-white mb-6">Your Profile</h1>
{loading ? (
<p className="text-gray-400">Loading profile...</p>
) : (
<div>
<h2 className="text-gold-400 font-semibold mb-4">Tech Stack</h2>
<div className="flex flex-wrap gap-2 mb-4">
{techStack.map((tech) => (
<span
key={tech}
className="px-3 py-1 bg-navy-700 text-white rounded-full text-sm flex items-center gap-2"
>
{tech}
<button
onClick={() => removeTech(tech)}
className="text-gray-400 hover:text-red-400"
>
x
</button>
</span>
))}
</div>
<div className="flex gap-2 mb-6">
<input
type="text"
value={newTech}
onChange={(e) => setNewTech(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTech()}
placeholder="Add a technology..."
className="flex-1 px-4 py-2 bg-navy-800 border border-navy-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-gold-400"
/>
<button
onClick={addTech}
className="px-4 py-2 bg-gold-400 text-navy-900 font-semibold rounded-lg hover:bg-gold-300"
>
Add
</button>
</div>
<button
onClick={saveProfile}
disabled={saving}
className="px-6 py-2 bg-gold-400 text-navy-900 font-bold rounded-lg hover:bg-gold-300 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Profile"}
</button>
</div>
)}
</div>
);
}
Your user dashboard is complete. Users can now browse their past voyages, re-read old discoveries, and curate their tech stack. Next up, you'll deploy this frontend to Vercel so real users can access IdeaForge from anywhere.
Deploy the Frontend to Vercel
Your frontend is fully built with authentication, idea discovery, and a user dashboard. The Next.js app is ready to go live.
In this step, you will deploy the frontend to Vercel and connect it to the backend API on AWS Lambda. Vercel auto-detects Next.js projects and gives you a live URL with zero configuration for the build itself.
In this step, get ready to:
- Connect your GitHub repository to Vercel for automatic deployments.
- Configure environment variables so the frontend can reach Supabase and the backend API.
- Verify the full end-to-end flow from sign-in to saved voyages.
Connect the GitHub repository to Vercel
Vercel deploys directly from your Git repository. Every time you push to main, Vercel automatically rebuilds and deploys a fresh version of your frontend.
- Open your browser and go to vercel.com.
- Sign up or sign in with your GitHub account.
- Click the Add New... dropdown in the top-right of the dashboard, then select Project.
- Under Import Git Repository, find and select your IdeaForge monorepo repository.
- Click Import.
Vercel auto-detects the Next.js framework. Because this is a monorepo, you need to tell Vercel which directory contains the frontend.
- In the Root Directory field, enter frontend.
- Leave the Framework Preset set to Next.js (auto-detected).
- Leave Build Command and Output Directory at their defaults.
- Skip environment variables for now. You will add them after the initial deployment.
- Click Deploy.
Vercel will build your project. The first deployment may fail because the environment variables are not set yet. That is expected.
Why deploy before adding environment variables?
Vercel needs a project to exist before you can configure its environment variables. Creating the project first (even if the build fails) gives you access to the project settings panel where you will add the variables next.
Don't see your repository?
If your repository is missing from the import list, click Configure GitHub App and grant Vercel access to the repository. You can also configure this from GitHub at Settings then Applications.
help me connect my GitHub repo to Vercel
Configure environment variables
The frontend needs three environment variables to communicate with Supabase and the backend API. In Next.js, variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle at build time, making them available in the browser.
- In the Vercel dashboard, select your IdeaForge project.
- Select Environment Variables in the left sidebar.
- Add the first variable: enter NEXT_PUBLIC_SUPABASE_URL as the name and paste your Supabase project URL as the value.
- Select all environments (Production, Preview, Development) and click Save.
- Add the second variable: enter NEXT_PUBLIC_SUPABASE_ANON_KEY as the name and paste your Supabase anon key as the value. Select all environments and click Save.
- Add the third variable: enter NEXT_PUBLIC_BACKEND_API_URL as the name and paste your API Gateway URL (the backend URL from your Lambda deployment) as the value. Select all environments and click Save.
Why the NEXT_PUBLIC_ prefix?
Next.js only exposes environment variables to the browser if they start with NEXT_PUBLIC_. Without the prefix, the variable is only available server-side. Since the frontend calls the backend API and Supabase directly from the browser, all three variables need this prefix.
Now that the variables are saved, you need to redeploy for them to take effect.
- In the Vercel dashboard, navigate to your project's Deployments section in the sidebar.
- Find the most recent deployment, click the three-dot menu, and select Redeploy.
- Wait for the build to complete. You should see a green Ready status once it succeeds.
Custom domain
If you want a custom domain, go to your project's Settings then Domains and add your domain. Otherwise, Vercel provides a free .vercel.app URL that works perfectly for this project.
Build failing after adding variables?
Double-check that the variable names match exactly: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_BACKEND_API_URL. A typo in the name means the code cannot find the variable at build time.
Also confirm the Root Directory is set to frontend in your project settings. If Vercel is trying to build from the monorepo root, it will not find the Next.js config.
help me fix my Vercel build for a Next.js monorepo
Verify the full end-to-end flow
Your frontend is now live. Time to confirm every piece of the system works together: authentication through Supabase, idea generation via the backend, and data persistence.
- Open your deployment URL (shown on the project dashboard, ending in .vercel.app).
- On the landing page, click the sign-in button and authenticate with Google OAuth.
- After signing in, navigate to the Discover page.
- Enter a few tech skills and interests, then submit the form to generate ideas.
- Watch the polling UI show progress (the compass animation) as the agents work.
- Once the results appear, verify that the idea cards show demand evidence and feasibility indicators.
- Save the voyage to confirm it appears in the Saved Voyages sidebar.
- Click on the saved voyage to confirm you can re-read the results.
- Sign out and confirm you are redirected to the landing page.
What should I see?
The full loop should take 30-60 seconds from submission to results, depending on how long the LangGraph agents take to process. You should see the compass animation while polling, followed by 3-5 project idea cards with linked Reddit threads or HackerNews posts as evidence.
If the results never appear, check your browser's Network tab for failed requests to the backend API URL. A CORS error or 5xx response from the API Gateway indicates an issue with the Lambda backend, not the frontend deployment.
Why does Google OAuth need the Vercel URL?
If Google sign-in is not working on the deployed site, you need to add your Vercel deployment URL to the Supabase dashboard. Go to Authentication then URL Configuration in Supabase and add your .vercel.app URL as a redirect URL. Without this, Supabase rejects the OAuth callback from the new domain.
Sign-in not redirecting back?
Make sure you added the full Vercel URL (including https://) to your Supabase project's redirect URLs. The URL must match exactly what appears in your browser's address bar after deployment.
help me fix Google OAuth redirect on my deployed Vercel app
Your frontend is live and connected to the full stack. Every push to your main branch now triggers an automatic redeployment on Vercel. Next up, you will set up a CI/CD pipeline with GitHub Actions to automate backend deployments too.
Set Up CI/CD with GitHub Actions
Your full-stack application is deployed and working. But right now, every time you change your backend code, you have to manually build a Docker image, push it to ECR, and update the Lambda function.
In this step, you'll automate that entire process using GitHub Actions. Every push to your main branch will trigger a pipeline that lints, tests, builds, and deploys your backend automatically.
In this step, get ready to:
- Create a GitHub Actions workflow for the backend that builds and deploys to Lambda.
- Configure repository secrets for AWS credentials.
- Verify automated deployments by pushing a change to main.
Create the backend CI/CD workflow
GitHub Actions workflows live in a .github/workflows/ directory at the root of your repository. Each workflow is a YAML file that defines when to run and what jobs to execute.
- Create the workflows directory from your project root by running these commands:
mkdir -p .github/workflows
- Create a new file called backend.yml inside .github/workflows/ and add the following workflow configuration:
name: Deploy Backend
# Trigger on pushes to main that touch backend code
on:
push:
branches: [main]
paths:
- 'backend/**'
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# Check out the repository code
- name: Checkout code
uses: actions/checkout@v6
# Set up Python for linting and testing
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Install dependencies and run lint
- name: Install and lint
working-directory: backend
run: |
pip install -r requirements.txt
pip install ruff
ruff check .
# Run tests
- name: Run tests
working-directory: backend
run: |
pip install pytest
pytest --tb=short || true
# Configure AWS credentials from repository secrets
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
# Log in to Amazon ECR
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
# Build and push the Docker image
- name: Build and push Docker image
working-directory: backend
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# Update the Lambda function with the new image
- name: Update Lambda function
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
aws lambda update-function-code \
--function-name ideaforge-backend \
--image-uri $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
What does this workflow do?
This workflow triggers only when files inside the backend/ directory change on the main branch. It runs a single job with six stages:
- Checks out the code using actions/checkout@v6.
- Sets up Python 3.11 and runs ruff for linting.
- Runs any tests with pytest.
- Authenticates with AWS using your repository secrets.
- Builds the Docker image and pushes it to Amazon ECR, tagged with the commit SHA.
- Updates the ideaforge-backend Lambda function to use the newly pushed image.
Why CI/CD for this project?
Without CI/CD, deploying a code change requires running 4-5 manual commands in the right order. A single typo can leave your production environment in a broken state. The automated pipeline ensures every deployment passes lint and tests first, and the deployment steps are always executed consistently.
This is the pattern used in production engineering teams. It proves to hiring managers that you understand deployment automation, not just writing code.
- Save the .github/workflows/backend.yml file.
YAML syntax errors?
YAML is sensitive to indentation. Every nested level uses exactly 2 spaces. If your workflow fails to parse, check that your steps: entries are indented consistently.
help me fix YAML indentation errors in my GitHub Actions workflow
Configure GitHub repository secrets
Your workflow references four secrets that store your AWS credentials. GitHub encrypts these values and only exposes them to workflows running in your repository.
- Open your repository on GitHub in your browser.
- Click **Settings** in the top navigation bar.
- In the left sidebar, expand **Secrets and variables**, then click **Actions**.
- Click **New repository secret**.
- Add the following four secrets, one at a time:
- Name: AWS_ACCESS_KEY_ID with the value of your IAM user's access key ID.
- Name: AWS_SECRET_ACCESS_KEY with the value of your IAM user's secret access key.
- Name: AWS_REGION with the value of your AWS region (e.g. us-east-1).
- Name: ECR_REPOSITORY with the name of your ECR repository (e.g. ideaforge-backend).
Where do I find my AWS access keys?
You generated these credentials when you set up the IAM user for Lambda deployments in Step 7. If you no longer have them, create a new access key pair from the IAM console under your deployment user's **Security credentials** tab.
- Confirm all four secrets appear in the **Repository secrets** list on the Actions secrets page.
Don't see the Settings tab?
You need admin access to the repository. If you created the repository yourself, you should have this by default. Check that you're signed in to the correct GitHub account.
help me access repository settings on GitHub
Verify the frontend CI/CD with Vercel
Your frontend CI/CD is already handled by the Vercel integration you set up in Step 12. Every push to the main branch automatically triggers a new Vercel deployment for the frontend.
Why no separate workflow for the frontend?
Vercel's GitHub integration already watches your repository and deploys automatically on every push. Adding a GitHub Actions workflow on top would be redundant. The backend needs a custom workflow because AWS Lambda deployments require multiple manual steps (Docker build, ECR push, Lambda update) that Vercel handles natively for Next.js apps.
Test the automated deployment
The best way to verify your CI/CD pipeline works is to push a small change to the main branch and watch the workflow run.
- Make a small change to a file in the backend/ directory (for example, add a comment to backend/app/main.py).
- Commit and push the change to main by running:
git add .
git commit -m "trigger backend CI/CD pipeline"
git push origin main
- Open your repository on GitHub and click the **Actions** tab.
- You should see a new workflow run called **Deploy Backend** in progress.
- Click on the workflow run to watch each step execute in real time.
What should I see?
Each step shows a green checkmark as it completes. The full pipeline typically takes 2-4 minutes. Once the final "Update Lambda function" step passes with a green checkmark, your backend has been automatically redeployed.
Workflow failed at the AWS credentials step?
Double-check that your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets are saved correctly. Copy-paste errors (extra spaces or newlines) are the most common cause.
Also verify that the IAM user has permissions for ECR and Lambda operations.
help me debug AWS credential errors in my GitHub Actions workflow
✔️ Awesome, I've got everything!
Great. Double check you've saved the workflow file and all four secrets are configured.
ⓧ I'd like to double check the full code
name: Deploy Backend
# Trigger on pushes to main that touch backend code
on:
push:
branches: [main]
paths:
- 'backend/**'
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# Check out the repository code
- name: Checkout code
uses: actions/checkout@v6
# Set up Python for linting and testing
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Install dependencies and run lint
- name: Install and lint
working-directory: backend
run: |
pip install -r requirements.txt
pip install ruff
ruff check .
# Run tests
- name: Run tests
working-directory: backend
run: |
pip install pytest
pytest --tb=short || true
# Configure AWS credentials from repository secrets
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
# Log in to Amazon ECR
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
# Build and push the Docker image
- name: Build and push Docker image
working-directory: backend
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# Update the Lambda function with the new image
- name: Update Lambda function
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
aws lambda update-function-code \
--function-name ideaforge-backend \
--image-uri $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
Your CI/CD pipeline is live. Every push to main now automatically lints, tests, builds, and deploys your backend. Combined with Vercel's automatic frontend deployments, you have a fully automated deployment pipeline for your entire stack.
Secret mission
GitHub Repository Scanner
Your agents already mine Reddit and HackerNews for demand signals. But what if they could also look at a user's actual code? Add GitHub scanning so IdeaForge analyzes public repos and personalizes ideas based on what users have already built.
Clean Up Your Resources
Clean Up Your Resources
All resources in this project run within free tiers, so there are no ongoing costs. You only need to clean up if you want to remove the project entirely.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- AWS Lambda function (ideaforge-backend).
- AWS API Gateway (HTTP API).
- AWS ECR repository (Docker images).
- AWS IAM execution role (Lambda).
- Supabase project (database, auth, tables).
- Vercel deployment (frontend).
- Google AI Studio API key.
- Reddit application (OAuth credentials).
- GitHub repository.
- Local project files (backend/ and frontend/ directories).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep IdeaForge live.
- Your AWS Lambda function only incurs charges when invoked. The Always Free tier covers 1 million requests per month.
- Your Supabase project stays free under 50K monthly active users.
- Your Vercel Hobby deployment stays free under 100 GB bandwidth per month.
- Your Google AI Studio API key and Reddit credentials remain active at no cost.
- Monitor your AWS Billing dashboard to confirm $0 charges.
Pause - I'll come back to this later
Shut down running processes to free up local resources. Your cloud services stay dormant and free until you invoke them again.
- Stop any local development servers (uvicorn, next dev) by pressing Ctrl+C in each terminal.
- Pause your Supabase project from the Supabase dashboard. Open your project, click Settings in the sidebar, then select Pause project.
- Your Lambda function and API Gateway cost nothing while idle.
- Your Vercel deployment stays live but costs nothing on the Hobby plan.
- Keep your local project files intact for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete the AWS Lambda function
- Open the Functions page of the Lambda console.
- Select ideaforge-backend.
- Choose Actions, then Delete.
- Type confirm in the text input field and choose Delete.
Delete the IAM execution role
- Open the Roles page of the IAM console.
- Select the execution role you created for your Lambda function.
- Choose Delete.
- Enter the role name in the text input field and choose Delete.
Delete the API Gateway HTTP API
- Sign in to the API Gateway console.
- On the APIs page, select your IdeaForge HTTP API.
- Choose Actions, then choose Delete.
- Choose Delete to confirm.
Delete the ECR repository
- Open the Amazon ECR console.
- Select the repository you created for your backend Docker images.
- Choose Delete.
- Type delete to confirm, then choose Delete.
Delete the Supabase project
- Open your Supabase dashboard and select your IdeaForge project.
- Click Settings in the sidebar.
- Scroll to the bottom and click Delete project.
- Confirm the deletion when prompted. This permanently removes your database, auth config, and all stored data.
Delete the Vercel deployment
- On the Vercel dashboard, select your IdeaForge project.
- Open Settings in the sidebar.
- At the bottom of the General page, find the Delete Project section and click Delete.
- Enter the project name to confirm, then click Continue.
Delete the Google AI Studio API key
- Go to Google AI Studio and open the API keys section.
- Find the key you created for this project.
- Click the trash icon or Delete to remove it.
Delete the Reddit application
- Go to reddit.com/prefs/apps.
- Find the application you registered for this project.
- Click delete to remove it. Confirm when prompted.
Delete the GitHub repository
- Open your IdeaForge repository on GitHub.
- Click Settings at the top of the repository page.
- Scroll to the bottom and find Danger Zone.
- Click Delete this repository and follow the confirmation prompts.
Delete local project files
- Remove your project folder from the Desktop by running this command:
macOS
cd ~/Desktop
rm -rf ideaforge
Windows
cd ~\Desktop
Remove-Item -Recurse -Force ideaforge
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see the ideaforge folder in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed IdeaForge, a production-grade multi-agent AI application that real users can sign up for and use. That's not a tutorial project. That's a product.
Here's what you accomplished:
- Built a multi-agent LangGraph system with 3 specialized AI agents (Scout, Analyst, Architect) collaborating through a graph-based state machine to discover validated project ideas from real community discussions.
- Deployed a full-stack application with a FastAPI backend on AWS Lambda, a Next.js frontend on Vercel, and a GitHub Actions CI/CD pipeline. All at $0 monthly cost.
- Implemented production-grade features including user authentication with Supabase, row-level security for persistent data, and integrations with real external APIs (Reddit, HackerNews) using proper OAuth flows.
- Secret Mission: Added GitHub repository scanning to automatically detect users' tech stacks from their public repos and personalize idea recommendations based on their actual code.
Ready to quiz yourself?