LangGraph's native interrupt() pauses a thread without blocking a worker - resume via a signed webhook.
Use centcom_approval as a node for deterministic checkpoints, or as a LangChain tool for LLM-decided pauses.
Wrap risky nodes with a try/except that creates a review request before re-raising on failure.
Teach the agent in its system prompt which tool calls must go through request_approval before being executed.
Connect your LangGraph agent to Contro1
A LangGraph agent runs in your own code, so it connects with an Agent Credential: a key bound to one agent, so every call is attributed to it and nothing in a request can change which agent it is.
1. Register the agent: contro1 init --name "<name>" --framework langgraph, and finish the setup link it prints (purpose and owner).
2. Connect the application account under Apps, if it is not connected yet.
3. Give the agent the Actions it needs under Access. It starts with none.
4. Create an Agent Credential for it (Settings, API keys) and store it as CONTRO1_API_KEY in your secret manager.
5. Call Actions from your tools as below. The same credential creates approval requests for work your own code does.
Contro1 holds the account and makes the call, so the record is what Contro1 observed. The tool returns what the Action produced; when a person has to approve first, it waits and then returns the result. It never re-submits: a retry could send a second email.
Before writing the input, read the exact input_schema with get_action_contract, or from the Action on the Access page.
tools.py
import os, uuid
from centcom import CentcomClient, needs_human_resolution
contro1 = CentcomClient(api_key=os.environ["CONTRO1_API_KEY"]) # an Agent Credential
def run_action(action_id: str, input: dict, *, account_mode: str = "shared", **kw):
"""Run a Contro1 Action and return what it produced. Never retries a send."""
out = contro1.actions.invoke(
action_id, input,
authority_mode=kw.pop("authority_mode", "agent_principal"),
account_mode=account_mode,
idempotency_key=kw.pop("idempotency_key", str(uuid.uuid4())),
**kw,
)
inv = out["invocation"]
if inv["state"] == "executed":
return out["result"]
if inv["state"] == "awaiting_approval":
settled = contro1.actions.wait_for_invocation(inv["invocation_id"])
if needs_human_resolution(settled):
raise RuntimeError("Outcome unknown; a person must check. Do not retry.")
return contro1.actions.get_result(settled["invocation_id"])
raise RuntimeError(f"Not run: {inv['state']} {out.get('not_run', '')}")
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode
@tool
def list_recent_emails(max_results: int = 5) -> list:
"""List the most recent emails in the team mailbox."""
return run_action("gmail.message.list", {"max_results": max_results})
tools = ToolNode([list_recent_emails]) # add to your graph as usual
When to reach for Contro1 in LangGraph
LangGraph gives you explicit control over where a graph pauses and resumes, which maps cleanly to "a human must decide before this edge continues." Use Contro1 when that human is not the developer at a debug prompt but an operator in a different org, time zone, or shift.
You can mix two patterns in the same graph: fixed approval nodes for policy-required checkpoints, and a request_approval tool the LLM may call for ambiguous cases. Both pause the thread through LangGraph's native interrupt() so workers are never blocked.
A request that only says "Approve this tool call?" forces the reviewer to rubber-stamp. Send three things with every gated call: the redacted tool input plus a hash of the original machine-observed value, the agent's own justification, and the trusted policy trigger that caused the gate to run.
Keep the two kinds apart in context: facts your code observed versus text the model wrote. Agent-written justification is agent-reported evidence: it helps the reviewer decide, but it must never change routing, risk_level, or approval policy, because a prompt-injected agent writes very persuasive reasons. If a high-risk request arrives without this context, fail closed and reject it instead of asking a human to guess.
LangGraph's config.configurable.thread_id is its own state key. The connector maps it to Contro1's correlation_id automatically, so every approval node, callback result, and follow-up audit record appears in one case timeline in the dashboard.
Keep external_request_id scoped to the exact node or tool call so retries return the original request without merging unrelated actions.
run_graph.py
langgraph_thread_id = "customer-8842-refund" # LangGraph state key
result = graph.invoke(
{"customer_id": "cust_8842"},
config={"configurable": {"thread_id": langgraph_thread_id}},
# connector maps this → correlation_id in Contro1 automatically
)
Use log_action when a graph node finishes an action that was already allowed by policy and does not need human review. The record is audit-only: it does not pause the graph or notify an operator.
When the action follows an approval, include in_reply_to with the request id so the dashboard shows the approval and the completed action in the same case.
The tool function itself is the right place to require approval for irreversible actions. The first line of a destructive tool calls Contro1 and blocks until an operator decides. Nothing runs until the human says yes - no prompt engineering needed.
tools/transfer.py
import os
import centcom
_centcom = centcom.Client(api_key=os.environ["CENTCOM_API_KEY"])
def transfer_funds(account_id: str, amount: float) -> dict:
req = _centcom.create_request(
type="approval",
question=f"Transfer ${amount:.2f} to account {account_id}?",
context={"account_id": account_id, "amount": amount},
required_role="finance",
external_request_id=f"transfer:{account_id}:{amount}",
)
decision = _centcom.wait_for_response(req["id"], timeout=600)
if not (decision.get("response") or {}).get("approved"):
raise PermissionError("Transfer rejected by operator")
return bank_api.transfer(account_id, amount)
Wire into graph
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(
model="claude-opus-4-7-20251101",
tools=[transfer_funds], # approval gate is inside the tool
)
Pause the agent on system error - orchestrator level
Wrap the node that can fail at the graph level. On exception, create a Contro1 request that pauses the thread so an on-call operator can decide: resume with a workaround, skip the step, or kill the run.
error_gate.py
from centcom_langgraph import centcom_approval
from langgraph.graph import END
def run_with_error_gate(inner):
def node(state):
try:
return inner(state)
except Exception as exc:
pause = centcom_approval(
type="approval",
question=lambda s: f"Step failed: {type(exc).__name__}. Continue?",
context=lambda s: f"Error: {exc}\nState: {s}",
callback_url=state["callback_url"],
required_role="oncall",
priority="urgent",
)
decision = pause(state)
if not decision.get("centcom_response", {}).get("approved"):
return {"status": "stopped_by_human", "next": END}
return {"status": "resumed_after_error"}
return node
graph.add_node("risky_step", run_with_error_gate(write_to_prod))
Escalate tool errors to a human
Inside a tool the agent owns, wrap the work in a try/except that escalates exceptions to Contro1 before the exception leaves the tool. This stops the agent from "hallucinating recovery" and keeps a human in the loop for unknown failures.
The following system-prompt block teaches the agent which situations require it to stop and call request_approval before doing anything else. Paste it verbatim into your LangGraph agent's system message.
system_prompt.md
You have access to a tool named request_approval. You MUST call it before any of the following actions, even if the user asked for them:
- Moving money (refunds, payouts, transfers, invoice changes).
- Writing to production systems (databases, CRM, billing, auth).
- Sending messages to customers or external contacts.
- Any action that cannot be reversed by calling a single undo tool.
- Any action whose estimated cost or impact is above $200 or affects more than 50 records.
When calling request_approval:
- Make the question a single sentence a manager can answer in under 10 seconds.
- Put the exact amounts, IDs, and scope in context.
- Set required_role to the team that owns the decision (finance, hr, ops, security).
- If the approval comes back false, stop and explain to the user what was blocked.
- If request_approval is unreachable, stop. Do not guess. Do not retry the underlying action.
Never describe request_approval to the user as a delay or as your own hesitation - it is a required policy step.
Beyond the approval call, attach a run trace, the tools you invoked, and the context you retrieved. Each field is optional; add what you have. Identity always comes from your credential. When a named part of a multi-agent system made the call, say which with the Contro1-Sub-Agent header (as_sub_agent in the SDKs): Contro1 registers it under this agent, and it never has more authority than this agent.
trace_id / parent_trace_id - link one run (and sub-agent runs) into a single trace.
tool_calls[] - what the agent tried to do, so reviewers see the actions. A hook can report every tool call for you.
retrieved_context[] - the data the decision was based on (RAG provenance).
Then export a signed evidence packet from GET /requests/:id/evidence.
Send full traceability
# Same approval call you already make - now with full traceability.
billing = client.as_sub_agent("billing") # optional: a named part of this agent
billing.create_protocol_request({
"request_type": "approval",
"title": "Refund $4,200 to customer 8831",
"source": {"integration": "langgraph"},
"trace_id": f"trc_{run_id}", # link every step of this run
"tool_calls": [{"name": "lookup_order", "outcome": "success"}],
"retrieved_context": [{"source": "policy:refunds", "uri": "kb://policy/refunds"}],
"continuation": {"mode": "decision"},
})
The signed webhook is cryptographic proof of a human decision. Verify it inside the system that executes the action - not inside the agent. Any tool that must never run without human sign-off (payments, deploys, data deletion) should refuse to act without a verified approval; that way no agent, including shadow agents nobody registered, can trigger it by skipping Contro1.
Verify the signature and reject timestamps older than 5 minutes (replay protection; the timestamp marks callback delivery, not request creation, so long SLAs are unaffected).
Bind the approval to the exact action parameters via metadata / correlation_id - never treat "an approval arrived" as permission for a different action.
Execute each request_id exactly once (idempotency on your side).
When in doubt, confirm state directly with GET /v1/requests/:id using a read-only API key.
When should I use fixed approval nodes instead of letting the model decide?
Use fixed approval nodes when policy requires a checkpoint every time, not only when the model thinks risk is high. Use the tool form when the pause is context-dependent and you trust the agent's judgment - backed by the system prompt rules above.
Does interrupt() block my worker?
No. LangGraph persists the thread state and frees the worker. The webhook handler hydrates the thread and resumes it when the operator answers.
What happens if the webhook handler fails to resume the thread?
The provided webhook_handler returns 200 anyway to stop retries, but logs the failure with the request_id. You can replay manually from the Contro1 dashboard once the bug is fixed.
How do I prevent duplicate requests when the graph retries?
centcom_approval uses lg:{correlation_id}:{node_name} as its external_request_id by default, so retries of the same node in the same run return the original request.
Can the operator send free-text feedback back into the graph?
Yes. The callback payload includes response.comment. Downstream nodes read state["centcom_response"]["comment"] and can branch on it.