AI sales agents built on LangGraph can now take on work that used to need a person. They can walk a buyer through a pricing tier, check a requested discount against margin rules, and draft contract terms. But when a negotiation reaches a high-stakes fork, such as a 28% custom discount, a non-standard indemnity clause, or a multi-region data residency guarantee, full autonomy becomes a liability. A generic “let me get back to you” email fails the buyer. Dumping a raw transcript on a rep fails the rep.
LangGraph has a better answer: human-in-the-loop (HITL) checkpoint routing. The agent pauses with `interrupt()` and its state is saved by a checkpointer. A human decides in Slack, and the same thread resumes with `Command(resume=...)`. This article covers how that works, where the common pitfalls are, and how to wire it to Slack with working code.
A note on naming: LangChain has renamed several products. LangGraph Platform is now LangSmith Deployment, and LangGraph Studio is now LangSmith Studio. The server component is called Agent Server in current docs, and the open-source `langgraph` library keeps its name. Existing deployments and APIs were unaffected by the rename.
The Problem with Traditional Chatbot Escalation
Most conversational AI platforms hand off to humans by ending the AI session and passing a transcript to a queue, a ticket, or a CRM record. In an enterprise negotiation this fails in three ways:
1. Context loss. The rep gets a wall of text. To find out which tools were called, which pricing rules fired, and where the conversation stalled, they have to reconstruct it by reading.
2. State destruction. If the AI session is discarded, a human who wants the agent to finish the paperwork must start the agent from scratch.
3. Slow response. Any handoff that goes through a queue adds delay, and response time matters in sales. A well-known Harvard Business Review study of 1.25 million inbound web leads found that firms that tried to contact a lead within an hour were nearly seven times as likely to qualify it as firms that waited even one more hour, and more than 60 times as likely as firms that waited 24 hours or longer (HBR, “The Short Life of Online Sales Leads”). That study measured inbound web leads rather than live pricing negotiations, but it shows how steeply responsiveness is rewarded in sales.
```
Traditional escalation
[Buyer negotiates] -> [Bot fails] -> [Session ends / context dropped]
|
v
[Ticket or CRM queue]
|
v
[Rep reads transcript cold]
```
How LangGraph Handles Pauses
LangGraph models an agent as a graph of nodes and edges. With a checkpointer attached, it saves graph state as it executes, scoped to a thread. That persistence is what makes a pause-and-resume workflow possible.
The primitives
- `thread_id`: The identifier that scopes saved checkpoints to one conversation or workflow. You pass it in the run config (`{"configurable": {"thread_id": "..."}}`) when invoking the graph directly. When you use the Agent Server API, you create or reference a thread by ID.
- Checkpointer: The persistence layer that stores state snapshots. For self-managed deployments, the `langgraph-checkpoint-postgres` package provides `PostgresSaver` and `AsyncPostgresSaver`. The first time you use one, you must call `.setup()` to create its tables. If you create the database connection yourself, use `autocommit=True` and `row_factory=dict_row`, or table creation and reads can fail.
- `interrupt()`: Called inside a node, it suspends execution at that exact point. LangGraph saves the graph state and waits indefinitely until you resume. The value you pass to `interrupt()` surfaces to the caller, for example under `__interrupt__` when you invoke the graph directly.
- `Command(resume=...)`: Resumes the paused thread. The resume value becomes the return value of the `interrupt()` call inside the node.
Two rules that matter in production
1. Interrupts require persistence. `interrupt()` relies on the checkpointer to save state. On a self-managed graph you compile with one. On the Agent Server, the server injects a checkpointer for you (PostgreSQL by default, with MongoDB and custom backends as options). The docs say not to configure a checkpointer in your graph code there, because the server needs to manage it.
2. The node restarts from the top on resume. When you resume, execution begins at the start of the node that called `interrupt()`, not on the next line. Any side effect before the `interrupt()` call, such as sending a Slack message, creating a CRM record, or charging a card, can therefore run twice. Keep such side effects in a separate node before the interrupting node, or make them idempotent.
One more point of terminology: LangGraph also supports static interrupts (`interrupt_before` and `interrupt_after` on nodes). The docs recommend them for debugging and testing, not for human-in-the-loop workflows. Use the dynamic `interrupt()` function for approvals.
Where LangSmith Studio Fits
LangSmith Studio (formerly LangGraph Studio) is an IDE for visualizing, running, and debugging agents. It connects to a local Agent Server started with `langgraph dev`, or to a deployed one. It supports graph visualization, running agents, managing assistants and threads, prompt iteration, and time-travel debugging of agent state.
Studio is where you design and test this workflow: trigger the interrupt, inspect the saved thread state, and confirm that the resume payload produces the right next step. Production routing to Slack is then handled by your own service calling the Agent Server API.
Architecture: From Interrupt to Slack and Back
```
[Buyer] -> [LangGraph agent] -> [Agent Server] [Relay service] [Slack]
1. negotiates | | | |
|-- 2. interrupt() saves state | |
| |-- 3. run ends; webhook -> |
| | |-- 4. post card -->|
| | |<-- 5. AE clicks --|
| | | (lock acquired) |
| |<-- 6. resume ------| |
|<-- 7. agent continues with the human's decision |
```
The sequence works as follows:
1. Branch evaluation. A node detects a request outside automated policy (for example, a discount above 15%).
2. Interruption. The node calls `interrupt()` with a structured payload. State is checkpointed and the run stops.
3. Notification. On the Agent Server, runs accept a `webhook` parameter and the server sends a POST request when the run completes. The webhook payload is a Run object; to read the pending interrupt, fetch the thread’s state. Alternatively, a dedicated node placed *before* the interrupting node can post to Slack, which respects the restart rule above.
4. Slack card. A relay service (InstaChime plays this role in this setup) builds a Block Kit message with interactive buttons for the Deal Desk channel.
5. Atomic claim. The first rep to click wins a short-lived lock, so two reps cannot resume the same thread.
6. Resume. The relay calls the Agent Server with `command={"resume": ...}` on the same thread, and the agent continues with the human’s decision.
Implementation
1. The graph: state, interrupt, and resume
```
from typing import Annotated, Optional, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.types import interrupt
AUTO_APPROVE_LIMIT = 0.15 # business rule: discounts above 15% need a human
class NegotiationState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
company_domain: str
annual_contract_value: float
requested_discount_pct: float
human_approved: Optional[bool]
approved_discount_pct: Optional[float]
rejection_reason: Optional[str]
async def analyze_negotiation(state: NegotiationState):
requested = state.get("requested_discount_pct", 0.0)
if requested <= AUTO_APPROVE_LIMIT:
return {"human_approved": True, "approved_discount_pct": requested}
Execution pauses here. On resume, this node restarts from the top and
interrupt() returns the value passed in Command(resume=...).
Do not put side effects (Slack posts, CRM writes) before this call.
decision = interrupt(
{
"event_type": "DEAL_DESK_APPROVAL_REQUIRED",
"company_domain": state["company_domain"],
"acv": state["annual_contract_value"],
"requested_discount": requested,
"reason": (
f"Requested discount of {requested:.1%} exceeds the "
f"automated {AUTO_APPROVE_LIMIT:.0%} threshold."
),
}
)
return {
"human_approved": decision.get("approved", False),
"approved_discount_pct": decision.get("approved_discount", 0.0),
"rejection_reason": decision.get("reason"),
}
async def respond_to_buyer(state: NegotiationState):
if not state.get("human_approved"):
text = (
"I'm not able to offer the requested terms. "
f"{state.get('rejection_reason') or ''}".strip()
)
else:
text = (
f"Good news: we can offer a {state['approved_discount_pct']:.1%} "
"discount. I'll prepare the agreement."
)
return {"messages": [{"role": "assistant", "content": text}]}
builder = StateGraph(NegotiationState)
builder.add_node("analyze_negotiation", analyze_negotiation)
builder.add_node("respond_to_buyer", respond_to_buyer)
builder.add_edge(START, "analyze_negotiation")
builder.add_edge("analyze_negotiation", "respond_to_buyer")
builder.add_edge("respond_to_buyer", END)
Self-managed: compile with your own checkpointer (e.g. AsyncPostgresSaver).
On Agent Server / LangSmith Deployment, do NOT pass a checkpointer; the server
injects one.
graph = builder.compile()
```
For a self-managed setup, create the checkpointer like this:
```
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
await checkpointer.setup() # first use only: creates the tables
graph = builder.compile(checkpointer=checkpointer)
```
The `langgraph-checkpoint-postgres` docs also recommend setting `LANGGRAPH_STRICT_MSGPACK=true` or passing an explicit `allowed_msgpack_modules` list. That restricts checkpoint deserialization to known-safe types if the database is ever compromised.
2. The Slack card
A Block Kit message with a header, a few fields, and an `actions` block is enough for a claim-and-decide card. Interactive components like buttons produce `block_actions` payloads when clicked. In the example below, the account name is a placeholder, and the button values carry an opaque thread ID rather than any sensitive data.
```
{
"channel": "C0123456789",
"text": "Human action required: enterprise negotiation paused",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "Deal Desk approval needed", "emoji": true }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Account:* acme-corp.example" },
{ "type": "mrkdwn", "text": "*Target ACV:* $140,000/yr" },
{ "type": "mrkdwn", "text": "*Requested discount:* 28%" },
{ "type": "mrkdwn", "text": "*Agent status:* paused (`interrupt()`)" }
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Reason:* requested discount exceeds the automated 15% threshold."
}
},
{
"type": "context",
"elements": [
{ "type": "mrkdwn", "text": "Thread: `7c9e6679-7425-40de-944b-e07fc1f90ae7`" }
]
},
{ "type": "divider" },
{
"type": "actions",
"block_id": "negotiation_actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Claim", "emoji": true },
"style": "primary",
"action_id": "claim_thread",
"value": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
},
{
"type": "button",
"text": { "type": "plain_text", "text": "Approve 20%", "emoji": true },
"action_id": "approve_20",
"value": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
},
{
"type": "button",
"text": { "type": "plain_text", "text": "Reject", "emoji": true },
"style": "danger",
"action_id": "reject_offer",
"value": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
]
}
]
}
```
For free-form overrides (say, 18% instead of 20%), open a modal from the claim button using the payload’s `trigger_id`. Slack triggers expire after three seconds and can be used only once, so open the modal immediately.
3. Receiving clicks and resuming the thread
Slack’s interactivity rules shape this handler:
- Slack sends interaction payloads to your Request URL as `application/x-www-form-urlencoded`, with a `payload` parameter containing JSON. It is not a JSON request body.
- You must acknowledge with an HTTP 200 within 3 seconds, or the user sees an error. Do slow work afterwards and report back via the payload’s `response_url`.
- Verify every request’s signature using your signing secret, computed over the raw body before parsing.
```
slack_handler.py
import hashlib
import hmac
import json
import os
import time
from urllib.parse import parse_qs
import httpx
import redis.asyncio as redis
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from langgraph_sdk import get_client
from langgraph_sdk.schema import Command
app = FastAPI()
lg = get_client(url=os.environ["LANGGRAPH_API_URL"])
r = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
ASSISTANT_ID = "enterprise_negotiator" # graph name from langgraph.json
def verify_slack_request(raw_body: bytes, headers) -> None:
ts = headers.get("X-Slack-Request-Timestamp", "")
sig = headers.get("X-Slack-Signature", "")
if not ts or abs(time.time() - int(ts)) > 60 * 5: # reject stale requests
raise HTTPException(status_code=401, detail="stale request")
base = f"v0:{ts}:{raw_body.decode()}".encode()
expected = "v0=" + hmac.new(SIGNING_SECRET.encode(), base, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(status_code=401, detail="bad signature")
async def claim(thread_id: str, user_id: str) -> bool:
"""Atomic claim: SET NX with an expiry, so only one rep wins."""
return bool(await r.set(f"lock:thread:{thread_id}", user_id, nx=True, ex=300))
async def owner(thread_id: str) -> str | None:
val = await r.get(f"lock:thread:{thread_id}")
return val.decode() if val else None
async def resume_thread(thread_id: str, decision: dict, response_url: str, text: str):
multitask_strategy="reject" is a second line of defense: a duplicate
resume on a busy thread is refused instead of queued (default is "enqueue").
await lg.runs.create(
thread_id,
ASSISTANT_ID,
command=Command(resume=decision),
multitask_strategy="reject",
)
async with httpx.AsyncClient() as http:
await http.post(response_url, json={"replace_original": False, "text": text})
@app.post("/slack/interactive")
async def interactive(request: Request, background: BackgroundTasks):
raw = await request.body()
verify_slack_request(raw, request.headers)
payload = json.loads(parse_qs(raw.decode())["payload"][0])
action = payload["actions"][0]
action_id, thread_id = action["action_id"], action["value"]
user_id, response_url = payload["user"]["id"], payload["response_url"]
if action_id == "claim_thread":
if await claim(thread_id, user_id):
return {"response_type": "in_channel", "text": f"<@{user_id}> claimed this negotiation."}
return {"response_type": "ephemeral", "text": f"Already claimed by <@{await owner(thread_id)}>."}
Approve/reject only for the rep who holds the lock.
if await owner(thread_id) != user_id:
return {"response_type": "ephemeral", "text": "Claim this thread first."}
if action_id == "approve_20":
decision = {"approved": True, "approved_discount": 0.20,
"reason": f"Approved at 20% by Slack user {user_id}"}
note = f"Approved at 20% by <@{user_id}>; agent resuming."
elif action_id == "reject_offer":
decision = {"approved": False, "approved_discount": 0.0,
"reason": "Discount request declined by Deal Desk."}
note = f"Rejected by <@{user_id}>; agent resuming."
else:
return {}
Acknowledge within 3 seconds; do the resume afterwards.
background.add_task(resume_thread, thread_id, decision, response_url, note)
return {}
```
The resume payload shape (`approved`, `approved_discount`, `reason`) matches what `analyze_negotiation` reads from `interrupt()`’s return value, so the two sides must agree on that contract.
Governance: Races, Timeouts, and Fallbacks
Preventing double-handling
When several account executives watch one channel, two of them may click at nearly the same moment. Two resume calls against the same paused thread can produce duplicate or conflicting results. The handler above uses two layers of protection:
- A claim lock in Redis (`SET key value NX EX 300`). The first caller creates the key; everyone else gets nothing back and is told who owns the thread. The expiry means an abandoned claim frees itself.
- `multitask_strategy="reject"` on the resume call. The Agent Server’s default for concurrent runs on a thread is `enqueue`, so a second resume would otherwise queue up.
A lock expiry is not a guarantee against a rep who is slow rather than absent. If your approvals are consequential, also record the approving user and timestamp in your own audit store.
Unclaimed escalations
If nobody claims a card, the buyer is left waiting. A tiered timeout is a common design, with thresholds that are a business decision rather than a LangGraph feature:
```
[interrupt raised] -> post card to the sales channel (primary AE)
| no claim after N seconds
v
escalate -> ping a manager or on-call rota
| still no claim after M seconds
v
fail-safe -> resume with a conservative default
(e.g. standard terms plus "a specialist will follow up")
```
Only automate the final fail-safe if the default is something you would be comfortable sending to the buyer without a human having seen it. Many teams will prefer to have the agent tell the buyer that a specialist will follow up and leave the thread paused.
Comparing Approaches
The table below is qualitative. Actual response times depend on your staffing, channel, and tooling rather than on the framework.
| Capability | Transcript handoff to a queue | CRM routing rules | LangGraph checkpoint routing |
|---|---|---|---|
| State preserved for the agent | Usually no; the session ends | No; the lead record is routed, not the agent’s state | Yes; the thread’s checkpointed state persists |
| What the human sees | Raw transcript | Lead or task record | A structured payload built from graph state |
| Human input reaches the agent | Re-typed or lost | Manual record updates | Directly, as the resume value of `interrupt()` |
| Resume after approval | Restart conversation | Not applicable | Continue on the same `thread_id` |
| Audit trail | Chat logs | Task history | Checkpoint history stored in the database |
Practical Notes
- Idempotency. Because the interrupting node re-runs on resume, treat everything before `interrupt()` as repeatable.
- One interrupt per decision. The docs warn against looping `interrupt()` calls with non-deterministic logic (such as `while True` validation); use a conditional edge to re-ask instead.
- Keep interrupt payloads simple. The docs advise against returning complex values from `interrupt()` calls. Stick to JSON-serializable data.
- Webhook security. Agent Server webhooks are plain POST requests; the docs suggest adding a secret token as a query parameter so only authorized requests reach your endpoint.
- Slack scopes. Posting messages needs a bot token with `chat:write`, and buttons need Interactivity enabled with a Request URL in your Slack app settings.
- Test in Studio first. Run the graph with a 28% discount, confirm the thread pauses, then resume it with different payloads to check each branch.
Conclusion
Checkpoint-based escalation fixes what transcript handoffs break. The agent keeps its state, the rep gets a structured summary instead of a transcript, and the human’s decision comes back as the return value of `interrupt()`. The graph never restarts. The main engineering work is in the details: verify Slack’s signatures, acknowledge within three seconds, claim threads atomically, and keep side effects out of the part of a node that re-runs on resume.
Next steps
1. Prototype the graph locally with `langgraph dev` and open it in LangSmith Studio.
2. Decide where persistence lives: the Agent Server’s built-in checkpointer, or your own `AsyncPostgresSaver` for a self-managed deployment.
3. Define your approval thresholds and the exact resume payload contract.
4. Build the Slack app (interactivity, signing-secret verification, claim lock) and test it with two reps clicking at once.
5. Decide, as a business policy, what should happen when no one claims a card.
Sources
- LangGraph interrupts and human-in-the-loop docs
- LangSmith Deployment: add human-in-the-loop with the SDK
- Agent Server overview and checkpointer configuration
- LangSmith Studio documentation
- LangChain changelog: product naming changes
- `langgraph-checkpoint-postgres` on PyPI
- Slack: handling user interaction
- Harvard Business Review: The Short Life of Online Sales Leads
