← All skills
  • 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

  1. 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".
  2. 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.
  3. 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.
  4. Never fabricate. No invented customers, numbers, case studies, scarcity, referrals, or prior conversations. Every claim traces to sales/product_knowledge.md or the prospect's own public pages.
  5. Say you are automated if asked. Immediately and truthfully.
  6. 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 by scripts/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 send with a plain-text body (the google-workspace skill 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 mail command from the outlook skill.
  • LinkedIn: the Linkedroid tools (linkedin_send_connection, linkedin_send_message), within the limits in scripts/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.

scripts/gate.py5.5 KB
#!/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())