- Productivity
- v1.0.0
- sales
- cold-email
- prospecting
- pipeline
- objections
Sales Playbooks
Outbound sales for an SDR agent: prospect by signal, write short human cold email, handle replies, qualify and book, with a pre-send gate and a local pipeline. Nothing sends without your approval.
SKILL.md
Sales Playbooks
This skill turns an agent into a disciplined SDR: it finds prospects for a reason (a signal), writes short human email, handles replies, qualifies, books, and keeps a pipeline the owner can read. Nothing leaves without the owner's yes, and nothing leaves that fails the pre-send gate.
Adapted from Harvey (MIT, © 2026 Ethan Rogers); see NOTICE.
What is in here
scripts/playbooks/ (shipped under scripts/ because that is what the registry delivers)
sales_methodology.md the loop, channel rules, decision priorities, metrics, hard ethical rules
prospecting_tactics.md finding companies and people without paid tools; signals; daily quotas
lead_qualification.md BANT screen, ICP score 1-10, MEDDIC, disqualifiers
account_navigation.md companies vs contacts, who to email first, multi-threading, sample review
signal_playbook.md turning one signal into the first line; which signal wins
email_frameworks.md the 3-email sequence, banned phrases, subject lines, the test
offer_strategy.md earn the right to ask; offer ladder; booking mechanics; pricing questions
objection_handling.md LAARC, the 4 categories, diagnose first, hard guardrails
reply_handling.md intents, conversation stages, what never gets a reply
linkedin_outreach.md LinkedIn via Linkedroid: connect, message, limits
scripts/
gate.py deterministic pre-send check, exit 0 = may send
pipeline.py SQLite pipeline: companies, contacts, observations (signals), outbox, conversations, suppressions
Read scripts/playbooks/sales_methodology.md first. Then the playbook for the task at hand: write a sequence
→ email_frameworks.md + signal_playbook.md; a reply came in → reply_handling.md +
objection_handling.md; building a list → prospecting_tactics.md + lead_qualification.md.
The rules that are not negotiable
- Nothing sends without the owner. Every outgoing email or LinkedIn message is shown in chat, one at a time, and sent only after an explicit yes for that message (or for a batch the owner asked to approve as a batch). Never approve for them. Never "send the rest like this".
- The gate runs before every send, after approval, on the exact text going out:
python3 scripts/gate.py --to <email> --subject <s> --body-file <f> [--status verified|risky|guess] [--kind sequence|reply]. Exit 0 means send. Anything else: fix the email, re-show it, re-approve. Never bypass the gate. - Opt-outs are absolute. "Stop", "unsubscribe", "remove me", "not interested, don't email again"
in any wording and on any channel:
pipeline.py suppress <email> --reason opt_out, confirm once if a reply is appropriate, never contact again, never try to save it. - Never fabricate. No invented customers, numbers, case studies, scarcity, referrals, or prior
conversations. Every claim traces to
sales/product_knowledge.mdor the prospect's own public pages. - Say you are automated if asked. Immediately and truthfully.
- When in doubt, don't send. Flag it to the owner instead.
Where things live
sales/config.yaml— persona, product, offer, ICP, channels, caps, quiet hours (written during onboarding, see the template's BOOTSTRAP.md).sales/product_knowledge.md,sales/competitive_intel.md— learned from the owner's website and answers; the only sources for product claims.sales/pipeline.db— the SQLite store managed byscripts/pipeline.py(never edit by hand).sales/drafts/— email text files the gate reads.
All paths are under the workspace: /root/.openclaw/workspace/sales/.
pipeline.py in one screen
pipeline.py init
pipeline.py add-company --name "Acme" --domain acme.com [--industry ...] [--size ...] [--location ...] [--source ...]
pipeline.py add-contact --company acme.com --first Sarah --last Lee --title "VP Marketing" [--email s@acme.com --email-status verified|risky|guess] [--linkedin URL]
pipeline.py observe --company acme.com [--contact s@acme.com] --signal HIRING_ROLE --text "Head of Growth" [--url ...] [--confidence 0.9]
pipeline.py signals # the catalog you may record (nothing is collected by default)
pipeline.py cohort --must HIRING_ROLE,DECISION_MAKER_TITLE --not INCUMBENT_AGENCY # companies matching a signal query
pipeline.py score --contact s@acme.com --points 8 --note "exact title, verified email"
pipeline.py queue --contact s@acme.com --step 1 --subject "acme website" --body-file sales/drafts/acme-1.txt [--send-after 2026-10-22T14:00:00Z]
pipeline.py outbox # what waits for approval, in order
pipeline.py approve <id> | reject <id> --reason ...
pipeline.py sent <id> --message-id ... # after the connector confirms
pipeline.py reply --contact s@acme.com --intent interested|objection|question|wrong_person|not_interested|opt_out|angry_legal|bounce --text "..."
pipeline.py stage --contact s@acme.com --to engaged|qualifying|presenting|negotiating|closing|closed_won|closed_lost
pipeline.py suppress <email> --reason opt_out|bounce|angry|legal|not_fit
pipeline.py is-suppressed <email> # exit 0 = suppressed, do not contact
pipeline.py stats # counts the heartbeat decides from
pipeline.py export --what contacts|companies|outbox|conversations > file.csv
Every fact is a row in observations with a signal code, value, confidence, evidence URL and time,
so a company can be re-profiled later and the history kept.
Sending
- Gmail:
gws gmail users messages sendwith a plain-text body (thegoogle-workspaceskill has the exact command). Create a draft first when the owner wants to send from their own client:gws gmail users drafts create. - Outlook / Microsoft 365: the
mailcommand from theoutlookskill. - LinkedIn: the Linkedroid tools (
linkedin_send_connection,linkedin_send_message), within the limits inscripts/playbooks/linkedin_outreach.md. Never browser automation. - Plain text only. At most one link, none in email 1. No tracking pixels. The owner's real name, company and physical mailing address in the signature; a working way to say stop.
The heartbeat
The template's HEARTBEAT.md runs the loop. Priority each pass: replies → send approved → write
sequences for queued prospects → prospect → report. Respect quiet_hours and max_daily_sends
from sales/config.yaml; stop all sending when bounces exceed the threshold and tell the owner.
Scripts
12 files ship with this skill. They are installed beside the SKILL.md.
#!/usr/bin/env python3
"""Deterministic pre-send gate: the last check before an email leaves.
Prompt rules are a floor, not a guarantee. Every outgoing email passes these non-model checks after
the owner approved it and before the connector sends it. A failure blocks the send and says why, so a
bad draft can never reach a prospect. Adapted from Harvey's gate.py (MIT, Ethan Rogers).
Usage:
gate.py --to EMAIL --subject TEXT --body-file PATH [--status verified|risky|guess|invalid]
[--allow-risky] [--kind sequence|reply] [--max-words N] [--json]
Exit 0: may send. Exit 1: blocked (reasons on stdout). Exit 2: usage error.
--status the address's deliverability as recorded in the pipeline (default: guess)
--kind reply the person wrote to us first, so deliverability is proven and --status is ignored
"""
import argparse
import json
import re
import sys
# Spam-filter and AI-tell phrases that must never appear in outgoing mail. Mirrors
# playbooks/email_frameworks.md; enforced here because a playbook can be ignored.
BANNED_PHRASES = (
"act now", "buy now", "click here", "click below", "limited time", "limited offer",
"exclusive deal", "special offer", "risk-free", "no obligation", "100% free", "make money",
"double your", "guaranteed results", "winner", "congratulations",
"i hope this finds you well", "hope this email finds you well", "i hope this email finds you",
"picture this", "what if i told you", "in today's fast-paced world", "game-changer",
"game changer", "cutting-edge", "revolutionize your", "unlock your", "supercharge your",
"just following up", "just checking in", "i'd love to", "reaching out because",
"quick question", "worth a quick look", "worth a quick chat", "worth a quick call",
"as an ai", "i'm an ai", "i am an ai", "as a language model",
)
MAX_SUBJECT_LEN = 90
DEFAULT_MAX_BODY_WORDS = 220 # hard ceiling; the playbook aims far lower (75 for email 1)
MAX_LINKS = 1
EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")
MERGE_TAG_RE = re.compile(r"\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}(?!\})")
URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
HTML_RE = re.compile(r"<\s*(html|body|div|table|img|a|p|br|span)\b", re.IGNORECASE)
DELIVERABLE = {"verified", "risky"}
def pre_send_check(to_email, subject, body, *, status="guess", allow_risky=False, kind="sequence",
max_words=DEFAULT_MAX_BODY_WORDS):
"""Every deterministic check. Returns a list of reasons; empty means the email may go."""
reasons = []
subject = (subject or "").strip()
body = (body or "").strip()
to_email = (to_email or "").strip().lower()
if not EMAIL_RE.match(to_email):
reasons.append(f"invalid recipient address '{to_email}'")
if kind == "sequence":
allowed = DELIVERABLE if allow_risky else {"verified"}
if (status or "guess") not in allowed:
reasons.append(f"email status '{status or 'guess'}' is not deliverable (need {', '.join(sorted(allowed))})")
if not subject:
reasons.append("empty subject")
if not body:
reasons.append("empty body")
if len(subject) > MAX_SUBJECT_LEN:
reasons.append(f"subject too long ({len(subject)} > {MAX_SUBJECT_LEN})")
if re.match(r"^\s*(re|fwd?)\s*:", subject, re.IGNORECASE) and kind == "sequence":
reasons.append("fake reply/forward subject on a fresh thread")
words = len(body.split())
if words > max_words:
reasons.append(f"body too long ({words} words > {max_words})")
leftover = MERGE_TAG_RE.findall(subject + " " + body)
if leftover:
reasons.append(f"unrendered merge tags: {sorted(set(leftover))[:3]}")
lowered = (subject + " " + body).lower()
hits = [p for p in BANNED_PHRASES if p in lowered]
if hits:
reasons.append(f"banned phrases: {hits[:3]}")
if "—" in body or "—" in subject:
reasons.append("em dash in text (an AI tell; use a period or comma)")
links = URL_RE.findall(body)
if len(links) > MAX_LINKS:
reasons.append(f"too many links ({len(links)} > {MAX_LINKS})")
if HTML_RE.search(body):
reasons.append("HTML markup in body (plain text only)")
if body.count("!") > 1:
reasons.append("more than one exclamation mark")
return reasons
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--to", required=True)
p.add_argument("--subject", required=True)
p.add_argument("--body-file", required=True)
p.add_argument("--status", default="guess", choices=["verified", "risky", "guess", "invalid"])
p.add_argument("--allow-risky", action="store_true")
p.add_argument("--kind", default="sequence", choices=["sequence", "reply"])
p.add_argument("--max-words", type=int, default=DEFAULT_MAX_BODY_WORDS)
p.add_argument("--json", action="store_true")
a = p.parse_args(argv)
try:
with open(a.body_file, encoding="utf-8") as f:
body = f.read()
except OSError as e:
print(f"cannot read body file: {e}", file=sys.stderr)
return 2
reasons = pre_send_check(a.to, a.subject, body, status=a.status, allow_risky=a.allow_risky, kind=a.kind, max_words=a.max_words)
if a.json:
print(json.dumps({"ok": not reasons, "reasons": reasons, "words": len(body.split())}))
elif reasons:
print("BLOCKED:")
for r in reasons:
print(f" - {r}")
else:
print(f"OK ({len(body.split())} words)")
return 0 if not reasons else 1
if __name__ == "__main__":
sys.exit(main())