My trading bot went live recently, and I wrote up the guardrail stack I built first: hard budget caps, idempotency keys, a dead-man switch, and an approval gate for anything unusual. One of those has quietly become my favorite, and it's the least technical of the bunch.
The approval gate. Not the asking-for-permission part — the part everyone, including me, gets wrong on the first try. The rule that makes the whole thing work:
If I don't answer, the answer is no.
Sounds obvious now. It wasn't. I shipped two versions of that gate before this one, both politely broken in ways that only show up when the human is asleep. Since the pattern generalizes to any agent that touches the real world — posts, payments, deletes — it's worth writing down how I got here.
The 2 AM Test
Back when the bot was still in paper mode, it flagged a setup as unusual — Tier 3 in my system, "pause and wait for a button tap." The request went out at 2:14 AM. I was asleep, as humans are.
Version one of the gate treated "no answer" as "still pending." The intent sat there. Polite. Patient. And because my executor is single-threaded by design — one intent at a time, in order — everything queued behind it sat there too. I woke up to a stopped pipeline and a missing morning briefing, because the briefing job was stuck behind the stuck job.
Here's the thing I had to admit to myself: a blocked agent isn't a safe agent. It's a stopped one. "Doing nothing" feels like the conservative failure mode until you realize the bot also stopped managing the positions it already had open. Safe would have been: reject, move on, keep running.
So version two overcorrected in the opposite direction: no answer → ask again, every 15 minutes, until answered. Reminders, right? Humans like reminders.
I woke up to 28 notifications about the same trade. I didn't read notification four, let alone twenty-eight. I swiped the fourth away half-awake and then spent a genuinely dangerous moment trying to remember whether what I'd swiped was approve or dismiss. That's when it clicked: a notification channel that nags gets muted, and a muted approval channel is worse than none — because it still feels like oversight.
Version three is what runs now:
- Ask once, with the full intent, the reasoning, and a deadline in plain text: "Reply by 03:14 or this is rejected."
- One reminder at the halfway mark. Then silence.
- Deadline passes with no reply → the intent is rejected, written to the audit log as
rejected_timeout, and listed in the next morning briefing. - The queue never blocks on an approval. The next job runs immediately.
That night, the bot asked, I slept, the trade didn't happen, and the briefing told me about it over coffee with the reasoning attached. I read it, decided the bot was right to want the trade, and entered it manually an hour later when the setup was still valid. Nothing was lost except the illusion that 2 AM me makes good decisions.
The Code, Which Is Embarrassingly Short
The whole gate is maybe thirty lines. This is the core of it:
class ApprovalGate:
WAIT = 3600 # total patience, in seconds
REMIND_AFTER = 1800 # one nudge, then silence
def request(self, intent):
deadline = now() + self.WAIT
self.send(intent,
f"Reply by {fmt(deadline)} or this is rejected. "
f"No reply = no trade.")
state = self.await_reply(deadline, remind_after=self.REMIND_AFTER)
if state is UNANSWERED:
self.audit.log(intent, outcome="rejected_timeout")
return Denied("timeout means no")
if state is REJECTED:
self.audit.log(intent, outcome="rejected_by_human")
return Denied("human said no")
return Approved(state.edits) # edits may shrink the intent, never grow it
Two details in there matter more than the mechanics:
The deadline is in the message. "No reply = no trade" is written down, every time. The system's behavior when I'm absent isn't a surprise — it's a stated contract inside the request itself.
Approvals can only shrink. When I reply, I can approve, reject, or approve at a smaller size. There is no path where my reply makes the action bigger. The gate edits down or blocks; it never amplifies. That asymmetry closes a whole class of "I replied with a typo and the bot did something heroic" incidents.
"Timeout Means Yes" Is Always One Boolean Away
Nobody writes if timeout: go_ahead() on purpose. But the pressure is real, and it never announces itself as recklessness. It sounds like: "the pipeline keeps stalling overnight, let's add a default," or "if he doesn't respond in an hour, he probably would have said yes." One boolean, one config value, and your fail-closed gate is fail-open — and nothing about the system looks different until the night it matters.
The reframe that fixed my thinking: an unanswered request is information. The human is asleep, or busy, or deliberately ignoring you. All three of those are "no." Not "maybe." Not "retry." No.
What the Logs Taught Me
Two numbers tell you whether a gate like this is healthy.
How often it asks. Tier 3 requests should be rare — mine currently fire about once a week. If they fire daily, the problem isn't the human, it's upstream: your thresholds and caps are miscalibrated and the gate is absorbing the overflow. A gate that asks constantly gets rubber-stamped, and reflex-tapping approve is the terminal failure state of every approval system ever built. Gate volume is a health metric for the whole stack.
How often I disagree with the rejects. The rejection log is a free dataset on where the bot's world model and reality diverge. In the first two weeks of logging timeouts, I noticed the bot kept flagging perfectly ordinary setups as unusual. Turned out a volatility threshold was stale and everything looked scary to it. The gate didn't just prevent bad trades — the rejects pointed me at a config bug I'd been living with. Rejections aren't noise in the system. They're the system talking.
One more, and I'm embarrassed by this one: I originally logged timeout-rejections as warnings, so my health monitor kept paging me about them. Wrong category. A timeout-rejection is the system working exactly as designed — it belongs in the routine morning briefing, not in alerting. The alert should fire only when the gate can't reach me at all. If your monitoring treats correct behavior as an incident, you'll mute the monitoring, and then the real incidents arrive as surprises.
It's a Decision Record, Not a Security Boundary
One nuance worth being precise about, because approval flows have a way of quietly becoming the only thing between an agent and disaster: the gate is not what keeps me safe. The executor's hard caps are. Even with a forged or fat-fingered "yes," the position-size cap, the exposure cap, and the daily-loss cap still bound what can happen. The gate decides whether; the caps decide how much. Two systems, one boring outcome.
It's the same reason the gate plays nicely with the dead-man switch: if the bot's heartbeat drops, pending approvals auto-reject along with everything else — a half-alive bot's outstanding questions can't be trusted either.
The Pattern, Without the Trading
You don't need a bot that moves money for this to apply. Anything an agent does that's hard to undo — publishing a post, sending an email on your behalf, deleting records, touching production — fits the same three-tier table:
- Reversible → act alone, log everything
- Bounded and routine → act alone, but send the receipt first
- Unusual or irreversible → ask once, remind once, silence means no
And the one line worth stealing, in any stack: timeout means no, and "no" is a first-class outcome, not an error.
Have you wired approval flows into your agents? I'm curious how other people handle the "human is asleep" case — bounded waits, escalation to a second human, or just letting it queue until morning? Drop a comment, genuinely curious.
Part of my Building in Public series — previously: the guardrail stack I built before going live, a postmortem of a double-fired cron job, a circuit breaker for agent outages, and a health monitor that tells me when my agents are dying.
Top comments (0)