← All articles

GTM Engineering

The Approve Button Is a Feature, Not a Bottleneck

An AI agent drafted 40 emails and shipped them before anyone read one. Here is the five-rung approval gate I put between the draft and the send, with the block-kit payload and the guardrail logic.

· 14 min read

Forty emails went out before anyone read one of them. The agent fired on a funding signal, drafted the sequence, and pushed straight to the sender because that was the pipeline I had wired. Two of the forty referenced the wrong funding round. One addressed a VP who had left the company in March. The account was a target logo we had chased for two quarters, and the first thing they saw from us was a robot that had not checked its facts. That is the day I stopped building agents that send.

The reflex after a miss like that is to add more validation inside the prompt. Better grounding, tighter guardrails, a fact-check step. All useful, none sufficient. The thing that saved the motion was cheaper and older than any of it: a person looks at the draft and clicks yes or no before it leaves the building. Not a review meeting, not a QA queue that fills up and gets ignored. A single Slack message with two buttons, sitting between the draft and the send, that a rep clears in eight seconds while waiting for their coffee.

That gate is five parts, and each one earns its place only after the one below it holds. This is the build order I climb, bottom to top, every time I stand up a new agent. The ladder is the whole framework; the rest of this piece is the code behind each rung.

The approval-gate build orderSever the send path first, tune the gate rate last
  1. L5Log every decision, then tune the gate rate1 in 5

    Store approver ID, timestamp, and reject reasons. Once a message type holds above 95 percent approval for a month, drop it to a one-in-five sample; if sampled quality slips, tighten back to every message.

  2. L4Run guardrails again at send time45/day

    Fit grade, signal age, suppression, still-employed, a 125-word cap, and a per-inbox ceiling of 45 sends a day. Run them at draft time and again at approve time, because a queued draft can go stale while it waits.

  3. L3Verify the callback, make it idempotentno double-send

    Check the Slack signature and timestamp to reject replays. Confirm the draft is still pending before acting, so a double-click or a Slack retry cannot send the same email twice.

  4. L2Post the draft to Slack with context8 sec

    Send a Block Kit message with the fit grade, signal age, and sender inbox as fields beside the body. The approver clears one in a median eight seconds, checking that a strong signal produced an on-target message, not only proofreading prose.

  5. L1Sever the send pathfail closed

    Before wiring anything else, make it impossible for the agent to reach a mailbox. The draft writes to a queue with status pending; the sender only reads status approved. If the button never gets built, nothing ships. Fail closed, not open.

3%
Of teams got real revenue from autonomous AI SDRs (SaaStr/Lemkin)
8 sec
Median time to clear one draft in the gate I run
0.30%
Spam-complaint cap that one bad blast can blow through (Gmail/Yahoo)

The gate is the product

The uncomfortable benchmark first. When SaaStr and Jason Lemkin surveyed teams that deployed AI SDRs, 83 percent got nothing usable and 3 percent got real revenue. The failure pattern in why AI SDRs fail is not that the models write badly. They write fine. The failure is that teams point a volume machine at a deliverability ceiling and let it send without a judgment layer, then act surprised when the domain burns and the reply rate craters. Autonomy was the selling point and autonomy was the defect.

I treat the approve step as a shipped feature with its own spec, not as a temporary crutch I will remove once the model gets good. It will never get good enough to remove, because the cost of a false send is asymmetric. A held draft costs a rep eight seconds. A sent mistake costs a target account, a chunk of domain reputation, and sometimes a spam complaint against the 0.30 percent hard cap that Gmail and Yahoo enforce. When one side of the ledger is eight seconds and the other side is a burned logo, you build the gate.

The signal source does not matter for this pattern. It can be a champion job change from UserGems, a funding event, a pricing-page revisit off web deanonymization, whatever your signal-based outbound engine ranks as strong enough to act on. What matters is L1: the moment the agent produces text meant for a human recipient, that text hits a queue where a person clears it first, and the sender can read nothing but the approved rows.

L2: post the draft to Slack with context

Here is the actual message the agent posts. This is Slack’s Block Kit format, the JSON payload sent to chat.postMessage. The draft renders as a section block, the account context renders as fields so the approver can sanity-check without leaving Slack, and the two buttons carry the draft ID in their value so the callback knows what got approved.

{
  "channel": "C08OUTBOUND",
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "Draft ready: Acme Corp" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*Contact:*\nJordan Lee, VP Data" },
        { "type": "mrkdwn", "text": "*Signal:*\nSeries B, 8 days ago" },
        { "type": "mrkdwn", "text": "*Fit score:*\nA2 (high)" },
        { "type": "mrkdwn", "text": "*Sender inbox:*\n[email protected]" }
      ]
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Subject:* Congrats on the raise, quick data-infra question\n\n*Body:*\nJordan, saw the Series B last week. Teams your size usually hit a data-cataloging wall right about now. We solved it for two other Series B data teams last quarter; happy to send the 3-line summary if useful."
      }
    },
    {
      "type": "actions",
      "block_id": "draft_9f2a1",
      "elements": [
        {
          "type": "button",
          "style": "primary",
          "text": { "type": "plain_text", "text": "Approve and send" },
          "action_id": "approve_draft",
          "value": "draft_9f2a1"
        },
        {
          "type": "button",
          "style": "danger",
          "text": { "type": "plain_text", "text": "Reject" },
          "action_id": "reject_draft",
          "value": "draft_9f2a1"
        }
      ]
    }
  ]
}

The fields block is doing quiet work. It shows the fit score and the signal age next to the draft, so the approver is not only proofreading prose. They are checking that a strong signal produced an on-target message. If the fit score reads B4 and the signal is 30 days stale, the reject is obvious before anyone reads the body. That is fit and intent staying separate the way shadow-mode scoring keeps them separate, surfaced right at the decision point.

L3: verify the callback and make it idempotent

When the approver clicks, Slack POSTs an interaction payload to your endpoint. Verify the signature, pull the action, and branch. Approve routes to the sender. Reject logs the reason and stops. The code below is the whole handler in the shape I run it, minus the framework boilerplate.

import hmac, hashlib, time

def verify_slack(req, signing_secret):
    ts = req.headers["X-Slack-Request-Timestamp"]
    if abs(time.time() - int(ts)) > 60 * 5:
        return False  # replay guard: reject stale requests
    base = f"v0:{ts}:{req.body}".encode()
    digest = "v0=" + hmac.new(signing_secret.encode(), base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, req.headers["X-Slack-Signature"])

def handle_interaction(payload):
    action = payload["actions"][0]
    draft_id = action["value"]
    approver = payload["user"]["id"]
    draft = db.get_draft(draft_id)

    # idempotency: a double-click must not send twice
    if draft["status"] != "pending":
        return respond("Already handled.")

    if action["action_id"] == "approve_draft":
        # hard guardrails run AGAIN at send time, not just at draft time
        if not guardrails_pass(draft):
            db.update(draft_id, status="blocked")
            return respond("Blocked by guardrail, not sent. See #outbound-log.")
        db.update(draft_id, status="approved", approver=approver)
        sender.enqueue(draft)          # only now does it touch a mailbox
        return respond(f"Sent by <@{approver}>.")

    if action["action_id"] == "reject_draft":
        db.update(draft_id, status="rejected", approver=approver)
        return respond(f"Rejected by <@{approver}>. Draft discarded.")

The status != "pending" check makes the handler idempotent, so a fumbled double-click or a Slack retry cannot send the same email twice. That is the same discipline behind idempotent automations applied to the last mile.

L4: run guardrails again at send time

The approve click does not send. It asks the guardrails whether sending is still safe, because the draft may have sat in the queue long enough for the world to change. The VP could have quit. The signal could have aged past its window. The sender inbox could have hit its daily ceiling from earlier approvals.

def guardrails_pass(draft):
    checks = [
        draft["fit_grade"] in ("A", "B"),          # never send to poor fit
        draft["signal_age_days"] <= 14,            # stale signal, kill it
        not is_suppressed(draft["email"]),         # unsubscribe / do-not-contact
        contact_still_employed(draft["contact_id"]),
        word_count(draft["body"]) <= 125,          # length cap; long mail underperforms
        inbox_daily_count(draft["sender"]) < 45,   # per-inbox send ceiling
    ]
    return all(checks)

That last check is the one that would have saved my forty-email morning. It caps sends per inbox per day at 45, inside the conservative range I hold new mailboxes to so they stay off the radar. An autonomous agent has no reason to stop at 45. A gated one cannot exceed it, because the guardrail refuses to enqueue. The 125-word cap earns its line too: Woodpecker’s campaign data shows 50-to-125-word emails beat 200-plus by roughly 2.3 times, so the guardrail is enforcing a reply-rate rule, not a style preference.

What the gate costs and what it saves

The objection I hear is that a human in the loop does not scale. Run the numbers before you believe it. Take 200 signal-flagged accounts through both paths in a single week.

PathDrafts reviewedHuman timeSentReply rateRepliesMeetings booked
Autonomous agent002002% (Woodpecker spray band)41
Gated agent20026m 40s (8s each)17012% (Woodpecker signal band)208

Read the human-time column first. At the median eight seconds a draft, reviewing all 200 costs one rep 26 minutes and 40 seconds across the whole week. That is the entire price of the gate. Against it, the gated path books eight meetings to the autonomous path’s one, and the 30 drafts it declined to send are the ones that would have referenced a stale round or a departed VP and dented the domain. The autonomous path sends more, converts less, and burns reputation doing it. That is the shape behind the SaaStr number: autonomy books like the top row, which is why only 3 percent of teams saw real revenue from it.

The interactive view below is the reply-rate story that makes the gate pay for itself. Autonomous sending drifts toward spray behavior because volume is the only lever an ungated agent pulls. Gated sending stays in the signal-triggered band, where Woodpecker’s 26,000-campaign dataset puts reply rates at 10 to 20 percent against 1 to 3 percent for spray.

Gated judgment: fewer sends, more replies
Same accounts, same signals. The gated agent sent less than half the volume and got replied to far more, holding the signal-triggered band. Bands from Woodpecker 26,000-campaign tiering.
View as table
ItemValue
Sent42%
Delivered40%
Replied12%
Positive7%

The gated pipeline sent less than half the volume and produced seven times the positive replies. It also kept delivery near sent, because it never triggered the spam signals that push the ungated agent’s delivered number down to 61. Fewer sends, better sends, live domain.

The routing table: which messages gate, which do not

Not every message needs the same friction. A cold first-touch to a target logo gets a human every time. A templated follow-up to someone who already replied warm can auto-send, because the risk is low and the recipient asked to hear from you. I encode that as a routing table the agent reads before it decides whether to queue for approval or send straight through. This is L5 in practice.

Message typeGate?ApproverWhy
Cold first-touch, target logoAlwaysAE on accountHighest cost of a miss; one shot at the logo
Cold first-touch, standard ICPSample 1 in 5SDR leadSpot-check quality without gating every one
Warm reply follow-upNononeRecipient already engaged; low risk
Champion job-change congratsAlwaysAE on accountRelationship message; robotic tone is fatal
Re-engagement after 90 daysAlwaysSDR leadSuppression and staleness risk is high

The sampling row matters. You do not have to gate 100 percent forever. Once a message type proves out at high approval rates, drop it to a sample. If the approve rate on standard-ICP first-touches sits above 95 percent for a month, gating one in five keeps a quality signal without taxing the rep. If that sampled approve rate falls, you tighten back to every message. The gate rate is a dial, not a switch.

Before and after, on the same motion

Autonomous agent Gated agent
Who decides to send The model, silently A named rep, on the record
Bad send blast radius Whole list before anyone notices Zero, the draft never leaves Slack
Per-inbox volume Whatever the queue holds Hard-capped at 45/day by guardrail
Reply rate band Drifts to spray, 1-3% Holds signal-triggered, 10-20%
Audit trail None; who approved this? Approver ID and timestamp on every send
Recovery from a mistake Apologize to a target account Reject, log the reason, move on
Same signals, same models, same account list. The only structural change is a human at L2 through L4.

The audit row is the one leadership cares about after the first incident. When someone asks who approved sending that, an autonomous pipeline has no answer. The gated one has a Slack user ID and a timestamp on every message that left. That record is what lets a nervous VP keep the agent turned on instead of killing it.

Ship the gate this week

Five steps, in ladder order. You can stand up the first three in a day and add the last two as the motion proves out. Do not skip step one to save time; an agent that can send before the gate exists is an agent that will send before the gate exists.

Build the approval gate
  1. 1

    Sever the send path first

    Make it impossible for the agent to reach a mailbox directly. The draft writes to a queue with status pending. The sender only reads status approved. If the button never gets built, nothing ever sends. Fail closed, not open.

  2. 2

    Post the draft to Slack with context, not bare prose

    Build the block-kit payload with the fit grade, signal age, and sender inbox as fields next to the body. The approver is checking that a strong signal made an on-target message. The draft ID rides in the button value so the callback knows what got cleared.

  3. 3

    Verify the callback and make it idempotent

    Check the Slack signature and timestamp to reject replays. On click, confirm the draft is still pending before acting so a double-click cannot send twice. Approve routes to the sender, reject logs the reason and stops.

  4. 4

    Run guardrails again at send time

    Fit grade, signal age, suppression list, contact still employed, word-count cap, per-inbox daily ceiling. Run them at draft time and again at approve time. A blocked check writes status blocked and posts to the log, never to a mailbox.

  5. 5

    Log every decision, then tune the gate rate

    Store approver ID, timestamp, and reject reasons. Once a message type holds above 95 percent approval for a month, drop it to a 1-in-5 sample. If sampled quality slips, tighten back to every message. The gate rate is a dial you turn with data.

The harder truth

The gate is not a stepping stone to full autonomy. I used to think of it that way, as training wheels I would remove once the model earned trust. I was wrong. The value of the gate is that it puts a named human on the record for every message that carries your company’s name to a stranger. That accountability is worth keeping even when the model is excellent, because the cost of a false send never stops being asymmetric.

Build the queue so the agent cannot send without a click. Post the draft with the context that makes the decision fast. Log who decided. Then walk into the room where someone asks whether the AI is safe to leave running, and answer with a Slack thread showing every draft, every approver, and every reject reason. That thread is the difference between an agent that survives its first mistake and one that joins the graveyard.

ai approvals outbound

Keep reading

One email. Every week.

One email a week: a system I built or broke, with the config, the numbers, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.

The newsletter opens soon.

Connect a provider in src/config.ts