This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Private AI Lead Assistant โ a small CRM where you paste a client conversation and a local model turns it into a structured lead.
A freelancer's WhatsApp or email thread with a prospect is already a CRM record in prose: who they are, what they want built, the budget, the deadline. Extracting it is tedious, and every cloud "AI CRM" solves it by uploading the transcript. This solves it on your own machine:
- Paste the conversation โ Analyze with a local model
- Model returns a validated JSON lead: client, company, project, budget, priority, timeline, status, tags
- Review step โ nothing is saved until you approve it (a 1.5B model is confidently wrong sometimes, so a human is the last gate)
- Open the lead โ draft a follow-up message or a full project proposal, then copy it out
Who would pay for it: freelancers and 2โ10 person agencies. Client conversations contain budgets, deadlines and NDA'd product plans; per-seat AI CRM pricing is hard to justify when you invoice by the project. Running extraction locally makes the marginal cost $0 โ which is the difference between a side hustle's tooling and a SaaS bill.
Stack: React 19 + Tailwind, Express, Postgres, TypeScript, and Qwen 2.5 (1.5B, open weights) served by Ollama โ no external AI API anywhere in the codebase.
Demo
The app runs on localhost by design โ the point is that the conversation never leaves the laptop, so there is no hosted demo to link. <!-- TODO: drop a Loom/YouTube walkthrough URL in here before publishing -->
Run it: npm install && npm run db:migrate && npm run dev (with Ollama up and ollama pull qwen2.5:1.5b) โ client on :5173, API on :3001.
What a full session looks like (real run against qwen2.5:1.5b):
- Register โ land on the dashboard
- Paste a chat where a restaurant owner in Kathmandu asks for an online ordering system
-
POST /api/ai/analyzeโ ~36s cold โ extraction: Sita Gurung ยท ABC Restaurant ยท "online ordering system" ยท 40,000โ60,000 NPR ยท high priority - Review, approve โ lead appears in the list, searchable by
q=ABC - Open lead โ Generate follow-up (~26s) โ Generate proposal (~26s)
-
GET /api/healthโ{"status":"ok","db":"up","ai":"up","model":"qwen2.5:1.5b","provider":"ollama"}
Code
Three pieces that make a small local model trustworthy:
1. Constrained decoding + determinism. Extraction is a structured prediction, so the request carries a JSON Schema (format) and fixed sampling โ same conversation, same lead:
/** Deterministic extraction: temperature 0 + fixed seed + explicit context budget. */
export const EXTRACTION_OPTIONS = {
temperature: 0,
seed: 42,
numCtx: 8192,
numPredict: 512,
} as const;
export async function analyzeConversation(ai: AiProvider, conversation: string) {
const raw = await callAi(ai, buildLeadExtractionPrompt(conversation), {
system: LEAD_EXTRACTION_SYSTEM,
format: leadExtractionJsonSchema, // Ollama constrained decoding
...EXTRACTION_OPTIONS,
});
return parseLeadExtraction(raw);
}
2. Never trust the model's JSON. LLM output crosses a Zod boundary, or the request fails with a 502 the UI can show:
/** LLM -> JSON -> Zod. Rejects with a useful 502 instead of trusting the model. */
const result = leadExtractionSchema.safeParse(parsed);
if (!result.success) {
const details = result.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ');
throw new HttpError(502, `AI returned data that failed validation: ${details}`);
}
3. Prompt injection is a real risk with paste-anything input. I measured the 1.5B model failing to resist injections in the eval harness, so protection is layered: the system prompt treats conversation text as untrusted data, a detector flags instruction-shaped text before it reaches the model, and the review UI warns you:
const INJECTION_PATTERNS: RegExp[] = [
/ignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above|preceding)\s+instructions?/i,
/\[system\]/i,
/<\s*system\s*>/i,
/pretend\s+(?:to\s+be|you\s+are)\s+(?:an?\s+)?(?:system|developer|assistant)/i,
];
export function detectPromptInjection(text: string): string | null {
for (const pattern of INJECTION_PATTERNS) if (pattern.test(text)) return PROMPT_INJECTION_WARNING;
return null;
}
How I Built It
- Open-weight model, local inference: Qwen 2.5 1.5B through Ollama โ pulled once, works offline, zero API keys. The 1.5B size is deliberate: it runs on a laptop CPU and keeps the whole thing free, which is what makes it a side hustle tool rather than another subscription.
-
Provider abstraction:
AiProvider(health()/generate(prompt, options)) withformat: 'text' | 'json' | objectfor constrained decoding. Swapping to llama.cpp or a bigger Qwen is one class. -
Different sampling per task: extraction and proposals run at
temperature: 0, seed: 42; follow-ups run at0.7so regenerating gives you a different email instead of the same one. -
A 29-conversation eval harness (
npm run ai:eval -w server) that prints Expected / Actual / Pass-Fail per case โ this is how I found the model's habits, e.g. defaultingpriority: "medium"when the chat never mentions urgency, which is exactly why the review step exists. -
Around it: React 19 + Tailwind (dark mode, toasts, skeletons, keyboard shortcuts), Express with Zod validation, bcrypt auth, rate limits, allow-list CORS, Drizzle on Postgres with
ilikesearch, 109 tests, and a Docker Compose dev stack (React + Express + Postgres, Ollama as an optional profile).
Getting the containers talking took the usual compose lesson โ localhost inside a container is the container, so the Vite dev proxy switches to the compose service name:
proxy: { '/api': { target: process.env.VITE_API_PROXY ?? 'http://localhost:3001', changeOrigin: true } }
(I got this from The 'Localhost' Trap: Networking Microservices in Docker Compose by Omar Lashin, and the whole local-first framing echoes How I Built a Zero-API-Cost AI Agent That Runs 100% Locally โ No Keys, No Cloud, No Bills by scriptgather.)
Why Does Open Innovation Matter?
For this project it isn't a preference โ it's the product:
- The data can't leave. Client budgets and NDA'd plans are the input. A closed API means every paste is a disclosure; local inference means the transcript stays on disk.
- $0 marginal cost. The business case for a freelancer is "extract leads for free forever," not "another $20/token-metered seat."
-
No lock-in or pricing risk. The model is a file you
ollama pull. If Qwen's license, pricing, or availability changed tomorrow, the pipeline wouldn't notice โ and theAiProviderinterface means the vendor layer was never load-bearing. - Open weights make the limitations debuggable. I could evaluate the 1.5B model against 29 realistic conversations and see where it's weak (prompt injection, unstated priority), then engineer around it with a review gate and a detector. With a closed API you only get vibes โ and when their model version changes under you, your prompts silently break.
- Room to fine-tune for the niche. The extraction schema is domain-specific (budget ranges in local currency, project tags). With open weights, a few hundred in-domain examples can specialize it โ something a general API handles poorly and you can't tune at all.
My Agent Session
The full build session โ verifying Phases 17โ24 of the PRD, writing the Docker stack, the dark-mode sweep, the live end-to-end run against the local model, and drafting this post:
Top comments (0)