Back to Blog

Human-in-the-loop notifications: approve an AI agent from your phone

Human-in-the-loop notifications: approve an AI agent from your phone

An agent that can deploy, refund, delete or email on its own is useful right up to the moment it does one of those things wrongly. The fix is not to stop it from acting. It is to make it ask first, and to make asking cheap enough that you actually answer.

Human-in-the-loop means exactly that: the agent stops at a checkpoint, a person approves or declines, and the agent continues with the answer. The hard part is not the logic. It is getting the question to you when you are not at the desk, and getting your answer back without a laptop.

This guide does it with a push notification that carries an Approve button. The agent sends it, waits, and reads your decision from a tiny endpoint the button calls.

How the loop works

Four moving parts, all small:

  1. The agent reaches a risky step and creates a request with a random ID.
  2. It sends a Pocket Alert push with two action buttons. Each button is an HTTP request to your approval endpoint, carrying that ID.
  3. You tap the push, read what the agent wants to do, and tap Approve or Decline. Your phone calls the endpoint directly.
  4. The agent polls the endpoint, sees the decision, and goes on, or stops.

If nobody answers before the timeout, the agent treats it as a no. An approval step that defaults to "yes" is a notification, not a safeguard.

What you need

  • A Pocket Alert account on a paid plan. Action buttons are stripped from messages on the Free plan, so the push would arrive without them. Starter is $6 a month.
  • An API key from API Keys, ideally a separate one for the agent.
  • Somewhere to run a small HTTPS endpoint your phone can reach. The example uses a Cloudflare Worker, which is free at this volume; any server works.

Step 1. The approval endpoint

The endpoint does two things: records a decision when the phone calls it, and reports it when the agent asks. This Worker keeps decisions in Workers KV for an hour:

export default {
  async fetch(request, env) {
    if (request.headers.get('X-Approval-Secret') !== env.SECRET) {
      return new Response('forbidden', { status: 403 });
    }

    const [, action, id] = new URL(request.url).pathname.split('/');
    if (!/^[a-f0-9]{32}$/.test(id ?? '')) {
      return new Response('bad id', { status: 400 });
    }

    if (request.method === 'POST' && (action === 'approve' || action === 'decline')) {
      const existing = await env.DECISIONS.get(id);
      if (!existing) await env.DECISIONS.put(id, action, { expirationTtl: 3600 });
      return new Response(existing ?? action);
    }

    if (request.method === 'GET' && action === 'status') {
      return new Response((await env.DECISIONS.get(id)) ?? 'pending');
    }

    return new Response('not found', { status: 404 });
  },
};

Bind a KV namespace as DECISIONS, add a long random SECRET as a Worker secret, and deploy. Three details are deliberate:

  • The first answer wins. A second tap, or a tap on the other button, cannot flip a decision that is already recorded. Pocket Alert does not block repeated taps on an http button, so the endpoint has to.
  • The secret travels in a header, set inside the button itself. The phone sends it, the agent sends it, nobody else has it.
  • Decisions expire. An approval from yesterday should not unlock anything today.

Workers KV is eventually consistent, so the agent can see a decision up to a minute after you tap. For an approval step that is fine; if you need it instant, any server with a database or a Durable Object does the same job.

Step 2. Ask from the agent

The helper below sends the push, polls for a decision and returns True only on an explicit approval. It works from any Python agent: a LangChain tool, an OpenAI Agents SDK function, a plain loop.

import json
import os
import time
import uuid

import requests

PUSH_API = "https://api.pocketalert.app/v1/messages"
APPROVALS = "https://approvals.YOUR_SUBDOMAIN.workers.dev"
SECRET = os.environ["APPROVAL_SECRET"]


def ask_human(title: str, details: str, timeout: int = 900) -> bool:
    request_id = uuid.uuid4().hex

    def button(label: str, action: str) -> dict:
        return {
            "type": "http",
            "label": label,
            "value": json.dumps({
                "url": f"{APPROVALS}/{action}/{request_id}",
                "method": "POST",
                "headers": {"X-Approval-Secret": SECRET},
            }),
        }

    requests.post(
        PUSH_API,
        headers={"Token": os.environ["POCKETALERT_API_KEY"]},
        json={
            "title": title,
            "message": details,
            "level": "high",
            "actions": [button("Approve", "approve"), button("Decline", "decline")],
        },
        timeout=10,
    ).raise_for_status()

    deadline = time.time() + timeout
    while time.time() < deadline:
        status = requests.get(
            f"{APPROVALS}/status/{request_id}",
            headers={"X-Approval-Secret": SECRET},
            timeout=10,
        ).text
        if status in ("approve", "decline"):
            return status == "approve"
        time.sleep(5)
    return False

Then put it in front of anything you would not want the agent to do unsupervised:

if ask_human("Deploy to production?", "build #482 · 14 files · all checks green"):
    deploy()
else:
    report("Deploy skipped: not approved")

level: "high" sends the push as time-sensitive, so it gets through Focus on iPhone and lands as a heads-up notification on Android. An approval you see an hour later is an approval the agent waited an hour for.

What you see on the phone

The push arrives with the title and details the agent wrote. Tap it, and the message opens in Pocket Alert with Approve and Decline under the text. One more tap sends the request; a toast shows the status code your endpoint returned.

Two platform details are worth knowing before you rely on this:

  • Buttons on the banner itself appear when Pocket Alert is open. When the app is in the background or closed, the operating system draws the banner without buttons, and they wait inside the message. Delivering them on a background banner needs a different kind of push, which is planned but not shipped.
  • On iPhone, banner buttons use generic labels. iOS registers button sets when the app starts and cannot take labels from the message, so an http button reads Run. Inside the message you always see your own labels. This is another reason to approve from the opened message rather than the banner.

Where to put the checkpoints

Ask too often and you will start approving without reading. Good checkpoints are the actions that are hard to undo or cost real money:

  • Deploys and database migrations on production
  • Sending email, posting publicly, or messaging customers
  • Refunds, purchases and anything that calls a payment API
  • Deleting data, rotating keys, changing DNS or permissions
  • Runs that will spend more than a set token or cloud budget

Everything else is better as a plain notification after the fact. The AI agent alerts page covers that side: finished, failed, stuck.

Doing it through MCP

If the agent runs in an MCP client, it can send the same push without the Python helper. The Pocket Alert MCP server exposes create_message with an actions parameter, so you can instruct the agent to call it with the two buttons and then check the status URL before continuing.

That works, with a caveat: the agent is now responsible for waiting, and a model told to wait for approval can still decide not to. For anything that matters, enforce the checkpoint in code the model cannot skip, like the helper above or a permission hook in Claude Code or Codex. Use the MCP route for the question and code for the gate.

Variations

  • Single button. Send only Approve and let silence mean no. On iPhone there is then nothing to confuse on the banner, and declining costs you nothing.
  • Workflow tools. In n8n, a Wait node set to resume on a webhook call gives you a resume URL; put it in the Approve button and the workflow continues when you tap.
  • Context in the push. Put the numbers in the message: the amount, the environment, the number of rows. You should be able to decide from the text alone.
  • A view button. Replace Decline with a view button that opens the diff, the invoice or the run, and decline by not approving.

FAQ

Does approving work from the lock screen?

Partly. When Pocket Alert is open, the buttons appear on the banner. Otherwise the banner has no buttons, and tapping it opens the message with Approve and Decline inside. Your phone may ask you to unlock first.

Is it secure?

The button's request goes from your phone straight to your endpoint; Pocket Alert does not proxy it or see the response. Protect the endpoint with a secret header, use a random ID per request, accept only the first answer, and expire decisions. Messages, including button configuration, are encrypted at rest.

What happens if I never answer?

The helper returns False after the timeout, 15 minutes by default, and the agent should treat that as declined. Pick a timeout that matches how long the task can sensibly wait.

Can several people approve?

Every device on the account gets the push, and the first tap wins. For per-person approvals, send each person their own push with a separate request ID and decide in the endpoint how many approvals you need.

Does it work on Android and iPhone?

Yes, both. On Android, banner buttons show your labels when the app is open; on iPhone they show Run. Inside the message both platforms show your labels.

Get an API key, deploy the endpoint, and give your agent a way to ask before it acts.

Share this article