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.

CapabilityTranscript handoff to a queueCRM routing rulesLangGraph checkpoint routing
State preserved for the agentUsually no; the session endsNo; the lead record is routed, not the agent’s stateYes; the thread’s checkpointed state persists
What the human seesRaw transcriptLead or task recordA structured payload built from graph state
Human input reaches the agentRe-typed or lostManual record updatesDirectly, as the resume value of `interrupt()`
Resume after approvalRestart conversationNot applicableContinue on the same `thread_id`
Audit trailChat logsTask historyCheckpoint 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