Skip to content

Human-in-the-loop required

Good AI agents don’t apply irreversible changes silently: git push --force, DROP TABLE, DB migration, prod rollout — all of these require human approval. A plain stdout prompt works poorly in this situation: you’re somewhere in a cafe, your laptop is closed, the agent is hanging and burning the context window.

Notifly solves this with a push + a public callback URL.

Minimal template: “press the button to continue”

Section titled “Minimal template: “press the button to continue””
import os, secrets, time, requests
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
NOTIFLY = (os.environ["NOTIFLY_URL"], os.environ["NOTIFLY_TOKEN"])
def ask_human(question, callback_base):
"""
Поднимает локальный httpd на свободном порту, ждёт approve/deny.
callback_base — публичный URL (через Cloudflare Tunnel / ngrok / fly.io),
чтобы можно было нажать с телефона.
"""
token = secrets.token_urlsafe(8)
answer = {}
class H(BaseHTTPRequestHandler):
def do_GET(self):
if token not in self.path:
self.send_response(404); self.end_headers(); return
answer["v"] = "approve" if "approve" in self.path else "deny"
self.send_response(200); self.end_headers()
self.wfile.write(b"OK, you can close this tab.")
def log_message(self, *a, **kw): pass
httpd = HTTPServer(("127.0.0.1", 0), H)
port = httpd.server_address[1]
Thread(target=httpd.serve_forever, daemon=True).start()
base = callback_base.rstrip("/")
msg = (f"{question}\n\n"
f"✅ {base}/approve?t={token}\n"
f"❌ {base}/deny?t={token}")
requests.post(f"{NOTIFLY[0]}/message",
params={"token": NOTIFLY[1]},
json={"title": "🤖 Нужен approve", "message": msg, "priority": 9},
timeout=5)
deadline = time.time() + 30*60
while time.time() < deadline and "v" not in answer:
time.sleep(2)
httpd.shutdown()
return answer.get("v", "timeout")
# usage:
verdict = ask_human(
"Применить миграцию V234__drop_legacy_users.sql на prod?",
callback_base="https://my-tunnel.example.com",
)
if verdict != "approve":
raise SystemExit(f"abort: {verdict}")

Cloudflare Tunnel, ngrok http 0, a fly machine — any simple way to publish a local port. If you already have a prod URL — it’s better to use it (route /approve//deny there).

Without your own HTTP server: “confirm via web-script”

Section titled “Without your own HTTP server: “confirm via web-script””

If the agent runs alongside the Notifly admin UI — create a pair of WebScript: one with type button_click and title: "Approved", the other — Denied. In the push message just send two links like https://your-notifly/script/T<approve_token> — a click sends a callback ping to your channel, the agent catches it via WebSocket or periodic poll GET /application/{id}/message.

Pro: no server at all. Con: polling or WebSocket needed on the agent side.

CLI agent: catch an approve prompt on stdout

Section titled “CLI agent: catch an approve prompt on stdout”

Many CLI agents (Claude Code, Codex CLI, Aider) print a line like Approve? [y/n] to stdout. The simplest approach is to wrap them with expect:

#!/usr/bin/expect -f
spawn aider --auto-commit ...
expect {
-re "Approve\\? \\[y/n\\]" {
exec curl -fsS -X POST "$env(NOTIFLY_URL)/message?token=$env(NOTIFLY_TOKEN)" \
-H "Content-Type: application/json" \
-d "{\"title\":\"🤖 Aider ждёт approve\",\"message\":\"...\",\"priority\":9}"
interact # pass control to the user over ssh
}
eof
}
  • Exactly what we’re doing. The command, file path, migration name, branch.
  • What will happen on deny. So you can confidently press “no”.
  • Diff / preview, if possible. Put a link to a gist in extras.
  • Deadline. “Abort after 30 minutes” — then you can ignore it while at the bar.