DEV Community

Ayush Singh Tomar
Ayush Singh Tomar

Posted on • Edited on

I Got Tired of Writing Cold Emails. So I Built an AI Agent to Do It for Me.

B2B sales reps spend hours researching a single lead — reading LinkedIn profiles, Googling the company, checking for recent news, then writing a personalized email that doesn't sound like a template. Most of that work is repetitive pattern-matching, not judgment. I wanted to see if an agent could do it better, faster, and without the copy-paste.

The result is SalesAgent — paste a LinkedIn URL, get a researched lead profile, an ML-based score (0–100), and a hyper-personalized cold email. End to end in under 45 seconds. No templates. No manual research. Just paste and go.

Live demo: salesagent-ai.streamlit.app
GitHub: github.com/ayush-s-tomar/salesagent


What It Does

  1. You paste a LinkedIn profile URL into the frontend
  2. A LangGraph agent kicks off
  3. The agent runs live Tavily web searches to research the lead and their company
  4. A scikit-learn model scores the lead 0–100 based on six signals
  5. Groq's LLaMA generates a personalized cold email referencing real company events
  6. You get: lead summary, score with breakdown, and a ready-to-send email

Here's what it looks like in action — I ran it on Satya Nadella's LinkedIn profile. It found real Microsoft Build 2026 keynote news and referenced it directly in the email:

SalesAgent running on Satya Nadella's LinkedIn profile

Subject: Congrats on Build 2026

Satya, the Microsoft Build 2026 keynote on June 2–3 featured the native Windows AI agent rollout and an expanded Copilot runtime — a strong signal of where Microsoft is taking the platform.

That's not a template. The agent found that news in real time and wrote around it — no preamble, no filler. (More on how I got it to write like this below.)


Architecture

SalesAgent Architecture Diagram

Three nodes. Each one enriches the context for the next. The scoring node doesn't call an LLM — it runs a trained ML model, which is faster and more deterministic for a classification task like this.

Stack: LangGraph · FastAPI · React · scikit-learn · Groq LLaMA 3.1 · Tavily · Streamlit Community Cloud


How Each Part Works

1. Research Node — Tavily + LangGraph

The agent calls Tavily's search API twice per lead:

  • Search 1: "{name} {company} LinkedIn" — pulls profile signals (title, summary, skills)
  • Search 2: "{company} news funding jobs 2024" — checks for recent company activity

Tavily returns structured results with titles, URLs, and content snippets. The LangGraph research node processes these into six binary/numeric signals that feed the scorer:

signals = {
    "has_company": bool,      # Is company name known?
    "has_title": bool,        # Is job title known?
    "skills_count": int,      # Number of skills (0–15)
    "has_summary": bool,      # Does profile have a summary?
    "has_news": bool,         # Did Tavily find company news?
    "has_jobs": bool,         # Did Tavily find job postings?
}
Enter fullscreen mode Exit fullscreen mode

has_news and has_jobs are the most valuable signals — they tell you whether the company is active and growing right now. That matters more than whether a LinkedIn summary exists.

2. Scoring Node — scikit-learn

The scorer uses a Gradient Boosting Classifier trained on 500 synthetic samples generated with numpy. Labels were assigned using a weighted formula:

score = (
    has_news    * 0.30 +   # Company is in the news = hot lead
    has_jobs    * 0.25 +   # Hiring = growing, budget exists
    has_title   * 0.20 +   # We know who we're targeting
    has_summary * 0.15 +   # They invest in their profile
    skills_count * 0.05 +  # Proxy for profile completeness
    has_company * 0.05     # Basic data quality check
)
Enter fullscreen mode Exit fullscreen mode

Why ML instead of just an LLM scoring the lead? Two reasons: speed and determinism. An LLM call adds 2–3 seconds and gives you a different score every run. A trained classifier runs in milliseconds and gives you the same score for the same inputs every time — which matters when you're building something people actually use.

In production, you'd retrain on real CRM data — won vs lost deals — with richer features like funding stage, company size, industry vertical, and email response rate. But for a portfolio project with no CRM access, synthetic training with domain-informed weights gets you a working, explainable scorer.

3. Email Generation Node — Groq + LLaMA

The email node takes the full lead context — name, title, company, recent news, job postings — and injects it into a structured prompt. Getting this right took a second pass.

The first version technically worked but still sounded like AI. It pulled real company news in, but the LLM kept wrapping those facts in filler:

"I hope this email finds you well. I was thrilled to see the exciting news about your company's recent innovative advancements..."

Real news, buried in template language. A recipient skims the first line, sees "I hope this finds you well," and stops reading — even if paragraph two has something genuinely specific to say.

So I went back into the prompt and made one change: forbid the filler outright, and force the email to open with a fact, not a greeting.

System: You are an expert B2B sales copywriter. Never use generic openers
like "I hope this email finds you well" or "I was excited to see." Open
with a specific fact about the company. Reference only real information
provided — no invented details.
Enter fullscreen mode Exit fullscreen mode

The difference was immediate. Same research, same lead, same model — just a tighter constraint on how it's allowed to start.

Before: "I hope this email finds you well. I was excited to see..."
After: "Congrats on the Build 2026 keynote on June 2 — the native Windows AI agent rollout and the expanded Copilot runtime were a strong signal of where Microsoft is taking the platform."

No preamble. It leads with the thing that makes the email worth reading.

Running it again on the same lead (Satya Nadella, against real Microsoft Build 2026 news) pushed the lead score from 84/100 to 90/100 — not because the scoring model changed, but because a fact-first email correlates with a more complete signal set (news found, jobs found, title confirmed) that the scorer weighs directly. Worth being honest about what that means: the scorer is still trained on synthetic data, not real won/lost deals, so it's a good relative signal between two runs of the same agent — not a claim that 90/100 means "this lead will convert."

SalesAgent full pipeline demo


What Broke (The Honest Part)

This is where I spent most of my time. Real projects break in ways tutorials never show you.

1. Groq Model Deprecations — Three Times

llama-3.3-70b-versatile failed. Switched to llama3-70b-8192. That was decommissioned. Tried llama3-groq-70b-8192-tool-use-preview — tool-calling didn't work properly. Ended up on llama-3.1-8b-instant, which is smaller but stable.

The lesson: never hardcode a model string. In a production system, this belongs in a config file or environment variable so you can swap it without touching code.

2. Tool-Calling Schema Bug — 400 Failed Generation

Groq was rejecting my tool schemas with a failed_generation 400 error. After multiple attempts to isolate it, the issue was that I was passing input_schema directly instead of extracting properties and required separately.

Wrong:

"input_schema": tool.input_schema
Enter fullscreen mode Exit fullscreen mode

Right:

"parameters": {
    "type": "object",
    "properties": tool.input_schema["properties"],
    "required": tool.input_schema.get("required", [])
}
Enter fullscreen mode Exit fullscreen mode

This took longer than it should have because the error message (failed_generation) gave no hint about the schema structure. If you're hitting this — check your tool schema first.

3. Interface Mismatch Between graph.py and llm.py

graph.py was calling run_with_tools(prompt=..., system=...) and expecting a (text, tool_log) tuple back. llm.py was written to accept messages=[] and return a dict. Classic interface mismatch between two files written in isolation.

Every bug from this — the prompt vs messages confusion, the system kwarg error, the tuple vs dict return type — cost me hours of debugging that a typed interface contract would have caught in seconds.

4. Python 3.14 on Render

pydantic-core failed to build because no wheel exists for Python 3.14. Fix: force PYTHON_VERSION=3.11.9 in the environment variables.

If you're deploying to a platform like Render: always pin your Python version explicitly. Don't trust the platform default.

5. The Cached ML Model That Wouldn't Let Go

After I rebalanced the scoring weights to favor the fact-first signals, the lead score stayed frozen — every run, no matter what I changed in the prompt or the code. I checked the prompt, checked the API response, checked the signal extraction. All correct. The score still didn't move.

The actual culprit: model.pkl, the trained scorer, was cached on disk from a previous deploy and never got retrained when the weighting logic changed underneath it. The code was right. The model on disk was stale. Nothing in the logs said so — it just quietly kept scoring against old assumptions.

Fix was one line in the build command: rm -f ml/model.pkl before every deploy, forcing a retrain from scratch each time. Cheap fix, expensive to find — because a stale artifact doesn't throw an error, it just gives you a plausible-looking wrong answer.

6. The Render Free Tier Got Suspended

Mid-project, my backend host (Render) suspended the free-tier deployment. No warning I saw in time — just a "This service has been suspended" page where the API docs used to be.

I didn't fight it. I pulled every reference to the old backend URL out of the README, replaced the curl example with a localhost version so anyone cloning the repo can still test the endpoint locally, and added an explicit line to the README's Known Limitations section rather than leaving a dead link for someone to discover on their own:

"Backend is not hosted live — the free-tier Render deployment was suspended, so the API is not reachable at a public URL right now. Run it locally with the steps above."

I ended up migrating the live demo to Streamlit Community Cloud instead, which is why the link above looks different from earlier screenshots.

Lesson: on a portfolio project, a dead link someone finds themselves is worse than an honest sentence explaining why it's dead.

7. Demo Assets and Docs Drifted From the Code

Between the screenshot, the GIF, and the README's "Demo Output" text block, I'd updated the code but not all three assets — so the README was showing an 84/100 score with old email copy while the actual screenshot showed 90/100 with the new fact-first output. Small inconsistency, but exactly the kind of thing a careful reader (or a hiring manager) notices.

Separately, the README's file tree still listed demo.mp4 as "optional" — leftover from before I'd actually recorded and embedded it — and didn't mention demo.gif at all, even though both were live in docs/. Nothing broken, just stale comments describing a folder that had since changed shape.

Fixed both by treating all demo assets as one unit: screenshot, GIF, and the "Demo Output" text get regenerated and reviewed together, not independently.


What I'd Do Differently

Define the LLM interface contract on day one.

The biggest source of bugs was graph.py and llm.py making different assumptions about function signatures, return types, and argument names — and those assumptions were never written down anywhere.

If I rebuilt SalesAgent today, the first file I'd create:

# contracts.py — written before any other code

def run_with_tools(prompt: str, system: str) -> tuple[str, list[dict]]:
    """Run LLM with tool-calling. Returns (response_text, tool_call_log)."""
    ...

def chat(messages: list[dict], system: str) -> str:
    """Simple chat completion. Returns response string."""
    ...
Enter fullscreen mode Exit fullscreen mode

One typed file, agreed upfront. Every bug from the interface mismatch would have been caught before a single line of agent logic was written.

Walk through the whole project as a stranger would before sharing it. Click every link, rerun the pipeline end to end, read every README claim against what's actually running. None of the bugs above were algorithm problems — they were discipline problems: a prompt that quietly regressed into filler, a model artifact that silently went stale, docs that drifted from the code. This is the cheapest QA pass available, and it's the one that catches stale caches and dead links before someone else does.

Beyond that — in a production version, I'd replace synthetic training data with real CRM data (won/lost deals) and add email open tracking to close the feedback loop, so the scorer retrains on actual outcomes instead of assumed weights.


Try It

Paste any LinkedIn URL into the live demo and watch the full research → score → draft pipeline run in real time. The email quality scales with how much public news exists about the company — well-covered companies get sharper, more specific emails.

Live demo: salesagent-ai.streamlit.app
GitHub: github.com/ayush-s-tomar/salesagent

If you're building something similar, or have hit similar prompt-drift, stale-cache, or deploy-suspension issues on your own projects, I'd genuinely like to hear about it — connect with me on LinkedIn.


Stack: LangGraph · FastAPI · React · scikit-learn · Groq LLaMA 3.1 · Tavily · Streamlit Community Cloud

Tags: #ai #python #machinelearning #langchain #buildinpublic

Top comments (0)