I build agents that take real actions. Not chatbots that suggest things, but systems that click buttons, issue refunds, and move data around. Once an agent can actually do something, the interesting question stops being "can it decide correctly" and becomes "what happens when it decides wrong." My answer, over and over, has been the same: put a gate in front of the irreversible actions and let a human hold the key.
That is the whole human-in-the-loop pattern in one sentence. Reads are cheap and reversible, so let them run. Writes are not, so make the agent stop and ask before it commits one. What varies is how you implement the stop, and I have shipped three different versions of it across three projects. Here is what each one taught me.
The pattern: a gate before anything you cannot undo
An autonomous agent works because you trust it on the common, safe cases. But autonomy and safety are not opposites you have to trade off against each other. The trick is to split the action space. The safe majority runs automatically. The risky minority pauses and routes to a person. The person only ever sees the decisions that actually need judgment, so the human cost stays low while the blast radius of a bad automated action stays bounded.
The three implementations below differ in where the gate lives: on your phone, in the agent's request contract, and inside the execution graph itself.
Implementation 1: approval as a phone tap (Greenlite)
Greenlite is the human-in-the-loop layer as a mobile app. It is an Expo / React Native codebase that builds to native Android and iOS, and its whole job is to be the place where escalations land when you are not at a desk.
The flow is: an agent wants to do something risky, your phone buzzes, you see the full context and the proposed action, and you approve or deny in one tap. Under the hood it pulls a feed of pending escalations across agents, each carrying a proposedAction and the reason it escalated. Tapping an item shows the full message and a one-tap Approve / Deny that routes back to the originating agent's approve endpoint. Push notifications come through expo-notifications, so an agent can buzz the phone the moment it needs a decision.
What I like about this design is that the approval contract is generic. An item has a proposedAction, and the backend exposes an /api/approve. Any new agent that learns to escalate plugs straight into the same feed with no bespoke UI work. The gate is a protocol, not a screen.
Implementation 2: write-gating in the request contract (Webhands)
Webhands is a computer-use agent for tools that have no usable API. It drives real dashboards (think a seller center or a supplier portal) through Cloudflare Browser Rendering and returns clean structured data plus a screenshot as proof of what it saw.
Here the gate is baked into the request itself. Every step the agent can take is either a read or a write, and any step marked write: true (clicking "Issue refund", say) is refused unless the request also includes confirm: true. So a scraping recipe that logs in and pulls this week's orders runs freely, because it only reads. A recipe that clicks a "confirm shipment" button comes back as an error until you resend it with confirm: true.
The important property is the default. Reads are safe by default and writes are deliberate by default. You do not have to remember to add a safety flag; you have to deliberately add a go-ahead flag. That inversion is the whole point. When someone forgets, the system fails closed, not open.
Implementation 3: a graph interrupt with a checkpointer (relayg)
relayg is a support triage agent built as a LangGraph state machine. It classifies an incoming ticket, runs pure Python refund policy rules, and then acts. The policy rules decide the gate: refunds under $50 auto-approve, refunds over $200 escalate to a senior agent, and refunds between $50 and $200 pause for human approval.
That pause is the most technically satisfying version of the gate I have built. When a ticket lands in the $50 to $200 band, the act node calls interrupt(), which suspends the graph mid-node. The state is checkpointed to SQLite, so the paused run survives a process restart. Later a reviewer resumes it with Command(resume=...), and execution continues from the exact same checkpoint with the human verdict injected into state. Every action the agent takes (send_reply, issue_refund, escalate) is a tool that appends to an audit.jsonl log, so there is a full record of what happened and who approved it.
This is the difference between "wait for a webhook and stuff the state into a database by hand" and having interrupts as a first-class part of the execution model. The paused run is not a suspended HTTP request holding memory hostage; it is durable state on disk you can resume days later. The interrupt and resume path is covered by the project's pytest suite, because if your safety gate is not tested, it is not a safety gate.
When NOT to gate
A gate has a cost, and the cost is a human's attention. If you gate too much, people start rubber-stamping, and a rubber-stamped approval is worse than no gate because it launders a risky action as a reviewed one. So I do not gate reads, ever. I do not gate actions that are trivially reversible. And I do not gate the high-frequency safe cases that make up the bulk of the work. relayg does not pause on a sub-$50 refund, and Webhands does not ask permission to scrape a page. Gating is for the actions where a wrong call actually costs something and cannot be quietly undone.
One honest caveat
A human-in-the-loop gate protects you from bad automated actions. It does not protect you from a bad human decision, and it does not make the agent's proposal correct. If the agent proposes the wrong refund and the reviewer is tired and taps Approve, the gate did exactly its job and you still got the wrong outcome. The gate buys you a decision point, not a correct decision. That is why the context you show at the gate matters as much as the gate itself: Greenlite shows the full message, Webhands returns a screenshot, relayg keeps an audit log. A gate with no context is just a slower way to say yes.
If you want to see any of these end to end, the code is at github.com/royalpinto007.
Top comments (0)