← Back to Guides
GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Security Experts

Build a Kill Switch for a LangGraph Agent

Four independent mechanisms - a tool-call interceptor, a watchdog, an anomaly detector, and a manual override - that stop it even when it will not stop itself.

#tutorial#intermediate#langgraph#ai-safety#agent-security#kill-switch

By the end you will have built a kill switch for a LangGraph agent: four independent mechanisms that stop it even when it will not stop itself. A tool-call interceptor blocks an ungranted call before it executes. A watchdog kills the agent's own OS process when it detects a behavioral loop. An anomaly detector revokes a credential when many individually-legal calls add up to a policy violation. And a manual override halts a thread, then forks it back to the last safe checkpoint. Intermediate level: it assumes you already know what an agent, a tool call, and a LangGraph runtime are, and spends its explanation budget on the four mechanisms rather than on LangGraph. About 90 minutes.

code
[interceptor] ALLOWED issue_refund(ticket_id=8812, cents=30000)[interceptor] BLOCKED issue_refund: refunds over 50000 cents (500 dollars) need a human[watchdog] KILLED pid=29368 after 5 steps (repeated call, no new state), exitcode=-15[anomaly] REVOKED cred-cumulative after 21 calls[override] HALTED thread demo-4, found the safe checkpoint before refund_c[override] 5 checkpoints on record - none deleted, the fork is a new branchAUDIT:  exceeds_authority (per-call): covered  exceeds_authority (cumulative): covered  unexpected_behaviour (loop): covered  explicit_safety_trigger (manual): covered

Verified against Python 3.13.9, langgraph==1.2.11, langgraph-checkpoint==4.2.0, langchain-core==1.6.1, langchain-anthropic==1.7.1, anthropic==1.4.0 on 2026-09-08. Steps 1 through 10 need no credential; the final step wires the kill switch around a live claude-sonnet-5 agent and was executed for real, against a working ANTHROPIC_API_KEY, for this verification - see Step 11's "What just happened" for the real transcript. Re-verify this tutorial if langgraph ships a 2.x release, if multiprocessing's default start method changes again after 3.14's shift to forkserver, or if langchain-anthropic changes its exception hierarchy - all three are load-bearing for Steps 4, 5, and 11.

Prefer to watch this get built before you type any of it? I recorded a walkthrough covering why most kill-switch implementations fail and what actually holds up - the same four failure modes this tutorial builds a mechanism against.

Prerequisites

  • Python 3.13.9 or later 3.13.x (3.14.x also works; see the note on process start methods in Step 4)
  • langgraph==1.2.11
  • langgraph-checkpoint==4.2.0 (pulled in transitively by langgraph, pinned here explicitly because this tutorial depends on its InMemorySaver)
  • langchain-core==1.6.1 (pulled in transitively; pinned for the message and tool types)
  • langchain-anthropic==1.7.1 and anthropic==1.4.0 - needed only for the final step (Step 11), where you wire the kill switch around a real Claude-backed agent
  • An Anthropic API key, set as ANTHROPIC_API_KEY - needed only for Step 11

Nothing else. Steps 1 through 10 use only the Python standard library plus langgraph and langchain-core - no network calls, no credentials.

On macOS or Linux:

code
mkdir killswitch-tutorial && cd killswitch-tutorialpython -m venv .venvsource .venv/bin/activatepip install langgraph==1.2.11 langgraph-checkpoint==4.2.0 langchain-core==1.6.1pip install langchain-anthropic==1.7.1 anthropic==1.4.0

On Windows (PowerShell):

code
mkdir killswitch-tutorial; cd killswitch-tutorialpython -m venv .venv.venv\Scripts\activatepip install langgraph==1.2.11 langgraph-checkpoint==4.2.0 langchain-core==1.6.1pip install langchain-anthropic==1.7.1 anthropic==1.4.0

Verify the install before you write any code of your own:

code
python -c "import langgraph, langgraph.checkpoint.memory, langchain_core; print('ok')"
code
ok

Four boundaries a LangGraph agent's execution loop can't reach

Every mechanism in this tutorial sits at one of four boundaries around an agent's execution loop. None of the four live inside it. That placement is the whole design: a mechanism the agent's own reasoning can reach is a mechanism it can reason its way around, or decline, or simply never reach.

Four boundaries because there are four ways an agent needs to be stopped, and no single mechanism catches all of them: a call that exceeds its authority, one call at a time (Steps 2-3); the same authority exceeded a little at a time, across many individually legal calls (Steps 7-8); a behavioral loop that breaks no rule at all (Steps 4-6); and an explicit human decision to stop, which needs to work even when nothing else is wrong (Steps 9-10). Step 11's closing audit checks all four by these exact names - exceeds_authority, split into per-call and cumulative, unexpected_behaviour, and explicit_safety_trigger - so it is worth having them in mind now.

The four boundaries, in the order you will build them:

mermaid
flowchart TB
    WATCHDOG["watchdog<br/>(watches from OUTSIDE<br/>the process, can kill it)"]
    INTERCEPTOR["interceptor<br/>(between a tool-call<br/>decision and its execution)"]

    subgraph LOOP["the agent's own loop"]
        direction LR
        A1["agent node"] --> T1["tool node"] --> A2["agent node"] --> DOTS["..."]
    end

    WATCHDOG ==>|kills| LOOP
    INTERCEPTOR -.-> T1

    T1 -.->|each call carries<br/>a credential id| ANOMALY["anomaly detector<br/>+ credential store<br/>(outside graph state)"]
    T1 -.->|each call spends against a<br/>running total the graph's<br/>own state never sees| ANOMALY

    OVERRIDE["manual override<br/>(standing flag checked<br/>before every step)"] -.->|checks| LOOP
    OVERRIDE ==>|forks to an earlier<br/>checkpoint| LOOP

    style LOOP fill:#ffffff,color:#2C2C2A,stroke:#2C2C2A
    style A1 fill:#38BDF8,color:#2C2C2A
    style A2 fill:#38BDF8,color:#2C2C2A
    style T1 fill:#4ADE80,color:#2C2C2A
    style DOTS fill:#ffffff,color:#2C2C2A,stroke:#2C2C2A
    style WATCHDOG fill:#F87171,color:#FFFFFF
    style INTERCEPTOR fill:#FB923C,color:#2C2C2A
    style ANOMALY fill:#6F00FF,color:#FFFFFF
    style OVERRIDE fill:#4B0082,color:#FFFFFF

Three of them run inside the same OS process as the agent: the interceptor, the anomaly detector, and the manual override. They still sit outside the part of that process the model's own output can influence, because they are plain Python functions a developer wrote. The watchdog is the exception, and it runs in a genuinely different OS process. A loop that never yields control back to Python cannot be interrupted by anything running inside it. Something outside the process boundary has to end it.

The credential store needs a note before you write a line of code: it is deliberately not a LangGraph state key. Step 10 explains why with a working demonstration. The short version is that LangGraph's checkpointer restores state, and a manual override that rewinds to an earlier checkpoint restores whatever that checkpoint remembered. Write "this credential is revoked" into checkpointed state and the rewind un-revokes it. An override that resurrects the exact authority it was supposed to take away is worse than no override at all.

Step 1: Build the LangGraph agent you are about to have to stop

Goal

Build the smallest LangGraph agent that can call tools, with no safety layer at all. This is the baseline every later step hardens.

Why this step

You cannot demonstrate stopping an agent without an agent that would otherwise run unchecked. This one gets two tools: a harmless search, and a refund that moves money. That pair is enough to carry one authorized action and one that should have been blocked. If the difference between a StateGraph loop and a plain while loop is not already obvious, LangGraph or a While Loop? You Already Have a Runtime covers that ground; this tutorial assumes it and moves straight to the graph below.

Code

agent.py:

code
from typing import Annotated, TypedDictimport operatorfrom langchain_core.messages import AnyMessage, ToolMessagefrom langchain_core.tools import toolfrom langgraph.graph import StateGraph, START, ENDclass AgentState(TypedDict):    messages: Annotated[list[AnyMessage], operator.add]@tooldef search_tickets(query: str) -> str:    """Search the support ticket system for tickets matching a query."""    return f"3 tickets match {query!r}: #8812 (open), #8815 (open), #8820 (closed)"@tooldef issue_refund(ticket_id: str, cents: int) -> str:    """Issue a refund of the given amount, in cents, against a ticket."""    return f"refunded {cents} cents on ticket {ticket_id}"TOOLS = [search_tickets, issue_refund]TOOLS_BY_NAME = {t.name: t for t in TOOLS}def route_after_model(state: AgentState):    last = state["messages"][-1]    if getattr(last, "tool_calls", None):        return "tools"    return ENDdef run_tools(state: AgentState) -> dict:    last = state["messages"][-1]    outputs = []    for tc in last.tool_calls:        result = TOOLS_BY_NAME[tc["name"]].invoke(tc["args"])        outputs.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))    return {"messages": outputs}def build_agent(checkpointer=None, tools_node=run_tools):    """Constructs ChatAnthropic lazily, inside this function, so importing this module    needs no API key - only calling build_agent() with the default model does."""    from langchain_anthropic import ChatAnthropic    model = ChatAnthropic(model="claude-sonnet-5").bind_tools(TOOLS)    def call_model(state: AgentState) -> dict:        return {"messages": [model.invoke(state["messages"])]}    graph = StateGraph(AgentState)    graph.add_node("agent", call_model)    graph.add_node("tools", tools_node)    graph.add_edge(START, "agent")    graph.add_conditional_edges("agent", route_after_model, ["tools", END])    graph.add_edge("tools", "agent")    return graph.compile(checkpointer=checkpointer)

Run it

The module is safe to import without an API key, because ChatAnthropic is constructed inside build_agent() rather than at import time. So the routing and tool-execution logic can be proved on its own, with no live model call at all:

step1_run.py:

code
from langchain_core.messages import AIMessagefrom agent import route_after_model, run_toolswith_calls = AIMessage(content="", tool_calls=[    {"name": "search_tickets", "args": {"query": "billing"}, "id": "toolu_01",     "type": "tool_call"},])print("route with tool_calls ->", route_after_model({"messages": [with_calls]}))without_calls = AIMessage(content="Here are the tickets.")print("route without tool_calls ->", route_after_model({"messages": [without_calls]}))result = run_tools({"messages": [with_calls]})for msg in result["messages"]:    print("tool result:", msg.content)
code
python step1_run.py

Expected output

code
route with tool_calls -> toolsroute without tool_calls -> __end__tool result: 3 tickets match 'billing': #8812 (open), #8815 (open), #8820 (closed)

What just happened

agent.py now defines a working ReAct-style loop: the model decides, the graph routes to tools when it made a call, run_tools executes and hands the result back for another turn. There is no policy check anywhere in it. issue_refund will execute for any amount the model asks for, and nothing outside the loop can stop the loop from running forever. The next four steps close those two gaps in order. Steps 9 and 10 close a third one this step does not hint at yet: what to do when a run that already happened needs undoing.

Step 2: Build the tool-call interceptor

Goal

Write a rule-based, default-deny gate that checks a tool call's name and arguments against an explicit policy before the call is allowed to run.

Why this step

agent.py's run_tools executes whatever the model decided, with no check at all. The standard answer is a tool-call interceptor: sit on the boundary between the agent's decision and the world, and default to deny. An allow-list catches a call nobody imagined. A deny-list only catches the ones somebody thought to write down. Academic work on this pattern (AgentSpec, Wang, Poskitt & Sun, ICSE 2026) reports it prevents unsafe executions in over 90 percent of measured code-agent cases, at millisecond overhead. Zero Trust Agents argues this is the only defensible architecture for agent tool calls in general; what you are about to write is a runnable, hand-rolled version of exactly that argument.

Code

policy.py:

code
from dataclasses import dataclassfrom typing import Callable@dataclass(frozen=True)class ToolPolicy:    tool_name: str    check: Callable[[dict], bool]    reason: strclass PolicyViolation(Exception):    def __init__(self, tool_call: dict, reason: str):        self.tool_call = tool_call        self.reason = reason        super().__init__(f"{tool_call['name']}: {reason}")class ToolCallInterceptor:    def __init__(self, policies: list[ToolPolicy]):        self._policies = {p.tool_name: p for p in policies}    def check(self, tool_call: dict) -> None:        policy = self._policies.get(tool_call["name"])        if policy is None:            raise PolicyViolation(tool_call, "no policy grants this tool at all")        if not policy.check(tool_call.get("args", {})):            raise PolicyViolation(tool_call, policy.reason)def make_call(call_id: str, name: str, **args) -> dict:    """Build a dict matching langchain_core's ToolCall shape. This tutorial scripts    calls this way so every demo is reproducible - the shape a real model emits on    `ai_message.tool_calls` is identical: {"name", "args", "id", "type"}."""    return {"name": name, "args": args, "id": call_id, "type": "tool_call"}

ToolCallInterceptor.check keys off tool_call["name"] and tool_call["args"] only. It never requires a "type" key, because langchain_core's own ToolCall marks type as NotRequired; a check that demanded it would reject perfectly valid calls.

Run it

Append this to the bottom of policy.py:

code
if __name__ == "__main__":    interceptor = ToolCallInterceptor([        ToolPolicy(            tool_name="search_tickets",            check=lambda args: True,            reason="search is always allowed",        ),        ToolPolicy(            tool_name="issue_refund",            check=lambda args: args.get("cents", 0) <= 50_000,            reason="refunds over 50000 cents (500 dollars) need a human",        ),    ])    granted = make_call("toolu_01", "issue_refund", ticket_id="8812", cents=30_000)    interceptor.check(granted)    print("ALLOWED issue_refund(ticket_id=8812, cents=30000)")
code
python policy.py

Expected output

code
ALLOWED issue_refund(ticket_id=8812, cents=30000)

What just happened

You have a working interceptor with two written policies and, implicitly, a rule for everything else. Anything that is not search_tickets or issue_refund has no policy at all, and check treats the absence as a block rather than a pass. Step 3 exercises both failure paths.

Step 3: Prove the interceptor blocks an ungranted call

Goal

Show the interceptor blocking a call that exceeds its per-call limit, and a call to a tool that was never granted at all.

Why this step

An interceptor that only ever allows calls has not been tested. Two failure shapes matter here, and they fail for different reasons. The first is a granted tool pushed past its limit: a human set the cap at 50,000 cents, and the call asks for 1,200,000. The second is a tool with no policy attached at all, where default-deny is the only thing standing in the way.

Code

Append to the if __name__ == "__main__": block in policy.py:

code
    overreach = make_call("toolu_02", "issue_refund", ticket_id="8812", cents=1_200_000)    try:        interceptor.check(overreach)        print("ERROR: should have blocked")    except PolicyViolation as e:        print(f"BLOCKED {e}")    ungranted = make_call("toolu_03", "delete_account", account_id="acct-1")    try:        interceptor.check(ungranted)        print("ERROR: should have blocked")    except PolicyViolation as e:        print(f"BLOCKED {e}")

Run it

code
python policy.py

Expected output

code
ALLOWED issue_refund(ticket_id=8812, cents=30000)BLOCKED issue_refund: refunds over 50000 cents (500 dollars) need a humanBLOCKED delete_account: no policy grants this tool at all

What just happened

Both blocks fired before anything ran. issue_refund.invoke() was never called for either the oversized amount or the ungranted tool, which is the property to hold onto: blocked means it never executed. Nothing in this tutorial, or in LangGraph, takes back a call that already left. The interceptor's blind spot is the next problem: a call that is individually fine every single time, twenty times in a row.

Step 4: Build the watchdog

Goal

Write a watchdog that reads a stream of heartbeat events from an agent's execution and detects a behavioral loop - the same call, over and over, with nothing new happening.

Why this step

A runaway loop is legal at every single step. Calling search_tickets a thousand times with the same query violates nothing in its policy, and the interceptor you just built has no rule that could catch it: the fault is the pattern across many calls. LangGraph does ship a built-in step budget, recursion_limit, and it is not the same mechanism. Exceed it and the graph raises langgraph.errors.GraphRecursionError: Recursion limit of N reached without hitting a stop condition - a real, catchable exception, which is exactly the problem: it is enforced inside the agent's own process, and a companion piece on this site measured it being re-granted in full on every resume, so a supervisor that retries a failed run gets a whole fresh budget rather than the remainder of the old one (LangGraph Checkpoints Restore Your Limits, Not Just Your State has the measurement). The watchdog asks a different question: not "how many steps has this run taken" but "is this run getting anywhere". And it asks from outside the process that could catch and swallow its own recursion error.

Code

watchdog.py:

code
import multiprocessing as mpimport timefrom dataclasses import dataclass@dataclassclass Heartbeat:    step: int    tool_name: str    tool_args: dict    new_state: boolclass Watchdog:    def __init__(        self, *, repeat_threshold: int = 5, step_budget: int = 200,        poll_interval: float = 0.05,    ):        self.repeat_threshold = repeat_threshold        self.step_budget = step_budget        self.poll_interval = poll_interval    def watch(self, process: mp.Process, heartbeats: "mp.Queue[Heartbeat]") -> dict:        last_call = None        repeat_run = 0        steps_seen = 0        while True:            if not process.is_alive() and heartbeats.empty():                return {"verdict": "completed", "steps": steps_seen}            try:                hb = heartbeats.get(timeout=self.poll_interval)            except Exception:                continue            steps_seen += 1            signature = (hb.tool_name, tuple(sorted(hb.tool_args.items())))            if signature == last_call and not hb.new_state:                repeat_run += 1            else:                repeat_run = 1                last_call = signature            hit_repeat = repeat_run >= self.repeat_threshold            hit_budget = steps_seen >= self.step_budget            if hit_repeat or hit_budget:                process.terminate()                process.join(timeout=2)                if process.is_alive():                    process.kill()                    process.join(timeout=2)                if hit_repeat:                    reason = "repeated call, no new state"                else:                    reason = "step budget exceeded"                return {                    "verdict": "killed",                    "reason": reason,                    "steps": steps_seen,                    "exitcode": process.exitcode,                }

The new_state flag on Heartbeat is there deliberately. A watchdog that compared only tool name and arguments would flag a legitimate retry: calling search_tickets with the same query five times because the results keep changing is not a loop. Calling it five times because nothing about the world changed in between is. new_state carries that distinction into the signature the watchdog compares.

A platform note before you run this. Python 3.14 changed the default process start method on POSIX from fork to forkserver; Windows and macOS have always defaulted to spawn. All three re-import the target module in the child process rather than inheriting the parent's memory, so the watchdog's target function has to be defined at module level, importable by name - not a lambda, not a closure. Step 5 shows exactly what happens when you break that rule.

Run it

Watchdog needs a process to watch before it does anything interesting, and that arrives in Step 5. For now, confirm the file parses and the class is importable:

code
python -c "from watchdog import Watchdog, Heartbeat; print(Watchdog().repeat_threshold)"
code
5

What just happened

The watchdog speaks in heartbeats rather than in tool calls, and that is what lets it sit outside the process making those calls. It does not know what search_tickets does. All it sees is whether the shape of the calls reaching it looks like progress or like a loop.

Step 5: Run a scripted runaway loop in its own process

Goal

Build a stand-in for an agent stuck in a loop, run it in a separate OS process, and wire its heartbeats to the watchdog.

Why this step

The watchdog can only kill what is not sharing its own process, and the heartbeat queue is how it learns about that process from outside. The loop here is scripted on purpose. A real model rarely loops on command, and the mechanism that catches a loop cannot tell whether it came from a confused model or from a while True, so the scripted version is the one that makes this reproducible.

Code

Append to watchdog.py:

code
def runaway_demo(heartbeats: "mp.Queue[Heartbeat]") -> None:    """A scripted stand-in for an agent stuck in a loop: the same call, forever."""    step = 0    while True:        step += 1        heartbeats.put(Heartbeat(            step=step,            tool_name="search_tickets",            tool_args={"query": "billing issue"},            new_state=False,        ))        time.sleep(0.01)

runaway_demo is a module-level function on purpose. It is the target passed to multiprocessing.Process, so Step 4's platform note lands here first. Try it the wrong way before the right way; the failure is one you want to recognize on sight.

wrong_way.py (a throwaway script to show the error - nothing later imports it):

code
import multiprocessing as mpimport timedef make_target():    def runaway_agent_local(heartbeats):        while True:            heartbeats.put(1)            time.sleep(0.01)    return runaway_agent_localif __name__ == "__main__":    # Forces the failure on every platform, including Linux, where the    # default start method is still `fork` on Python 3.13 - fork inherits    # the parent's memory instead of re-importing, so this bug hides there    # unless you force `spawn`, exactly as Windows/macOS/3.14+ already do.    mp.set_start_method("spawn", force=True)    q = mp.Queue()    p = mp.Process(target=make_target(), args=(q,))    try:        p.start()        p.join(timeout=2)    except Exception as e:        print(f"{type(e).__name__}: {e}")

Run it

code
python wrong_way.py

Expected output

code
AttributeError: Can't get local object 'make_target.<locals>.runaway_agent_local'

That line is the one to recognize, and on some platforms it is not the last thing printed. Under the spawn start method, the child process is already created before the parent tries to pickle its target, so when pickling fails and the parent exits, the now-orphaned child can print a second, unrelated traceback of its own - typically ending in OSError: [WinError 87] or PermissionError: [WinError 5] on Windows, and it varies run to run. That second traceback is noise from the crash, not a second bug; nothing in this tutorial depends on it, and there is no clean way to suppress it from the parent, since the failed start() leaves nothing to call terminate() on.

Now the working version. Append this to watchdog.py:

code
if __name__ == "__main__":    heartbeats = mp.Queue()    process = mp.Process(target=runaway_demo, args=(heartbeats,))    process.start()    print(f"agent process started, pid={process.pid}")    watchdog = Watchdog(repeat_threshold=5)    verdict = watchdog.watch(process, heartbeats)    print(f"verdict: {verdict['verdict']}, reason: {verdict['reason']}")    print(f"steps: {verdict['steps']}, exitcode: {verdict['exitcode']}")    print(f"process alive after kill: {process.is_alive()}")
code
python watchdog.py

Expected output

code
agent process started, pid=29368verdict: killed, reason: repeated call, no new statesteps: 5, exitcode: -15process alive after kill: False

The pid on your machine will differ. exitcode=-15 needs a word of explanation: on POSIX that is the negated signal number for SIGTERM, which is what terminate() sends. Python's multiprocessing reports the same negative-signal-style code on Windows too, even though the underlying Win32 TerminateProcess() call carries no signal at all. That is multiprocessing's own convention, verified above on Windows 11. It is not a guarantee you get from raw subprocess or from TerminateProcess in general.

What just happened

A real OS process ran a real infinite loop, and the watchdog killed it from outside without ever asking it to stop. runaway_demo never learned it was killed. It got no chance to catch an exception, run a finally block, or decline. That is what a standing condition means in practice: a mechanism outside the agent's own control loop, so nothing running inside that loop gets a vote on whether it fires.

There is a limit to this, and it belongs here rather than in a footnote. terminate()'s own documentation warns that a killed process sharing a pipe or a queue with anything else can leave that pipe or queue corrupted. This demo does not hit the case: the watchdog shares exactly one queue with the child, and only the child writes to it. Wire the same watchdog around a process holding a lock or a second shared resource and you have to account for it.

A subprocess boundary is a kill boundary, not a sandbox. Python removed its own sandboxing modules, rexec and Bastion, in the 2.3-to-3.0 era as unfixable, and nothing replaced them. There is no supported story for containing what a process can do to itself before it is killed. Only for ending it.

Step 6: The loop the interceptor is structurally blind to

Goal

Run the interceptor against a thousand identical calls and watch it allow every one, then compare that with the watchdog's verdict on the same shape of call from Step 5.

Why this step

Two separate mechanisms are only worth their cost if they catch different things. This step puts a number on that instead of taking it on faith.

Code

prove_boundary.py:

code
from policy import ToolCallInterceptor, ToolPolicy, make_callinterceptor = ToolCallInterceptor([    ToolPolicy("search_tickets", lambda a: True, "search is always allowed"),])# 1,000 calls to a tool with no restriction at all - every single one is legalfor i in range(1, 1001):    interceptor.check(make_call(f"toolu_{i}", "search_tickets", query="billing issue"))print("interceptor: checked 1000 identical calls, blocked 0 - every one was allowed")

Run it

code
python prove_boundary.py

Expected output

code
interceptor: checked 1000 identical calls, blocked 0 - every one was allowed

What just happened

A thousand identical calls, all correctly allowed, because no single one of them violates anything. The interceptor is doing its job: one call, one policy, one verdict. That is the whole comparison: set it against Step 5's watchdog.py, which killed the process after five repeats of that same shape of call, in well under a second, with no policy change required. There is nothing further to run - the watchdog half of this comparison is the Step 5 output you already have on screen. The watchdog is not a redundant second copy of the interceptor. It exists because the interceptor structurally cannot see this pattern, no matter how the policy is written.

Step 7: Build the anomaly detector for cumulative violations

Goal

Write a detector that watches a running total per credential and revokes the credential the moment the total crosses a limit, even though every individual call stayed under the interceptor's per-call cap.

Why this step

Twenty-four refunds of five hundred dollars each pass Step 2's interceptor twenty-four times, because each one sits exactly at the granted limit. The policy sees one call at a time and has no way to bound the sum. That is a second shape of exceeding authority, and it needs a separate mechanism on purpose: revoke the credential at the source, so that every call after the one that tripped the limit fails immediately, everywhere the credential is checked. This is not a hypothetical: the Hugging Face breach that mapped to ten OWASP ASI categories ran to 17,000 individually small, individually legal actions before anyone caught the pattern - exactly the shape this mechanism exists to catch earlier.

Code

anomaly.py:

code
from dataclasses import dataclass, fieldclass CredentialRevoked(Exception):    pass@dataclassclass CredentialStore:    """Deliberately not LangGraph state. See the mental model above, and Step 10 for    what goes wrong if this lives inside the checkpointed graph state instead."""    _valid: dict = field(default_factory=dict)    def grant(self, credential_id: str) -> None:        self._valid[credential_id] = True    def revoke(self, credential_id: str, reason: str) -> None:        # reason is accepted for a real audit sink; this in-memory store drops it.        self._valid[credential_id] = False    def is_valid(self, credential_id: str) -> bool:        return self._valid.get(credential_id, False)    def check(self, credential_id: str) -> None:        if not self.is_valid(credential_id):            raise CredentialRevoked(f"credential {credential_id!r} is not valid")class AnomalyDetector:    def __init__(self, store: CredentialStore, *, cumulative_limit: int):        self.store = store        self.cumulative_limit = cumulative_limit        self._totals: dict = {}    def observe(self, credential_id: str, amount: int) -> None:        self.store.check(credential_id)        total = self._totals.get(credential_id, 0) + amount        self._totals[credential_id] = total        if total > self.cumulative_limit:            self.store.revoke(                credential_id,                f"cumulative total {total} exceeded limit {self.cumulative_limit}",            )

observe checks that the credential is still valid before it does anything else. A credential revoked by an earlier call is therefore rejected on the next one, without waiting for the total to be recomputed.

Run it

Append this to the bottom of anomaly.py:

code
if __name__ == "__main__":    store = CredentialStore()    store.grant("cred-standard")    detector = AnomalyDetector(store, cumulative_limit=10_000)    for i in range(1, 30):        try:            detector.observe("cred-standard", 500)        except CredentialRevoked as e:            print(f"call {i}: REJECTED before it ran - {e}")            break        print(f"call {i}: allowed, running total now {detector._totals['cred-standard']}")
code
python anomaly.py

Expected output

code
call 1: allowed, running total now 500call 2: allowed, running total now 1000...call 20: allowed, running total now 10000call 21: allowed, running total now 10500call 22: REJECTED before it ran - credential 'cred-standard' is not valid

(The full run prints all 21 allowed calls. They are elided here for length; calls 3 through 19 differ from the pattern shown only in the numbers.)

What just happened

Call 21 pushes the running total past the limit and still goes through. The pattern only becomes visible after that call lands, so there was no moment at which it could have been blocked. Everything after it changes: call 22 is rejected before issue_refund would have run at all, and not because its own arguments were wrong. The credential itself is gone. That is what "every open connection drops" means in practice - nothing goes hunting for the places the credential was in use, it simply stops working everywhere it is checked.

Step 8: Confirm the revocation holds on the next call

Goal

Confirm that once a credential is revoked, a fresh call against it fails on the very first check, with no memory of how many calls came before.

Why this step

Step 7 showed the revocation happening once, inline. The guarantee a production system actually needs is narrower: after revocation, does the next attempt still get caught, even when it comes much later and from a different part of the system, or does something somewhere reset the counter?

Code

Append to the if __name__ == "__main__": block in anomaly.py:

code
    print(f"\ncredential valid now: {store.is_valid('cred-standard')}")    try:        detector.observe("cred-standard", 500)    except CredentialRevoked as e:        print(f"next call after revocation: REJECTED - {e}")

Run it

code
python anomaly.py

Expected output

Running the whole file reprints Step 7's 22 lines first, then a blank line, then two new ones. Only the two new lines are shown here:

code
credential valid now: Falsenext call after revocation: REJECTED - credential 'cred-standard' is not valid

What just happened

A brand new call, sharing nothing with the twenty-one before it except a credential id, is rejected on its first check. AnomalyDetector.observe never has to remember the history that caused the revocation. CredentialStore.is_valid answers in one lookup, which is why the guarantee survives elapsed time and concurrent checkers elsewhere in a real system.

Step 9: Build the manual override

Goal

Build a standing override that halts a thread and can fork its execution back to the last checkpoint that satisfies a caller-supplied safety predicate.

Why this step

LangGraph forks rather than rolls back. Its own documentation is explicit that update_state "does not roll back a thread" - it creates a new checkpoint that branches forward from an existing one, and resuming from a past checkpoint's config does the same: the checkpoints in between are never deleted. "Rewind to the last safe checkpoint" is still the right mental model for what a human using this override wants, even though the mechanism underneath is a fork, not a truncation. ManualOverride below finds a past checkpoint's config and hands it back for you to resume from, and adds a halt flag that every node in a real agent graph can check unconditionally. State Architecture for Agent Networks goes deeper into why resuming a checkpointed run is the dangerous part of durable execution, and Human-in-the-Loop at Production Scale covers what this single-thread halt-and-fork pattern looks like generalized to a fleet of agents rather than one script.

Two mistakes here fail loudly rather than silently, which is worth knowing before you hit them. Call get_state_history or invoke without a thread_id in the config on a checkpointed graph and LangGraph raises ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id. Call any of those methods on a graph compiled without a checkpointer at all and it raises ValueError: No checkpointer set. Both are in the "When it breaks" table below with their fixes.

Code

override.py:

code
from typing import Annotated, TypedDictimport operatorfrom langgraph.graph import StateGraph, START, ENDfrom langgraph.checkpoint.memory import InMemorySaverfrom anomaly import CredentialStore, AnomalyDetector, CredentialRevokedclass RunState(TypedDict):    log: Annotated[list[str], operator.add]    cents_spent: intdef make_refund_node(name: str, cents: int, detector: AnomalyDetector, cred_id: str):    """One node that spends `cents` against `cred_id`, checked through the detector."""    def node(state: RunState) -> dict:        detector.observe(cred_id, cents)        return {            "log": [f"{name}: refunded {cents} cents"],            "cents_spent": state["cents_spent"] + cents,        }    return nodeclass ManualOverride:    """A standing, unconditional control: halt now, then fork execution back to the    last checkpoint that satisfies a caller-supplied safety predicate."""    def __init__(self, app):        self.app = app        self._halted: set = set()    def halt(self, thread_id: str) -> None:        self._halted.add(thread_id)    def is_halted(self, thread_id: str) -> bool:        return thread_id in self._halted    def last_safe_checkpoint(self, thread_id: str, *, is_safe) -> dict:        config = {"configurable": {"thread_id": thread_id}}        history = list(self.app.get_state_history(config))        for snapshot in history:  # reverse chronological, per LangGraph's own docs            if is_safe(snapshot.values):                return snapshot.config        raise LookupError(f"no safe checkpoint found for thread {thread_id}")    def rewind(self, thread_id: str, *, is_safe) -> dict:        safe_config = self.last_safe_checkpoint(thread_id, is_safe=is_safe)        self._halted.discard(thread_id)        return safe_configdef build_refund_graph(detector: AnomalyDetector, cred_id: str):    """Four refund nodes in sequence, each spending 400 cents against cred_id."""    graph = StateGraph(RunState)    for name in ["refund_a", "refund_b", "refund_c", "refund_d"]:        graph.add_node(name, make_refund_node(name, 400, detector, cred_id))    graph.add_edge(START, "refund_a")    graph.add_edge("refund_a", "refund_b")    graph.add_edge("refund_b", "refund_c")    graph.add_edge("refund_c", "refund_d")    graph.add_edge("refund_d", END)    return graph.compile(checkpointer=InMemorySaver())

ManualOverride.halt and is_halted belong at the top of every node in a real agent graph. A node that runs if override.is_halted(thread_id): return {"messages": []} before doing anything else honors the halt unconditionally, the same way the interceptor honors its policy. The check is Python the developer wrote, not a judgment the model gets to make.

Run it

Append this to the bottom of override.py. build_refund_graph gives you something to exercise the override against: four nodes that each spend 400 cents through Step 7's AnomalyDetector, against a 1,000-cent cumulative limit.

code
if __name__ == "__main__":    store = CredentialStore()    store.grant("cred-standard")    detector = AnomalyDetector(store, cumulative_limit=1_000)    app = build_refund_graph(detector, "cred-standard")    override = ManualOverride(app)    config = {"configurable": {"thread_id": "acct-9"}}    try:        app.invoke({"log": [], "cents_spent": 0}, config)        print("ERROR: refund_d should have been blocked")    except CredentialRevoked as e:        snap = app.get_state(config)        print(f"run halted before {snap.next!r}: {e}")        print(f"cents_spent when it halted: {snap.values['cents_spent']}")    print(f"credential valid after the halt: {store.is_valid('cred-standard')}")    override.halt("acct-9")    print(f"override has halted this thread: {override.is_halted('acct-9')}")
code
python override.py

Expected output

code
run halted before ('refund_d',): credential 'cred-standard' is not validcents_spent when it halted: 1200credential valid after the halt: Falseoverride has halted this thread: True

What just happened

refund_a and refund_b spend 400 cents each, for a total of 800 against the 1,000 limit. refund_c spends a third 400, pushes the total to 1,200, and completes anyway, on exactly Step 7's timing: the revocation fires at the end of that call, not before it. refund_d is the one rejected, on its very first check, because it runs after the credential is already gone. override.halt("acct-9") then sets the flag a real agent node would check before taking another step - is_halted reports True because nothing has cleared it yet. What is left is a halted thread, a revoked credential, and a state history that remembers every step of how it got there. That is the view a human reaching for the override needs.

Step 10: Prove the override cannot resurrect authority

Goal

Use the override to find the last checkpoint before refund_c ran, fork execution from there, and confirm the credential Step 9 revoked is still revoked - even though the fork's own state predates the revocation entirely.

Why this step

ACRFence (Zheng, Yang, Zhang & Quinn, arXiv:2603.20625) names this failure mode Authority Resurrection. Rewind an agent to a checkpoint from before a credential was spent, and if the credential's state lives inside that checkpoint, the agent now holds it again with no memory of ever having used it. Step 7 put CredentialStore outside graph state for exactly this reason. This is where that decision either holds up or does not.

Code

Append to the if __name__ == "__main__": block in override.py:

code
    is_safe = lambda v: v.get("cents_spent", 0) <= 800    safe_before_c = override.last_safe_checkpoint("acct-9", is_safe=is_safe)    safe_snap = app.get_state(safe_before_c)    print(f"last safe checkpoint: next={safe_snap.next!r}, "          f"cents_spent={safe_snap.values['cents_spent']}")    fork_config = override.rewind("acct-9", is_safe=is_safe)    print(f"the fork cleared the halt: {not override.is_halted('acct-9')}")    try:        app.invoke(None, fork_config)        print("ERROR: refund_c should have been blocked by the still-revoked credential")    except CredentialRevoked as e:        print(f"resumed - refund_c blocked on the FIRST try: {e}")        print("the fork predates the revocation and has no memory of it -")        print("only the external CredentialStore does, which catches the retry")

override is the same instance Step 9 built and halted - this step reuses it rather than constructing a new one, so the halt it set is still there to clear.

Run it

code
python override.py

Expected output

Running the whole file again reprints Step 9's four lines first, then these:

code
last safe checkpoint: next=('refund_c',), cents_spent=800the fork cleared the halt: Trueresumed - refund_c blocked on the FIRST try: credential 'cred-standard' is not validthe fork predates the revocation and has no memory of it -only the external CredentialStore does, which catches the retry

What just happened

rewind clears the halt on its way out, and the second printed line confirms it: the halt is what freezes a thread while a human decides what to do, and forking to a safe checkpoint is that decision, so the flag has done its job by the time the fork exists. A design that wanted the thread to stay frozen after the fork would simply not call self._halted.discard(...) inside rewind.

The override walks the history back to the last checkpoint where cents_spent was still at or under 800, the state right before refund_c ran, and forks execution from there. Passing None as the input to app.invoke is what makes this a resume rather than a fresh run - LangGraph takes its starting state from the checkpoint named inside fork_config instead of from a state dict, and continues from that checkpoint's next node onward. Had the credential's validity been part of RunState, that fork would have restored a cents_spent of 800 and, with it, whatever "still valid" the graph believed at that point. Authority Resurrection, exactly as ACRFence describes it. What happens instead is that refund_c fails on replay, immediately, because CredentialStore was never part of what the fork restored. Step 7's design decision is doing that work, not anything about the fork mechanism.

It is easy to assume the fork deleted the bad history. It did not. On this thread, len(list(app.get_state_history(config))) reads 5 right before the forked app.invoke(None, fork_config) and 6 right after it returns - rewind() itself adds no checkpoint, only the resumed invoke does, and only one, not fewer. The original run's checkpoints, including the ones after refund_c, are all still on record. "Rewind" describes accurately enough what this does to the thread you resume from next. It describes nothing about the record, which LangGraph never deletes, and an audit trail that wants to explain why a thread halted has to read the full history rather than the branch that won.

Step 11: Wire the four mechanisms into a LangGraph kill switch and audit the result

Goal

Combine the interceptor, watchdog, anomaly detector, and manual override into a single KillSwitch, run a scripted demonstration of all four firing, and audit the result against the four trigger classes this tutorial opened with: an ungranted call, a cumulative policy violation, a behavioral loop, and a human-issued halt.

Why this step

Four mechanisms that are never checked against each other are four separate scripts, not a kill switch. This step is where they become one object with one audit method. And in the closing code block, the scripted stand-ins from Steps 2 through 10 give way to the real agent from Step 1.

Code

kill_switch.py:

code
import multiprocessing as mpfrom policy import ToolCallInterceptor, ToolPolicy, PolicyViolation, make_callfrom watchdog import Watchdog, runaway_demofrom anomaly import CredentialStore, AnomalyDetector, CredentialRevokedfrom override import ManualOverride, build_refund_graphclass KillSwitch:    def __init__(self, interceptor: ToolCallInterceptor, watchdog: Watchdog,                 detector: AnomalyDetector, override: ManualOverride | None = None):        self.interceptor = interceptor        self.watchdog = watchdog        self.detector = detector        self.override = override        self.audit_log: list[dict] = []    def _record(self, trigger: str, detail: str) -> None:        self.audit_log.append({"trigger": trigger, "detail": detail})    def guard_call(self, tool_call: dict, credential_id: str) -> None:        """The exceeds-authority boundary: rule-based per call, then cumulative."""        try:            self.interceptor.check(tool_call)        except PolicyViolation as e:            self._record("exceeds_authority", str(e))            raise        cents = tool_call.get("args", {}).get("cents", 0)        try:            self.detector.observe(credential_id, cents)        except CredentialRevoked as e:            self._record("exceeds_authority_cumulative", str(e))            raise    def watch_process(self, process: mp.Process, heartbeats) -> dict:        verdict = self.watchdog.watch(process, heartbeats)        if verdict["verdict"] == "killed":            self._record("unexpected_behaviour", verdict["reason"])        return verdict    def audit(self) -> dict:        """Which of the four trigger classes has a mechanism behind it?"""        return {            "exceeds_authority (per-call)": self.interceptor is not None,            "exceeds_authority (cumulative)": self.detector is not None,            "unexpected_behaviour (loop)": self.watchdog is not None,            "explicit_safety_trigger (manual)": self.override is not None,        }

Run it

Append this to the bottom of kill_switch.py:

code
def demo_exceeds_authority(ks: KillSwitch) -> None:    granted = make_call("toolu_d1", "issue_refund", ticket_id="8812", cents=30_000)    ks.guard_call(granted, "cred-demo")    print("[interceptor] ALLOWED issue_refund(ticket_id=8812, cents=30000)")    overreach = make_call("toolu_d2", "issue_refund", ticket_id="8812", cents=1_200_000)    try:        ks.guard_call(overreach, "cred-demo")    except PolicyViolation as e:        print(f"[interceptor] BLOCKED {e}")def demo_runaway_loop(ks: KillSwitch) -> None:    heartbeats = mp.Queue()    process = mp.Process(target=runaway_demo, args=(heartbeats,))    process.start()    verdict = ks.watch_process(process, heartbeats)    print(f"[watchdog] KILLED pid={process.pid} after {verdict['steps']} steps "          f"({verdict['reason']}), exitcode={verdict['exitcode']}")def demo_cumulative_violation(ks: KillSwitch) -> None:    ks.detector.store.grant("cred-cumulative")    for i in range(1, 30):        call = make_call(f"toolu_c{i}", "issue_refund", ticket_id="9001", cents=500)        try:            ks.guard_call(call, "cred-cumulative")        except CredentialRevoked:            print(f"[anomaly] REVOKED cred-cumulative after {i - 1} calls")            returndef demo_manual_override() -> None:    store = CredentialStore()    store.grant("cred-override")    detector = AnomalyDetector(store, cumulative_limit=1_000)    app = build_refund_graph(detector, "cred-override")    config = {"configurable": {"thread_id": "demo-4"}}    try:        app.invoke({"log": [], "cents_spent": 0}, config)    except CredentialRevoked:        pass    is_safe = lambda v: v.get("cents_spent", 0) <= 800    override = ManualOverride(app)    override.halt("demo-4")    override.rewind("demo-4", is_safe=is_safe)    print("[override] HALTED thread demo-4, found the safe checkpoint before refund_c")    history = list(app.get_state_history(config))    print(f"[override] {len(history)} checkpoints on record - none deleted, "          f"the fork is a new branch")if __name__ == "__main__":    interceptor = ToolCallInterceptor([        ToolPolicy("search_tickets", lambda a: True, "search is always allowed"),        ToolPolicy(            "issue_refund",            lambda a: a.get("cents", 0) <= 50_000,            "refunds over 50000 cents (500 dollars) need a human",        ),    ])    store = CredentialStore()    store.grant("cred-demo")    detector = AnomalyDetector(store, cumulative_limit=10_000)    watchdog = Watchdog(repeat_threshold=5)    # override=object() is a stub, not a real ManualOverride - see "What just    # happened" below for what that does and does not prove.    ks = KillSwitch(interceptor, watchdog, detector, override=object())    demo_exceeds_authority(ks)    demo_runaway_loop(ks)    demo_cumulative_violation(ks)    demo_manual_override()    print()    print("AUDIT:")    for trigger, has_mechanism in ks.audit().items():        print(f"  {trigger}: {'covered' if has_mechanism else 'GAP'}")
code
python kill_switch.py

Expected output

code
[interceptor] ALLOWED issue_refund(ticket_id=8812, cents=30000)[interceptor] BLOCKED issue_refund: refunds over 50000 cents (500 dollars) need a human[watchdog] KILLED pid=29368 after 5 steps (repeated call, no new state), exitcode=-15[anomaly] REVOKED cred-cumulative after 21 calls[override] HALTED thread demo-4, found the safe checkpoint before refund_c[override] 5 checkpoints on record - none deleted, the fork is a new branchAUDIT:  exceeds_authority (per-call): covered  exceeds_authority (cumulative): covered  unexpected_behaviour (loop): covered  explicit_safety_trigger (manual): covered

This is the transcript from the top of this tutorial - you have now built every line that produced it. As in Step 5, pid will differ on your machine and even between your own runs; unlike pid, the step count, the "21 calls", and the "5 checkpoints" are deterministic for this exact script, so a mismatch on any of those is worth investigating.

What just happened

Four independently-built mechanisms ran against four independently-scripted failure modes, in one process, and the audit shows a working mechanism behind each of the four trigger classes this tutorial opened with. Look at override=object() in the setup, though. The audit only checks that something is wired into the override slot, not that the thing works. That is the honest limit of a presence check, and a real audit would go further - the gap is the second item under "Where to go next."

Wiring the real agent (needs an Anthropic API key)

Everything above proves the four mechanisms work against scripted inputs. This closing block attaches them to the actual ChatAnthropic-backed agent from Step 1 - and, unlike the rest of this tutorial's live-agent framing in earlier drafts, this one was executed for real, against claude-sonnet-5, with a working ANTHROPIC_API_KEY. The result changed what this section says, so read the transcript below before you run it yourself.

live_demo.py:

code
import multiprocessing as mpfrom langchain_core.messages import HumanMessage, ToolMessagefrom langgraph.checkpoint.memory import InMemorySaverfrom agent import build_agent, TOOLS_BY_NAMEfrom policy import ToolCallInterceptor, ToolPolicy, PolicyViolationfrom watchdog import Watchdog, Heartbeatfrom anomaly import CredentialStore, AnomalyDetector, CredentialRevokedfrom kill_switch import KillSwitchdef guarded_tools_node(ks: KillSwitch, credential_id: str, heartbeats):    def run_tools(state):        last = state["messages"][-1]        outputs = []        for step, tc in enumerate(last.tool_calls, start=1):            heartbeats.put(Heartbeat(                step=step, tool_name=tc["name"], tool_args=tc["args"], new_state=True,            ))            try:                ks.guard_call(tc, credential_id)            except (PolicyViolation, CredentialRevoked) as e:                outputs.append(ToolMessage(content=f"blocked: {e}",                                            tool_call_id=tc["id"]))                continue            result = TOOLS_BY_NAME[tc["name"]].invoke(tc["args"])            outputs.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))        return {"messages": outputs}    return run_toolsdef run_guarded_agent(prompt: str, heartbeats) -> None:    """Runs inside its own OS process, so the watchdog can kill it from outside."""    interceptor = ToolCallInterceptor([        ToolPolicy("search_tickets", lambda a: True, "search is always allowed"),        ToolPolicy(            "issue_refund",            lambda a: a.get("cents", 0) <= 50_000,            "refunds over 50000 cents (500 dollars) need a human",        ),    ])    store = CredentialStore()    store.grant("cred-live")    detector = AnomalyDetector(store, cumulative_limit=10_000)    # Watchdog() here is a placeholder satisfying KillSwitch's constructor - this    # child process never calls watch_process itself. The Watchdog that actually    # watches this process is the second one, built in __main__ below.    ks = KillSwitch(interceptor, Watchdog(), detector)    tools_node = guarded_tools_node(ks, "cred-live", heartbeats)    app = build_agent(checkpointer=InMemorySaver(), tools_node=tools_node)    config = {"configurable": {"thread_id": "live-1"}}    app.invoke({"messages": [HumanMessage(prompt)]}, config)if __name__ == "__main__":    heartbeats = mp.Queue()    process = mp.Process(        target=run_guarded_agent,        args=("Refund ticket 8812 for $12,000.", heartbeats),    )    process.start()    watchdog = Watchdog(repeat_threshold=5, step_budget=50)    verdict = watchdog.watch(process, heartbeats)    print(verdict)

Run it

code
python live_demo.py

Expected output

code
{'verdict': 'completed', 'steps': 2}

What just happened

steps: 2 means the agent made two tool calls before the graph ended on its own - and both of them were search_tickets, not issue_refund. The full transcript shows why: the model searched for ticket #8812 twice, found nothing that corroborated a $12,000 refund, and stopped to ask for confirmation instead of calling issue_refund at all. The interceptor never fired, because nothing ever reached it - claude-sonnet-5 declined the risky action on its own, unprompted by any safety code in this tutorial.

That result held up under real pressure, not just the polite version of the prompt. Three further live runs, not part of live_demo.py itself, tried harder: a direct instruction to skip verification and call issue_refund immediately, a social-engineered message claiming prior manager approval, and a prompt-injection attempt where the fabricated approval was planted inside search_tickets's own tool output instead of the user's message - the more realistic version of the attack, since a model is generally less suspicious of what a tool tells it than what a user asks for directly. All three were declined. The tool-injection run is the one worth quoting, because the model named the attack pattern explicitly rather than just refusing:

I don't act on instructions embedded in data sources; I only act on instructions from you.

Four for four, this is a genuinely good result for the model's alignment, and it is exactly why this tutorial does not lean on it. Every one of those refusals depended on claude-sonnet-5, on this exact prompt, on a conversation short enough that a planted instruction was easy to spot, and on training that happened to hold on this question today. A weaker model, a longer conversation that dilutes the system prompt's authority, a subtler injection, or next month's model would not come with the same guarantee - and the interceptor's guarantee was never supposed to depend on any of that. It blocks the call because the call would have failed a rule, not because it trusts the model to have already decided not to make it. The four mechanisms in this tutorial exist for the run where the model's own judgment is the thing that fails.

When it breaks

StepVerbatim errorCauseFix
5AttributeError: Can't get local object 'make_target.<locals>.runaway_agent_local'The watchdog's target function was a closure or a lambda, not importable by nameDefine the target at module level, as runaway_demo is in watchdog.py
9ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_idapp.invoke() was called without a thread_id in the config, on a graph compiled with a checkpointerAlways pass {"configurable": {"thread_id": "..."}} when the graph has a checkpointer
9ValueError: No checkpointer setget_state, get_state_history, or update_state was called on a graph compiled with graph.compile() and no checkpointer= argumentPass checkpointer=InMemorySaver() (or a durable backend) to compile()
4, live agentlanggraph.errors.GraphRecursionError: Recursion limit of N reached without hitting a stop condition.The graph's own step budget was reached - this is recursion_limit, not the watchdog, and it is raised inside the agent's processCatch it if you want a soft stop; remember it resets on every resume (see Step 4's link), so it is not a substitute for the watchdog
11, live agentanthropic.AuthenticationError / langchain_core.exceptions.ModelAuthenticationError: ... (HTTP 401)ANTHROPIC_API_KEY is unset or invalidSet a valid key; langchain-anthropic 1.6.0+ raises the portable ModelAuthenticationError, which is also an anthropic.AuthenticationError by inheritance - catch either
11, live agentlangchain_core.exceptions.ModelRateLimitError: ... (HTTP 429)Too many concurrent or per-minute requests to the Anthropic APIBack off and retry; is_retryable is True on this exception class specifically
11, live agentAnthropicInvalidRequestError: Error code: 400 - ... 'temperature' is deprecated for this model.ChatAnthropic(model="claude-sonnet-5", temperature=0) - this model rejects an explicit temperature, hit live while verifying this tutorialDrop temperature=0 entirely; agent.py above already omits it

The LangGraph kill switch architecture

mermaid
flowchart TB
    subgraph PROC["one OS process"]
        direction TB
        AGENT["agent node<br/>(ChatAnthropic)"] --> ROUTE{"tool call?"}
        ROUTE -->|yes| GATE["tool-call interceptor<br/>+ anomaly detector"]
        ROUTE -->|no| DONE["END"]
        GATE -->|allowed| TOOLS["tools node<br/>(executes the call)"]
        GATE -->|blocked| BACK["blocked ToolMessage"]
        TOOLS --> AGENT
        BACK --> AGENT
    end

    WATCHDOG["watchdog<br/>(separate process)"] -.->|heartbeats| GATE
    WATCHDOG ==>|kill -15| PROC

    STORE[("credential store<br/>(outside graph state)")]
    GATE <-->|check / revoke| STORE

    HUMAN["manual override"] -.->|halt flag,<br/>checked every node| AGENT
    HUMAN ==>|fork to last<br/>safe checkpoint| CKPT[("checkpointer<br/>(InMemorySaver)")]
    CKPT -.->|state history| PROC

    style AGENT fill:#38BDF8,color:#2C2C2A
    style ROUTE fill:#FACC15,color:#2C2C2A
    style GATE fill:#FB923C,color:#2C2C2A
    style TOOLS fill:#4ADE80,color:#2C2C2A
    style BACK fill:#F87171,color:#FFFFFF
    style DONE fill:#4ADE80,color:#2C2C2A
    style WATCHDOG fill:#F87171,color:#FFFFFF
    style STORE fill:#6F00FF,color:#FFFFFF
    style HUMAN fill:#4B0082,color:#FFFFFF
    style CKPT fill:#6F00FF,color:#FFFFFF
    style PROC fill:#ffffff,color:#2C2C2A,stroke:#2C2C2A

Solid arrows stop execution outright: the watchdog's kill, the override's fork. Dashed arrows are information crossing a boundary without stopping anything by itself, like a heartbeat, a halt flag being read, or state history being consulted. The credential store and the checkpointer sit outside the process box on purpose. Code running inside the process consults both, and nothing that happens inside the process restores or resets either one, which is the property Step 10 depends on.

The complete artifact

code
killswitch-tutorial/├── agent.py           # Step 1 + Step 11: the LangGraph agent, ChatAnthropic-backed├── step1_run.py       # Step 1: proves the routing logic with no API key├── policy.py          # Steps 2-3: ToolCallInterceptor, ToolPolicy, PolicyViolation├── watchdog.py        # Steps 4-6: Heartbeat, Watchdog, runaway_demo├── wrong_way.py       # Step 5: the pickling failure, kept as a reference├── prove_boundary.py  # Step 6: the interceptor's blind spot├── anomaly.py         # Steps 7-8: CredentialStore, AnomalyDetector├── override.py        # Steps 9-10: ManualOverride, build_refund_graph├── kill_switch.py     # Step 11: KillSwitch, the scripted demo└── live_demo.py       # Step 11: the real agent (needs a key)

agent.py, policy.py, watchdog.py, anomaly.py, override.py, and kill_switch.py appear above in full, in the order they are built. step1_run.py, wrong_way.py, prove_boundary.py, and live_demo.py are complete as shown too. There is nothing left to fill in.

Where to go next

  • Swap InMemorySaver for a durable checkpointer. Its own docstring is explicit: "When the process restarts, all checkpoints are lost." langgraph-checkpoint-sqlite for local development, langgraph-checkpoint-postgres for anything that has to survive a restart - the ManualOverride code in Steps 9-10 does not change at all, only which class you pass to compile(checkpointer=...).
  • Make KillSwitch.audit() check that the override actually works, not just that a slot is filled. This tutorial's audit() only confirms self.override is not None. A real audit would drive a scripted halt and rewind through every registered thread and confirm the fork actually lands on a safe checkpoint, not just that a slot is filled.
  • Extend the interceptor past the tool boundary. A 2026 paper on OS-level agent enforcement, ActPlane (Zheng et al., arXiv:2606.25189), measures that tool-layer guardrails miss an indirect subprocess, shell-out, or compiled binary the agent invokes on its own - anything that does not go through run_tools at all. The interceptor in this tutorial has exactly that blind spot.
  • Give the credential store real teeth. CredentialStore here is an in-memory dict for the same reason InMemorySaver is - it makes the tutorial runnable without external services. A production version revokes against a real identity provider or a short-lived-token issuer, which is also what the 2026 multi-nation Careful Adoption of Agentic AI Services guidance (CISA, NSA, ASD/ACSC, CCCS, NCSC-NZ, NCSC-UK) names as its most demanding technical requirement.

References


AI Engineering

Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

Books by Ranjan Kumar

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments