I'm a freelance Next.js developer. Last month I built ProposalAI — a tool
that reads Upwork job posts and generates tailored proposals. Here's the
technical breakdown of what I learned.
The Problem With Existing AI Proposal Tools
Most tools on the market use a single large prompt:
"Write a proposal for this job post: [POST]. Skills: [SKILLS]. Make it professional."
Plain Text
The result? Generic garbage. It starts with "Hi, I'm interested," talks
about your skills, and ends with "I look forward to hearing from you."
90% of freelancers on Upwork write exactly this.
My First Attempt (And Why It Failed)
I started with the same single-prompt approach. The output was indistinguishable
from every other AI tool. I almost gave up until I had an insight:
The problem isn't the model. It's the prompt architecture.
The Solution: Two-Stage Prompting
Instead of one big prompt, I split it into two stages: Extraction and Generation.
Stage 1: Signal Extraction
const extractionPrompt = ` You are analyzing a freelance job posting. Extract ONLY these fields as JSON:
{ "client_company": "string | null", "specific_problem": "string - the exact pain point described", "desired_outcome": "string - what success looks like", "tone": "formal | casual | urgent", "budget_signal": "string | null - any rate mentioned" }
Job Post: ${jobPost} `;
Plain Text
This pass just pulls structured data. No generation. No creativity. Just facts.
**Stage 2: Proposal Generation**
typescript
const proposalPrompt = ` Write an Upwork proposal using this EXACT structure:
OPEN with: "${extracted.specific_problem || 'Your project sounds interesting'}"
Reference their SPECIFIC problem, not your skills
Do NOT start with "Hi" or "I read your post"
PROVE with one quantified result
Example format: "I helped a client [do X] which resulted in [Y]"
Keep it specific, not generic
MATCH their tone: ${extracted.tone}
END with a specific question about their current setup
Example: "Are you currently using [tool]?"
Keep total word count under 200
NEVER start sentences with "I am," "I have," or "I am a"
Client: ${extracted.client_company || 'not mentioned'} `;
Plain Text
Why This Works
- The extraction pass forces the model to read and understand the job post before generating output.
- The generation pass has no choice but to reference the client's specific problem, because it's the first thing in the prompt.
- The "NEVER" rules prevent the model from defaulting to generic phrases.
The Tech Stack
- Next.js 14 (App Router) + Tailwind CSS + shadcn/ui
- Supabase (Postgres, Auth, Edge Functions for secure OpenAI calls)
- GPT-4o (best reasoning for extraction)
- Creem (payments - Stripe alternative with lower fees)
What I'd Do Differently
- Stream from day one. Non-streaming responses feel broken to users.
- Cache extraction. I re-extract on every edit. Should key by job post hash.
- Add a "regenerate" button early. Users want alternatives instantly.
Conclusion
The quality difference between a single-prompt tool and a two-stage extraction
- generation tool is night and day. Same model, different architecture.
If you're building something that needs to produce specific, tailored output
from unstructured input, the two-stage pattern is the single biggest lever
I found.
Check out the live tool at proposalai.top (free tier available).
Top comments (0)