LangChain

In this guide you'll build an ops agent whose drop_table tool never touches production without a human sign-off. Python, about ten minutes.

Before you start

  1. A Confirm account and API key. Sign in, open Dashboard → API Keys, create a key, and copy it (it's shown once).
  2. An Anthropic API key (or any chat model LangChain supports).
  3. Python 3.10+.
terminal
pip install langchain langchain-anthropic requests
.env / shell
export CONFIRM_API_KEY=cfm_live_...
export ANTHROPIC_API_KEY=sk-ant-...

Step 1: the approval helper

One function handles the whole Confirm side: create the request, poll every 5 seconds, return the verdict. Copy it as-is.

confirm_client.py
import os
import time
import requests

BASE = "https://confirm.dev/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['CONFIRM_API_KEY']}"}


def require_approval(summary: str, payload: dict, notify: str, ttl_minutes: int = 60, **fields) -> dict:
    """Create an approval request and block until a human decides.

    Returns the resolved request. Check ["status"] for APPROVED / REJECTED / EXPIRED
    and execute ["effectivePayload"], which includes any edits the human made.
    """
    res = requests.post(
        f"{BASE}/requests",
        headers=HEADERS,
        json={
            "summary": summary,
            "payload": payload,
            "notify": notify,          # an email, or "group:<key>" on Pro
            "ttlMinutes": ttl_minutes, # fail safe if nobody answers
            **fields,                  # reasoning, recentActions, agentState
        },
        timeout=30,
    )
    res.raise_for_status()
    request = res.json()

    while True:
        time.sleep(5)
        current = requests.get(
            f"{BASE}/requests/{request['id']}", headers=HEADERS, timeout=30
        ).json()
        if current["status"] != "PENDING":
            return current

Step 2: gate the dangerous tool

Wrap the destructive action with the @tool decorator. The docstring matters: it tells the model that waiting and rejection are normal outcomes, so it doesn't retry in a loop.

agent.py
from langchain_core.tools import tool
from langchain_anthropic import ChatAnthropic
from langchain.agents import create_agent

from confirm_client import require_approval


@tool
def drop_table(table: str) -> str:
    """Drop a database table in production.

    Always requires human approval, which can take minutes. A rejection is a
    valid outcome: report the reviewer's note back instead of retrying.
    """
    verdict = require_approval(
        summary=f"DROP TABLE {table} on production",
        payload={"table": table, "database": "production"},
        notify="group:platform",  # or "dba@company.com" on Hobby
        reasoning=f"Ticket OPS-441 marks {table} as unused; dropping it frees storage.",
        recentActions=["Read ticket OPS-441", f"Counted rows in {table}", "Checked for foreign keys"],
    )
    if verdict["status"] != "APPROVED":
        note = verdict.get("resolutionNote") or verdict["status"].lower()
        return f"Blocked by human reviewer: {note}"

    approved = verdict["effectivePayload"]  # includes any edits the human made
    run_migration(approved)                 # your real migration logic
    return f"Dropped {approved['table']}. Approved by {verdict['resolvedByEmail']}."


agent = create_agent(
    model=ChatAnthropic(model="claude-opus-4-8"),
    tools=[drop_table],
    system_prompt="You are a careful ops agent. Destructive actions go through drop_table.",
)

result = agent.invoke(
    {"messages": [("user", "Ticket OPS-441 says user_sessions_old is unused. Clean it up.")]}
)
print(result["messages"][-1].content)

Step 3: run it and reject

terminal
python agent.py

What you'll see, in order:

  1. The agent decides to drop the table and calls the tool.
  2. The run pauses inside require_approval. This is the whole point.
  3. The approver's inbox gets "Action required: DROP TABLE user_sessions_old on production". Open the link and look at the payload: exactly what the agent wants to do, not a paraphrase.
  4. Click Reject with the note "billing still reads that table nightly".
  5. The tool returns Blocked by human reviewer: billing still reads that table nightly. The model reads that and answers the ticket accordingly, and no query ever ran.

Check your dashboard afterward: the request is in the audit log with your identity, the note, and the timestamp.

If you're on LangGraph with checkpoints, you don't need to block. Store the request id in state, end the run, and resume the graph from your webhook handler when request.approved arrives. Same API, no polling.

Next steps