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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
| Path | Drafts reviewed | Human time | Sent | Reply rate | Replies | Meetings booked |
|---|---|---|---|---|---|---|
| Autonomous agent | 0 | 0 | 200 | 2% (Woodpecker spray band) | 4 | 1 |
| Gated agent | 200 | 26m 40s (8s each) | 170 | 12% (Woodpecker signal band) | 20 | 8 |
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.
View as table
| Item | Value |
|---|---|
| Sent | 42% |
| Delivered | 40% |
| Replied | 12% |
| Positive | 7% |
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 type | Gate? | Approver | Why |
|---|---|---|---|
| Cold first-touch, target logo | Always | AE on account | Highest cost of a miss; one shot at the logo |
| Cold first-touch, standard ICP | Sample 1 in 5 | SDR lead | Spot-check quality without gating every one |
| Warm reply follow-up | No | none | Recipient already engaged; low risk |
| Champion job-change congrats | Always | AE on account | Relationship message; robotic tone is fatal |
| Re-engagement after 90 days | Always | SDR lead | Suppression 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 |
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.
- 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
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
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
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
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.
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