DEV Community

Cover image for How to Pause an AI Agent for Human Approval Without a WebSocket
Yuuki Yamashita
Yuuki Yamashita

Posted on

How to Pause an AI Agent for Human Approval Without a WebSocket

If an AI agent needs a human to approve something mid-task, the instinct is usually to reach for a websocket, a message queue, or some kind of push notification service to bridge the backend and the frontend. I ended up not needing any of that. One DynamoDB row, polled from both sides, does the whole job. I built this for SubSentry, an agent for AWS's Agents for Humans Hackathon that renews clean subscriptions on its own and asks a human before touching anything that looks like a price hike, a duplicate charge, or an unrecognized merchant. This post is about the mechanism underneath that "asking," not the subscription-tracking part.

The shape of the problem

An agent tool call that needs human approval has to do two contradictory things at once. It has to actually block, because the agent's next step depends on the answer, and it has to somehow let something completely separate (a browser tab, a Slack bot, a CLI) deliver that answer whenever a human gets around to it, which could be five seconds or five minutes later. The backend process and the thing collecting the human's decision don't share memory, don't share a request, and in my case run on entirely different platforms (Bedrock AgentCore Runtime for the agent, Vercel serverless functions for the UI).

The trick is to stop thinking of it as backend-talks-to-frontend at all. Neither side needs to know the other exists. They both just need to agree on one row.

The row as a mailbox

Here's the actual store, trimmed slightly:

def request_approval(*, subscription_id, suggested_action, reasons, amount_usd, ttl_seconds=120):
    approval_id = str(uuid.uuid4())
    now = time.time()
    entry = {
        "approval_id": approval_id,
        "subscription_id": subscription_id,
        "status": "PENDING",
        "suggested_action": suggested_action,
        "reasons": reasons,
        "amount_usd": amount_usd,
        "created_at": str(now),
        "expires_at": str(now + ttl_seconds),
        "decision": None,
    }
    _dynamo_put(entry)
    return entry


def wait_for_decision(approval_id, *, poll_sec=1.0):
    while True:
        entry = get_approval(approval_id)
        if entry["status"] != "PENDING":
            return entry
        if time.time() > float(entry["expires_at"]):
            entry["status"] = "EXPIRED"
            _dynamo_put(entry)
            return entry
        time.sleep(poll_sec)
Enter fullscreen mode Exit fullscreen mode

The agent's tool calls request_approval, gets back an approval_id, and immediately calls wait_for_decision on it, which just sits there polling DynamoDB once a second. That's the entire "block" side. It's a plain Python while True loop, nothing fancier, because AgentCore Runtime is already paying for a long-running invocation, so there's no reason to make the waiting clever.

The other side never has to know it's being waited on

The frontend's job is smaller than it sounds: read rows where status = PENDING, render them as cards, and when a human clicks Approve or Reject, write the decision back. Here's the write, as a DynamoDB UpdateItem call from a Next.js API route:

const r = await ddb().send(
  new UpdateCommand({
    TableName: TABLES.approvals,
    Key: { approval_id: id },
    UpdateExpression: "SET #s = :d, decision = :d, #r = :r, decided_at = :t",
    ExpressionAttributeNames: { "#s": "status", "#r": "reason" },
    ExpressionAttributeValues: { ":d": body.decision, ":r": body.reason ?? "", ":t": String(Date.now() / 1000), ":pending": "PENDING" },
    ConditionExpression: "attribute_exists(approval_id) AND #s = :pending",
    ReturnValues: "ALL_NEW",
  })
);
Enter fullscreen mode Exit fullscreen mode

The ConditionExpression is doing more work than it looks like. It means two people can't both approve the same card and have it silently double-apply, and it means a decision can't land on a row that already expired. If the condition fails, DynamoDB throws ConditionalCheckFailedException, which the route turns into a 409. No locking, no transactions, just a condition on a single-item write.

And that's the whole contract. The agent doesn't call an API on the frontend. The frontend doesn't call an API on the agent. A CLI can write the same UpdateItem and it works identically, which is why agent.py approve <id> from a terminal resolves the exact same pending card as clicking Approve in the browser. Neither side was written with the other in mind, they just both read and write the same table with the same status field.

Where this breaks if you're not careful

The failure mode I actually hit wasn't in this mechanism, it was one level down. Strands dispatches multiple tool calls from the same agent turn concurrently, and my first pass at local storage (before I had a real DynamoDB table wired up) was a plain read-JSON-modify-write with no locking. Two tool calls landing at nearly the same instant would both read the file, both append their own entry in memory, and whichever one wrote last won, silently dropping the other's write. I found it because a local .approvals.json file had two writes visibly tangled together mid-file, not because a test failed cleanly.

The fix was five lines, a threading.Lock around the read-modify-write section:

with _LOCK:
    data = _local_load()
    data[approval_id] = entry
    _local_save(data)
Enter fullscreen mode Exit fullscreen mode

DynamoDB's per-item UpdateItem doesn't have this problem at all, since each write targets one item atomically. The bug only existed because my local dev fallback was reinventing a worse version of what DynamoDB gives you for free. Worth remembering next time a "just write it to a JSON file for now" shortcut feels harmless.

Why not just use a websocket

I did consider it, mostly out of habit. But a websocket needs a persistent connection on both ends, which means something has to stay alive to hold it, and AgentCore Runtime invocations and Vercel serverless functions are both built around not staying alive longer than they have to. Polling a table every one to three seconds costs nothing worth optimizing at this scale, and it means either side of the system can restart, redeploy, or die completely mid-wait and the other side won't even notice, because the DynamoDB row is the only thing that has to survive.

If you want to see the whole thing running, live demo and code are here:

Built for AWS's Agents for Humans Hackathon. #AgentsforHumans

Top comments (0)