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
- A Confirm account and API key. Sign in, open Dashboard → API Keys, create a key, and copy it (it's shown once).
- An Anthropic API key (or any chat model LangChain supports).
- 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 currentStep 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.pyWhat you'll see, in order:
- The agent decides to drop the table and calls the tool.
- The run pauses inside
require_approval. This is the whole point. - 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.
- Click Reject with the note "billing still reads that table nightly".
- 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.
LANGGRAPH
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
- Webhooks for event-driven resumption.
- Approval groups to route platform changes to the whole on-call rotation.
- The API reference for every field and error code.