The Problem with Manual Cold Outreach
Last quarter, I had a clear target: SaaS founders who’d just closed a Series A, specifically those building on a modern stack like Next.js and Supabase. My goal was to offer a specialized dev-ops consulting service. Manually, this meant hours on Crunchbase, then LinkedIn, then trying to guess email formats, and finally, crafting a unique email for each person. It was slow, soul-crushing work. The personalization was shallow, often just a name and company, because deep research for hundreds of prospects just isn’t sustainable. My response rates were abysmal, and the time sink was eating into actual client work. I knew there had to be a better way, and I figured an AI agent could be it.
Building the Agent for Outreach
My first thought was to throw a large language model at the problem and hope for the best. That’s a rookie mistake, and I’ve made it before. Instead, I broke the process down. First, I needed data. Clay became my go-to for this. I set up a Clay table to pull in recent Series A announcements, filter by company size, and then enrich profiles with tech stack data (using their built-in integrations for BuiltWith and similar tools). This gave me a clean list of prospects, complete with their names, companies, roles, and confirmed tech stacks. This data step is non-negotiable; garbage in, garbage out applies even more acutely to agents. Without high-quality, verified data, any personalization effort is just guesswork, and an agent will only amplify that inaccuracy.
Next, the agent itself. I chose LangGraph for its explicit state management. I needed a clear flow, a directed acyclic graph of operations:
- Prospect Selection: Take a prospect from the Clay list. This involved a simple Python script pulling from Clay’s API and pushing to a queue.
- Research: Use a search tool (via a custom LangGraph tool) to find recent news, blog posts, or public statements from the founder or company. This is where the real personalization material lives. I built this tool to hit a few different public APIs – a news aggregator, a blog search, and a LinkedIn profile scraper (carefully, within terms of service). The challenge wasn’t just finding information, but extracting relevant snippets. I had to instruct the LLM within the tool to look for specific keywords related to growth, challenges, or recent product launches.
- Draft Email: Generate a personalized cold email draft, referencing the research and the prospect’s tech stack. This LangGraph node took the structured research output and a predefined persona/goal for the email.
- Review & Refine: A small, dedicated LLM chain to check for tone, clarity, and adherence to a predefined outreach strategy (e.g., “focus on pain points related to scaling Supabase”). This step was crucial for catching obvious errors and ensuring brand voice.
- Output: Store the final draft in a Google Sheet, ready for human review and sending.
The initial setup was fiddly. Getting the custom tools to reliably extract specific information from web pages was a constant battle. I spent days debugging regex patterns and prompt instructions for the research step. It’s not just about “giving the agent a tool”; it’s about defining the tool’s output schema and the agent’s expectations of that schema with surgical precision. If the research tool returned a malformed JSON or an empty string, the email drafting step would either hallucinate or crash. I learned to build in robust error handling and fallback mechanisms at every step. For example, if the primary news search failed, the agent would try a secondary blog search. If both failed, it would generate a more generic, but still tech-stack-specific, email, flagging it for extra human review. This kind of defensive programming is essential for agents.
Here’s a simplified idea of a LangGraph node for the research step:
def research_node(state):
prospect = state['current_prospect']
search_query = f"'{prospect['company_name']}' OR '{prospect['founder_name']}' recent news OR blog"
# Call custom search tool
search_results = custom_search_tool(query=search_query)
# Use LLM to summarize and extract key points
extracted_info = llm_summarize_tool(text=search_results, instructions="Extract 3-5 key points about recent company activity or founder statements relevant to growth or tech challenges.")
return {"research_data": extracted_info}
This snippet doesn’t show the full complexity, but it illustrates how a specific function acts as a tool, and its output then feeds into the next stage. The llm_summarize_tool itself is another agentic step, often a smaller, specialized LLM call.
The Silent Failures and Cost Overruns
This is where the rubber met the road, and where most agent deployments fall apart. My agent didn’t just “fail”; it failed silently. It would happily generate emails that looked plausible on the surface but were subtly wrong. A founder’s name misspelled, a company’s recent funding round misquoted, or a reference to a blog post that didn’t exist. These weren’t obvious errors; they required a human eye to catch. I’d review a batch of 50 emails, find 10 with subtle inaccuracies, and realize the agent had been running for hours, burning API credits on bad outputs. The worst part? The agent thought it was doing a great job. Its internal “confidence score” (if it had one) would have been high. This is the insidious nature of agent failures: they often produce plausible but incorrect outputs, making detection difficult without deep inspection.
The cost was another shock. Each research step involved multiple API calls to various services (search, summarization, entity extraction). An agent that loops even once or twice on a single prospect because it can’t find the right information, or because its internal “review” step keeps asking for revisions, quickly racks up a bill. I saw my OpenAI usage spike dramatically. I had to implement strict token limits on each LLM call and add a “max retries” counter to my LangGraph nodes. If a step failed more than three times, the prospect was flagged for manual review, and the agent moved on. This saved me hundreds of dollars a week. Without these guardrails, a runaway agent could easily blow through a monthly budget in a single afternoon. This isn’t just about cost; it’s about governance. You need to know who’s touching what data, and how much it’s costing you.
Debugging was a nightmare before I integrated LangSmith. Trying to trace the execution path of an agent, seeing which tool was called with what arguments, and what the LLM’s exact thought process was (its internal monologue, if you will) without a dedicated observability platform is like trying to find a needle in a haystack blindfolded. LangSmith gave me the visibility I needed. I could see the exact prompts, the tool outputs, and the LLM responses for every step of every run. I could filter by status, by cost, by latency. This was essential for identifying why the agent was making bad decisions or getting stuck. For example, I found that my news search tool was occasionally returning irrelevant articles, which then led the summarization LLM astray. By seeing the raw search results in LangSmith, I could refine the search query parameters and the summarization prompt. Without it, I honestly don’t think I could have shipped this agent to production. Langfuse offers similar capabilities, and I’ve heard good things, but I’m already deep in the LangSmith ecosystem.