DEV Community

Cover image for How I Built an Autonomous PR Agent with SerpApi, LangGraph, and LangSmith
Stefano Casafranca
Stefano Casafranca

Posted on

How I Built an Autonomous PR Agent with SerpApi, LangGraph, and LangSmith

Building in Public an Autonomous Marketing Agent with SerpApi, LangGraph, and LangSmith inside Reddit to promote my GitHub Project.

Most "AI agent" tutorials show you a ReAct loop that calls tools until the LLM decides it's done. That works for demos. It breaks in production.

I built an autonomous PR / Mkt agent called Doug that discovers relevant Reddit threads via SerpApi, drafts contextual comments, and post them on a schedule, all without human intervention. It runs on GitHub Actions, traces every decision in LangSmith, and has been operating in production for 2 weeks now.

Here's how I designed it to actually work till the point of "drafting the response for the Reddit Thread".

Disclaimer: The final step for the agent was supposed to be posting in reddit itself but seems like Reddit banned that...I have to copy the draft and post it on the thread that my agent Doug found for me each morning so I can market my plugin and help more devs get rid of using WordPress for their clients as the CMS and use the open source tool I created: Build_Script -- so Google Docs turns into the new CMS.


Why ReAct Loops Break in Production

The standard agent pattern gives the LLM full control. It picks which tools to call, how many times, and when to stop. For a chatbot? Fine. For an autonomous agent running on a cron with real API keys and real Reddit credentials? That's asking for trouble.

What happens when the LLM decides to retry a failed API call 30 times? Or ranks 0 threads and drafts 0 comments because it "wasn't confident enough"? Or ignores your subreddit allowlist because the prompt said "use your best judgment"?

I learned this the hard way. Doug's architecture is the result: deterministic control plane, bounded semantic reasoning.

The Split That Makes It Work

Doug has two layers, and they never cross:

  1. Control plane (pure code): Scheduling, policy checks, posting caps, subreddit allowlists, deduplication, idempotency. Zero LLM involvement.
  2. Semantic layer (bounded LLM calls): Exactly two LLM calls per cycle. One ranks threads. One drafts comments. That's it. No loops, no retries, no "let the model decide."
START
  |
  v
load_context ............. (deterministic: read brain files, load posted URLs)
  |
  v
collect_candidates ....... (SerpApi + PRAW: dual discovery)
  |
  v
merge_and_dedup .......... (deterministic: combine, deduplicate by URL)
  |
  v
policy_filter ............ (deterministic: allowlist, age, score, already-posted)
  |
  v
rank_candidates_llm ...... (LLM CALL #1: rank top 5 by relevance)
  |
  v
draft_comments_llm ....... (LLM CALL #2: draft a comment for each)
  |
  v
apply_post_policy ........ (deterministic: check mode + daily cap)
  |
  +----> post_comments ... (conditional: only if policy says yes)
  |
  v
finalize_run ............. (persist state, email summary)
  |
  v
END
Enter fullscreen mode Exit fullscreen mode

The LLM never decides whether to post. Code does. The LLM never decides how many comments to write. Caps do. Its job is purely semantic: "which threads match?" and "what's a helpful comment?"

Step 1: Discovery with SerpApi

The first real problem is finding threads worth commenting on. Reddit's own search is slow to index new content and misses a lot. SerpApi flips this by searching Google's index of Reddit, which is way more comprehensive.

from serpapi import GoogleSearch

@tool
def search_serpapi(keywords: str, max_results: int = 20) -> list[dict]:
    """Search Google for Reddit threads via SerpApi."""
    params = {
        "engine": "google",
        "q": f"site:reddit.com {keywords}",
        "tbs": "qdr:w",  # last week only
        "num": max_results,
        "api_key": os.environ["SERPAPI_API_KEY"],
    }
    search = GoogleSearch(params)
    results = search.get_dict().get("organic_results", [])

    candidates = []
    for r in results:
        url = r.get("link", "")
        subreddit_match = re.search(r"reddit\.com/r/(\w+)", url)
        candidates.append({
            "title": r.get("title", ""),
            "url": url,
            "subreddit": subreddit_match.group(1) if subreddit_match else "",
            "snippet": r.get("snippet", ""),
            "source": "serpapi",
        })
    return candidates
Enter fullscreen mode Exit fullscreen mode

Why SerpApi over Reddit's API? Google already ranks Reddit threads by relevance. When someone posts "best CLI tool for content management" on r/webdev, Google's index surfaces it faster and more accurately than Reddit's native search. SerpApi gives me structured access to that ranking.

I also run PRAW (Reddit's Python wrapper) as a fallback to catch threads less than 1 hour old that Google hasn't indexed yet:

@tool
def search_reddit(keywords: str, max_results: int = 20) -> list[dict]:
    """Fallback: catch fresh threads PRAW finds before Google."""
    reddit = praw.Reddit(
        client_id=os.environ["REDDIT_CLIENT_ID"],
        client_secret=os.environ["REDDIT_CLIENT_SECRET"],
        user_agent="doug-agent/1.0",
    )
    candidates = []
    for submission in reddit.subreddit("all").search(
        keywords, sort="new", limit=max_results
    ):
        candidates.append({
            "title": submission.title,
            "url": f"https://reddit.com{submission.permalink}",
            "subreddit": submission.subreddit.display_name,
            "score": submission.score,
            "age_hours": (time.time() - submission.created_utc) / 3600,
            "source": "praw",
        })
    return candidates
Enter fullscreen mode Exit fullscreen mode

After both sources return, a deterministic merge-and-dedup step combines them by URL, giving priority to SerpApi when both find the same thread.

Step 2: Policy Filtering (No LLM Needed)

Before the LLM touches anything, hard constraints filter out garbage. This is pure code, no ambiguity:

SUBREDDIT_ALLOWLIST = {
    "ClaudeAI", "LocalLLaMA", "programming", "Python",
    "OpenAI", "Anthropic", "MachineLearning", "webdev",
    "coding", "learnprogramming",
}
MAX_THREAD_AGE_DAYS = 14
MIN_THREAD_SCORE = 2

def filter_candidates(candidates, posted_urls):
    """Pure code. No LLM. No hallucination risk."""
    filtered = []
    for c in candidates:
        subreddit = c.get("subreddit", "").replace("r/", "")
        if subreddit not in SUBREDDIT_ALLOWLIST:
            continue
        if c.get("age_hours", 0) > MAX_THREAD_AGE_DAYS * 24:
            continue
        if c.get("url", "") in posted_urls:
            continue  # already commented on this one
        if c.get("score", 0) < MIN_THREAD_SCORE and c.get("source") != "serpapi":
            continue
        filtered.append(c)
    return filtered
Enter fullscreen mode Exit fullscreen mode

This is the part most tutorials skip. You don't need a prompt to say "only post in approved subreddits." You need an allowlist. You don't need the model to "remember" which threads you already commented on. You need a set lookup.

Step 3: Building the Graph with LangGraph

Here's where LangGraph earns its keep. Instead of a flat script, the workflow is a compiled graph with explicit nodes, edges, and one conditional branch:

from langgraph.graph import StateGraph, END

def build_reddit_hunt_graph():
    graph = StateGraph(DougState)

    graph.add_node("load_context", load_context)
    graph.add_node("collect_candidates", collect_candidates)
    graph.add_node("merge_and_dedup", merge_and_dedup)
    graph.add_node("policy_filter", policy_filter)
    graph.add_node("rank_candidates_llm", rank_candidates_llm)
    graph.add_node("draft_comments_llm", draft_comments_llm)
    graph.add_node("apply_post_policy", apply_post_policy)
    graph.add_node("post_comments", post_comments)
    graph.add_node("finalize_run", finalize_run)

    graph.set_entry_point("load_context")
    graph.add_edge("load_context", "collect_candidates")
    graph.add_edge("collect_candidates", "merge_and_dedup")
    graph.add_edge("merge_and_dedup", "policy_filter")
    graph.add_edge("policy_filter", "rank_candidates_llm")
    graph.add_edge("rank_candidates_llm", "draft_comments_llm")
    graph.add_edge("draft_comments_llm", "apply_post_policy")

    # The only conditional edge: post or skip
    graph.add_conditional_edges("apply_post_policy", route_after_policy, {
        "post_comments": "post_comments",
        "finalize_run": "finalize_run",
    })

    graph.add_edge("post_comments", "finalize_run")
    graph.add_edge("finalize_run", END)

    return graph.compile()
Enter fullscreen mode Exit fullscreen mode

Why a graph instead of a script? Three reasons:

  1. LangSmith traces every node. I see exactly where time goes, what each node produced, and where failures happen.
  2. Conditional routing is explicit. route_after_policy returns either "post_comments" or "finalize_run". No magic.
  3. State flows cleanly. Each node reads from and writes to a typed state object. No globals, no side effects.

Step 4: LLM Ranking (One Call, Bounded)

This is LLM call #1. One call. No ReAct loop. No retries. The model sees at most 15 candidates and returns its top 5:

def rank_candidates_llm(state: DougState) -> dict:
    candidates = state.get("filtered_candidates", [])
    if not candidates:
        return {"ranked_candidates": [], "threads_ranked": 0}

    llm = ChatAnthropic(model="claude-sonnet-4-6", temperature=0, max_tokens=4096)

    prompt = (
        "Rank these Reddit threads by how well BUILD_SCRIPT answers "
        "the poster's question. Return a JSON array of the top 5 "
        "thread URLs, ordered best to worst. Only include threads "
        "where BUILD_SCRIPT is a genuine, helpful answer.\n\n"
        f"Candidates:\n{json.dumps(candidates[:15], indent=2)}"
    )

    response = llm.invoke([
        SystemMessage(content=state["system_prompt"]),
        HumanMessage(content=prompt),
    ])

    ranked_urls = _extract_json_array(response.content)
    url_to_candidate = {c["url"]: c for c in candidates}
    ranked = [url_to_candidate[u] for u in ranked_urls if u in url_to_candidate]

    return {"ranked_candidates": ranked, "threads_ranked": len(ranked)}
Enter fullscreen mode Exit fullscreen mode

The key decision here: I don't let the model explain why. I don't ask for reasoning chains. I ask for a ranked list and move on. Explanations are for debugging in LangSmith, not for runtime.

Step 5: Drafting Comments (One Call, Done)

LLM call #2. Again, single call, bounded output:

def draft_comments_llm(state: DougState) -> dict:
    ranked = state.get("ranked_candidates", [])
    if not ranked:
        return {"drafted_comments": [], "drafts_created": 0}

    llm = ChatAnthropic(model="claude-sonnet-4-6", temperature=0, max_tokens=4096)

    prompt = (
        "Draft a genuine, helpful Reddit comment for each thread. "
        "Answer the poster's question and naturally mention BUILD_SCRIPT "
        "as a solution where relevant. Keep each under 200 words. "
        "Return a JSON array with 'thread_url' and 'comment_body'.\n\n"
        f"Threads:\n{json.dumps(ranked[:5], indent=2)}"
    )

    response = llm.invoke([
        SystemMessage(content=state["system_prompt"]),
        HumanMessage(content=prompt),
    ])

    drafts = _extract_json_array(response.content)

    # Persist drafts for review
    draft_dir = MEMORY_DIR / "drafts" / datetime.now().strftime("%Y-%m-%d")
    draft_dir.mkdir(parents=True, exist_ok=True)
    (draft_dir / f"drafts-{datetime.now().strftime('%H%M%S')}.json").write_text(
        json.dumps(drafts, indent=2)
    )

    return {"drafted_comments": drafts, "drafts_created": len(drafts)}
Enter fullscreen mode Exit fullscreen mode

Each draft gets persisted to disk regardless of whether it gets posted. This is important: even in shadow mode (more on that below), I can review every draft the agent produced and tweak the system prompt accordingly.

The JSON Extraction Problem

LLMs don't always return clean JSON. Sometimes there's a markdown fence around it. Sometimes there are literal newlines inside strings. Doug includes a robust parser that handles the messiest outputs:

def _extract_json_array(text: str) -> list:
    # Try the full text first
    match = re.search(r'\[.*\]', text, re.DOTALL)
    if not match:
        return []

    raw = match.group()
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        pass

    # Fix unescaped newlines inside strings
    fixed = re.sub(
        r'(?<=": ")(.*?)(?="[,\}\]])',
        lambda m: m.group().replace('\n', '\\n'),
        raw, flags=re.DOTALL,
    )
    try:
        return json.loads(fixed)
    except json.JSONDecodeError:
        pass

    # Last resort: extract individual objects
    return [
        json.loads(m.group())
        for m in re.finditer(r'\{[^{}]*\}', raw, re.DOTALL)
        if _is_valid_json(m.group())
    ]
Enter fullscreen mode Exit fullscreen mode

This saved me from at least 3 failed runs where the model wrapped JSON in triple backticks or used literal line breaks inside comment bodies.

Shadow Mode: Don't Ship Blind

Doug starts in shadow mode. For three days, it runs the entire pipeline: SerpApi discovery, LLM ranking, LLM drafting, email summaries. Everything except posting to Reddit.

def check_shadow_promotion(state) -> bool:
    return (
        state.mode == "shadow"
        and state.healthy_shadow_days >= 3
        and state.health_status == "healthy"
    )
Enter fullscreen mode Exit fullscreen mode

After three healthy days, mode transitions to live automatically. I almost shipped without this. During shadow mode, I caught a draft where the LLM hallucinated a feature that Build_Script doesn't have. That would have been embarrassing on Reddit.

Seeing Inside with LangSmith

Every graph.invoke() is traced with structured metadata:

result = graph.invoke({"runtime_state": state}, config={
    "run_name": "doug:reddit_hunt",
    "tags": ["reddit_hunt", state.mode, f"day-{state.mission_day}"],
    "metadata": {
        "run_id": state.run_id,
        "mode": state.mode,
        "brain_hash": state.brain_hash,
    },
})
Enter fullscreen mode Exit fullscreen mode

I can open LangSmith and see: which threads SerpApi returned, how the LLM ranked them, what comments it drafted, whether the policy layer allowed posting. Every run. Retroactively. When a draft reads weird, I trace back through the ranking node to understand why that thread was selected. No guessing.

Running on GitHub Actions

Two cron workflows. No servers.

name: Doug Reddit Hunt
on:
  schedule:
    - cron: '7 15 * * 1,3,5'  # Mon/Wed/Fri 10:07 AM CT

jobs:
  hunt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - run: python -m agents.doug
        env:
          SERPAPI_API_KEY: ${{ secrets.SERPAPI_API_KEY }}
          LANGCHAIN_API_KEY: ${{ secrets.LANGCHAIN_API_KEY }}
          LANGCHAIN_TRACING_V2: true
          LANGCHAIN_PROJECT: o1-swarm-doug
      - name: Commit memory
        run: |
          git config user.name "Doug [bot]"
          git config user.email "doug-bot@users.noreply.github.com"
          git add memory/
          git diff --cached --quiet || git commit -m "doug: reddit_hunt cycle"
          git push
Enter fullscreen mode Exit fullscreen mode

Costs: effectively zero. GitHub Actions free tier covers it. SerpApi handles 100 searches/month on the free plan. Two Claude API calls per cycle at ~$0.01 each.

After every run, Doug commits its memory (drafts, metrics, run artifacts) back to the repo. Full audit trail, version controlled.

What I Actually Do Every Morning

Since Reddit blocked automated posting, here's my workflow now:

  1. Wake up. Check the email Doug sent me overnight with the thread list and drafted comments.
  2. Open each thread URL.
  3. Read the draft. If it's good (it usually is), I copy-paste it as my comment.
  4. If a draft needs tweaking, I edit it. Takes 30 seconds.

Total time: ~5 minutes per morning to promote Build_Script across 3-5 relevant threads. Without Doug, finding those threads alone would take 30+ minutes, and I probably wouldn't do it consistently.

What I'd Do Differently

Start with shadow mode from day one. Three days of validation caught a hallucinated feature description before it hit Reddit.

Use SerpApi as primary, not fallback. I originally had PRAW as the main discovery source. Google's index is more comprehensive, and SerpApi's structured output is cleaner to parse. PRAW is better as the "catch fresh threads" supplement.

Keep LLM calls bounded and countable. Every time I was tempted to add "one more LLM call" to handle an edge case, I wrote a deterministic check instead. Two calls per cycle. The agent got more reliable every time I said no to a third.

The Stack

  • Python 3.12
  • LangGraph for workflow orchestration
  • LangSmith for tracing and observability
  • SerpApi for Google-powered Reddit discovery
  • PRAW for real-time Reddit fallback
  • Claude Sonnet for ranking and drafting
  • GitHub Actions for scheduling (Mon/Wed/Fri + daily health check)
  • Gmail SMTP for operational reporting

Full source: o1-swarm on GitHub


If you're building agents that need to run without you watching, the architecture matters more than the model. Deterministic control, bounded LLM calls, and full observability are what separate a demo from something you actually trust to run on a cron.

Doug finds me 3-5 relevant threads every Monday, Wednesday, and Friday morning. I copy-paste and post. That's the whole workflow. The boring, reliable kind.

Top comments (0)