Always-on agents · Same use cases, free tools

A prospecting agent that watches for buying signals

On 11 September Salesforce announced Hunter, an outbound sales agent that works a pipeline from research to outreach over weeks and months. I cannot buy that for a two-person company, so I built the part of it I actually want, ran it against real public feeds this afternoon, and kept the one rule that matters: it drafts, it never sends.

In short

A prospecting agent watches public signals, scores each one against what a buyer of yours looks like, remembers what it has seen, and ranks a short list every morning. Mine runs on a timer, reads job boards, release feeds and news, and drafts an opening line for the top signal. It has no code path that sends anything. A human does that part.

Part of the Always-on agents series, post 25 of 33.

Of all the agents in this autumn's launches, the one that made me most uncomfortable was the sales one. Not because it is a bad idea. Because an agent that pursues a goal for weeks, with memory, and whose goal is to get strangers to reply, is exactly the kind of thing I would want a very short leash on. So before I wrote this I went and read what Salesforce actually said it does, and then I built the piece of it I would run myself.

What Salesforce says Hunter does

Salesforce announced Hunter on 11 September 2026 as an outbound sales agent that, in its words, works a sales pipeline from research to outreach and collaborates with sellers over weeks and months. It is the first agent to run on what Salesforce calls its long-horizon runtime, built so agents can pursue goals across days and weeks instead of completing only a task or interaction. Three properties are named: memory that preserves context and progress across sessions, durable execution that keeps plans running over time and lets an agent resume or course-correct, and dynamic steering that adapts to a user's feedback. Hunter is in pilot, with general availability promised for November 2026. A second agent, Piper, handles inbound leads and is available now. Pricing was not in the announcement.

Hold those three properties against the three tests from my definition post: a goal that outlives a run, a trigger that is not a person typing, and an action followed by a report. Hunter clearly passes. What I notice is that the memory and the durability are the hard, boring parts, and they are the parts that make it dangerous to hand a sales goal to. An agent that forgets is annoying. An agent that remembers every prospect and keeps going is a liability if nobody is watching what it sends.

The version I am willing to run

I split prospecting into two halves. The listening half is reading public signals, deciding which ones look like a buyer, and remembering what has already been seen. The acting half is writing to a person. I am happy to hand the first half to an agent that runs every morning. I am not willing to hand it the second, and I built the agent so that it cannot do the second even by accident: there is no sending code in it. It drafts an opening line, prints it, and stops.

That split is the same one I use for the product I sell. Leo listens to what an audience is responding to, around the clock, and acts by booking posts onto a calendar. The acting half there is reversible, which is why I let it run. Outreach is not reversible. Once an email lands, it has landed.

What counts as a signal for my company

Leo turns long-form video a company already has into short social posts. So a buying signal for me is a team that has video and no time to cut it: a podcast, a webinar series, a YouTube channel, a fresh head of marketing or content marketing hire, a funding round that means a bigger content budget. I wrote those down as a table of words and weights, which is deliberately crude. A model could do this step better; a table does it for free, in a millisecond, and I can see exactly why a signal scored what it scored.

Signal wordWeightWhy
head of marketing, content marketing4A new owner of the problem I solve
video, podcast, webinar, series b, series c, demand gen3Footage exists, or budget just arrived
youtube, marketing, content, social, hiring, launch2Adjacent; worth a look, not a call
brand1Too broad on its own

The build: feeds, a store, a score, a list

The whole agent is one Python file with no dependencies beyond the standard library. It reads a list of feeds, keeps every signal it has ever seen in a SQLite database so the same job posting never scores twice, scores the new ones, and prints a ranked list. The optional model step, which drafts an opening line, talks to a local Ollama server if you give it one and is skipped if you do not. The morning run is a systemd timer or a cron line, which I cover in the Linux core guide.

prospect.py"""A prospecting agent that watches public signals, scores them, and ranks a morning list.
It drafts outreach. It never sends anything: there is no sending code in this file on purpose.

Usage: python3 prospect.py signals.txt state.sqlite [ollama_host] [model]
"""
import json, os, sqlite3, sys, urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

feeds_file, db_path = sys.argv[1], sys.argv[2]
OLLAMA = sys.argv[3] if len(sys.argv) > 3 else ""
MODEL = sys.argv[4] if len(sys.argv) > 4 else "qwen2.5:1.5b"

# What a buying signal looks like for my company: teams with video and no time to cut it.
WEIGHTS = {"video": 3, "podcast": 3, "webinar": 3, "youtube": 2, "marketing": 2, "content": 2,
           "social": 2, "hiring": 2, "launch": 2, "series b": 3, "series c": 3,
           "head of marketing": 4, "content marketing": 4, "demand gen": 3, "brand": 1}

db = sqlite3.connect(db_path)
db.execute("CREATE TABLE IF NOT EXISTS signal (id TEXT PRIMARY KEY, feed TEXT, title TEXT, "
           "link TEXT, score INT, seen TEXT)")

def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": "ao-prospect/0.1"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()

def entries(xml):
    root = ET.fromstring(xml)
    ns = {"a": "http://www.w3.org/2005/Atom"}
    out = []
    for it in root.iter("item"):                                   # RSS
        out.append((it.findtext("guid") or it.findtext("link"), it.findtext("title") or "",
                    it.findtext("link") or "", it.findtext("description") or ""))
    for e in root.iter("{http://www.w3.org/2005/Atom}entry"):      # Atom
        link = e.find("a:link", ns)
        out.append((e.findtext("a:id", namespaces=ns), e.findtext("a:title", default="", namespaces=ns),
                    link.get("href") if link is not None else "",
                    e.findtext("a:summary", default="", namespaces=ns)))
    return [(i, t.strip(), l, s) for i, t, l, s in out if i]

def score(title, summary):
    text = (title + " " + summary).lower()
    return sum(w for k, w in WEIGHTS.items() if k in text)

def draft(title, link):
    if not OLLAMA:
        return "(no model configured: draft skipped)"
    prompt = ("Write a two-sentence, plain, non-salesy opening line for an email to the company "
              "behind this signal, from Lera at Leo AI, which turns existing long-form video into "
              "social posts. Mention the signal specifically. Signal: %s (%s). Do not invent facts."
              % (title, link))
    body = {"model": MODEL, "stream": False, "options": {"temperature": 0.2},
            "messages": [{"role": "user", "content": prompt}]}
    req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=600) as r:
        return json.load(r)["message"]["content"].strip()

now = datetime.now(timezone.utc).isoformat(timespec="minutes")
fresh = []
for line in open(feeds_file):
    url = line.strip()
    if not url or url.startswith("#"):
        continue
    try:
        xml = fetch(url)
    except Exception as e:
        print("FEED FAILED %s: %s" % (url, str(e)[:80]))
        continue
    for eid, title, link, summary in entries(xml):
        if db.execute("SELECT 1 FROM signal WHERE id=?", (eid,)).fetchone():
            continue
        s = score(title, summary)
        db.execute("INSERT INTO signal VALUES (?,?,?,?,?,?)", (eid, url, title, link, s, now))
        if s > 0:
            fresh.append((s, url.split("/")[2], title, link))
db.commit()

fresh.sort(reverse=True)
print("Morning list %s: %d scored signal(s), top %d shown" % (now, len(fresh), min(5, len(fresh))))
for s, feed, title, link in fresh[:5]:
    print("  score %2d  [%s] %s\n            %s" % (s, feed, title, link))
if fresh:
    s, feed, title, link = fresh[0]
    print("\nDraft for the top signal (NOT sent; there is no send function in this agent):\n  "
          + draft(title, link).replace("\n", "\n  "))

The signals file is just URLs, one per line. For this run I used a GitHub releases feed, a remote job board, a technology news feed and a Hacker News search feed:

signals.txthttps://github.com/openclaw/openclaw/releases.atom
https://weworkremotely.com/categories/remote-programming-jobs.rss
https://techcrunch.com/feed/
https://hnrss.org/newest?q=%22head+of+marketing%22+OR+%22content+marketing%22

I ran it in a fresh python:3.12-slim container on my Mac on 20 October 2026, with no model attached. This is the real output, unedited:

First run, 20 October 2026FEED FAILED https://hnrss.org/newest?q=%22head+of+marketing%22+OR+%22content+marketing%22: HTTP Error 502: Bad Gateway
Morning list 2026-10-20T20:22+00:00: 26 scored signal(s), top 5 shown
  score  8  [weworkremotely.com] Tailscale: Analytics Engineer, Data
            https://weworkremotely.com/remote-jobs/tailscale-analytics-engineer-data
  score  7  [weworkremotely.com] Postscript: Client Strategist
            https://weworkremotely.com/remote-jobs/postscript-client-strategist
  score  7  [weworkremotely.com] Discord: Engineering Manager, Machine Learning (Safety)
            https://weworkremotely.com/remote-jobs/discord-engineering-manager-machine-learning-safety
  score  7  [weworkremotely.com] Air Apps: Framer Website Designer
            https://weworkremotely.com/remote-jobs/air-apps-framer-website-designer-1
  score  7  [techcrunch.com] Spotify launches paid podcast memberships with perks beyond bonus content
            https://techcrunch.com/2026/10/20/spotify-launches-paid-podcast-memberships-with-perks-beyond-bonus-content/

Draft for the top signal (NOT sent; there is no send function in this agent):
  (no model configured: draft skipped)

Three things in that output are the point. One feed returned a 502 and the agent said so and carried on, which is what an unattended job must do. Twenty-six items scored above zero out of everything in those feeds, which is already a filter. And the top five are mostly wrong for me: a data engineer at Tailscale is not a buyer of social video, it scored eight because the posting mentions content, hiring and marketing somewhere in a long description. That is what a word table does on day one, and it is why the ranked list goes to a person and not to an outbox.

Where the model earns its place, and where it does not

There are two places a model could go in this agent. The first is scoring: read each signal and decide whether this is a company that has video and no time. A small local model can do that, and I measure how well in the local models post. It would have demoted the Tailscale posting. The second is drafting: write the opening line for the top signal. I keep that step in, because a draft that mentions the actual signal saves me five minutes, and I keep it harmless, because it prints to a terminal and nowhere else.

What I would not do is let the model decide what to send, or to whom, or when. That is the difference between my version and the one Salesforce is selling, and I think the difference is the product. Hunter's value is that it carries the whole pipeline across weeks. The cost is that you have to trust it with your name in strangers' inboxes. I am not there, and I think most small companies should not be there either until they have watched the ranked list for a month and seen how often it is wrong.

Running it every morning

  1. Put the file and the signals list on a machine that stays on. A Mac that does not sleep, a free cloud box or a five-dollar server. The series has a guide for each.
  2. Give it a user and a timer. A systemd timer with Persistent=true, so a morning the machine was off is caught up on the next boot. The unit and timer are in the Linux core guide; only the ExecStart line changes.
  3. Point the output at you. Printing to a log is enough to start. When you trust it, add a Telegram bot and send the ranked list, the way the morning brief agent does.
  4. Review the list before anyone is written to. The draft is a starting line, not an email. Every send is a human action, in a human's mail client, under a human's name.

My own company runs two prospecting jobs on the same Mac that publishes this blog; they are called prospect-builder and prospect-engagement in the scheduler. The part of them I would defend to anyone is the part I have described here: a ranked list that a person reads, and no agent pressing send.

Sources

  1. Salesforce, Salesforce expands Agentforce with new agents (Hunter, Piper and the long-horizon runtime) (accessed October 20, 2026)
  2. We Work Remotely, Remote programming jobs RSS feed (accessed October 20, 2026)
  3. GitHub, OpenClaw releases feed (accessed October 20, 2026)

Written by Lera Leonteva. The run shown was made on 20 October 2026 in a fresh container; feed contents change daily.

Frequently asked questions

What does Salesforce's Agentforce Hunter actually do?

By Salesforce's own description on 11 September 2026, Hunter is an outbound sales agent that works a pipeline from research to outreach and collaborates with sellers over weeks and months. It runs on a long-horizon runtime with memory across sessions, durable execution and dynamic steering. It was in pilot at announcement, with general availability promised for November 2026.

Can I build a prospecting agent for free?

The listening half, yes. Public feeds, a SQLite store, a word-weight score and a ranked list need nothing but Python and a machine that stays on. A local model through Ollama can draft opening lines at no API cost. What you cannot get for free is the judgement about who to contact, which I keep human.

Why does your agent never send email?

Because sending is the one irreversible act in the whole loop. My rule for every unattended job is that it may only do what can be undone, and an email in a stranger's inbox cannot be undone. The agent has no sending code, so the rule cannot be broken by a bug or a bad prompt.

How do you stop the same signal appearing every day?

Every signal's id goes into a SQLite table the first time it is seen, and the agent skips anything already there. The database is the agent's memory; back it up with the rest of the machine.

How well does the word-weight scoring work?

On day one, badly in an informative way: in my run the top result was a data engineering job that happened to mention content and marketing. That is why the list goes to a person. A small local model as a second-pass judge improves it, and the local models post measures how much.

Lera Leonteva
Lera Leonteva
CEO & Founder, Leo AI

Lera Leonteva is the founder and CEO of Leo AI, where she works on turning long-form footage into short-form video that earns attention on its own merits. She writes Video Scaling Strategies, taking one piece of content that worked and one that did not, and using what the market is doing to explain the gap.

The always-on agent I run for social video

Leo watches what your audience is responding to, picks a clip from footage you already have, edits it and books it on the calendar. Every hour, without being asked. It is the agent this whole series grew out of.

Keep going with the series

Every post in the Always-on agents series is on one page, newest first. If you would rather see the agent I run for social video, here it is.