LangChain

cli_sdk.integrations.langchain puts a calibrated check in front of the tools of a LangChain 1.x agent built with create_agent. You pass middleware=cli_middleware(guard), and every call the model proposes to a guarded tool is allowed, sent to a human through LangChain’s own HumanInTheLoopMiddleware, or refused with an error ToolMessage. Each decision carries a finite-sample guarantee on data like your calibration set, and anything the calibration cannot vouch for goes to a person. Tools without a rule, such as read-only lookups, run as usual.

Why calibrate the threshold

The usual guardrail is a confidence threshold picked by hand (“auto-approve refunds above 0.8”). Nobody can say how many auto-approved refunds that number lets through wrong, and its meaning changes silently when the model, the prompt or the traffic changes.

The refunds example below makes this concrete. On its 320 labelled (synthetic) refund decisions, a hand-set threshold of 0.80 would auto-approve 183 requests, 20 of them wrong. The conformal risk control bound for that threshold is (20 + 1) / 321 = 0.065, above the 0.05 target, and nothing in the code would show it. The calibrated threshold is 0.863: the lowest threshold whose bound is at most 0.05 (178 approved, 15 wrong, bound 0.0498). It comes with the statement “Expected rate of decisions that are auto-approved and wrong is at most 0.05”. It is recomputed whenever you recalibrate, and it is tied to the evidence model and prompt it was computed with, so a change makes the profile stale and every guarded call goes to a human until you recalibrate.

A guarantee bounds a rate over many decisions on data exchangeable with the calibration set. It never makes a single refund or a single triage reply correct. Read “at most 5% of requests like these are auto-approved and wrong”, never “this approval is safe”. See Guarantees.

Install

pip install "cci-sdk[langchain]"                                  # mock mode: offline, keyless
pip install "cci-sdk[langchain,openai]" langchain-openai          # OpenAI, Azure OpenAI, Gemma on vLLM or SGLang
pip install "cci-sdk[langchain,anthropic]" langchain-anthropic    # Claude as agent and evidence model
pip install "cci-sdk[langchain,openai]" langchain-google-genai    # Gemini (the Gemini evidence backend uses the openai SDK)

The langchain extra installs langchain, langchain-core and langgraph (1.x). The provider packages (langchain-openai, langchain-anthropic, langchain-google-genai) are for the agent model. The openai and anthropic extras are for the evidence model. The framework packages need Python 3.10 or later.

How decisions map onto LangChain

cli_middleware(guard) returns two middleware for create_agent: HumanInTheLoopMiddleware, configured with the guard, and CLIGuardMiddleware. Across one model turn they run in this order:

  1. Score once. CLIGuardMiddleware.after_model scores every guarded call in the new AIMessage with guard.check(...) and stores each decision in the agent state under cli_guard, keyed by tool call id. The state is checkpointed, so the decision survives the pause.
  2. Pause if escalated. HumanInTheLoopMiddleware.after_model runs next. Its when predicate reads the stored decision, so only escalated calls interrupt. All escalated calls from one turn go into one interrupt.
  3. Enforce at execution. CLIGuardMiddleware.wrap_tool_call refuses blocked calls, and in batch mode escalated ones, before the tool runs.
Guard decisioncli_middleware(guard) (human review on)cli_middleware(guard, human_review=False) (batch jobs)
allowNo interrupt. The tool runs.The tool runs.
escalateThe run pauses on a HumanInTheLoopMiddleware interrupt. pending_reviews(result) lists the actions with a review card. Resume with Command(resume={"decisions": [...]}): approve runs the call. edit runs the reviewer’s arguments as written. reject returns an error ToolMessage (User rejected the tool call ...) and the tool does not run.The tool does not run. The agent gets an error ToolMessage with "status": "needs_human_review" and the decision under "cli", which you can queue.
blockNever interrupts. The tool does not run. The agent gets an error ToolMessage with "status": "blocked".Same.

A local-mode Gate either auto-approves or escalates. block comes from Set or Interval rules with block_labels or block_levels, from a hosted Gate that abstains, and from on_heuristic="block". An uncalibrated (heuristic) answer never allows.

Set up a guarded agent

Choose the agent model

The agent model proposes tool calls. The examples build it with chat_model(provider) in examples/agents/_shared/langchain_models.py, which reads the model, key and endpoint from environment variables (see examples/agents/.env.example). While a key still holds its YOUR_..._API_KEY placeholder, the example stops with a clear message before any network call. Every code block below is the branch of chat_model for that provider; s holds the settings from the environment.

export OPENAI_API_KEY=...            # your key
export OPENAI_MODEL=gpt-4.1-mini     # optional; this is the default
python examples/agents/langchain/finance_refund_agent.py --provider openai
if provider == "openai":
    from langchain_openai import ChatOpenAI
 
    return ChatOpenAI(model=s.model, api_key=s.api_key, base_url=s.base_url, temperature=0,
                      use_responses_api=False, **overrides)

Choose the evidence model and calibrate

The evidence model scores each proposed guarded call. It is the model the calibration describes, and it can differ from the agent model (--evidence-provider). There are two access levels in local mode:

Access levelHow a score is madeProvidersRequests to calibrate 320 examplesRequests per guarded call
L1, log-probabilitiesOne request; the first-token probabilities of the option lettersOpenAI and Azure OpenAI non-reasoning models (for example gpt-4.1-mini), Gemma on vLLM or SGLang3201
L0, samplingsample_count sampled answers, turned into smoothed frequenciesAnthropic Claude, Gemini (the examples use sample_count=8)320 x 8 = 2,5608

At L0 a score can take at most sample_count + 1 distinct values, so the calibrated threshold is coarser and more calls escalate than with an L1 model. Calibration runs once per evidence model. The profile is a JSON file in the store directory, fingerprinted with the evidence model, its settings and the query’s prompt. A mismatch makes the guard fail closed: every guarded call escalates until you recalibrate. The examples detect a stale profile and rebuild it automatically; --recalibrate forces a rebuild. See Bring your own model for every backend and Local mode for profile files and sizes.

The guarantee holds only for the scoring function it was calibrated with. The guard must therefore see exactly the context the calibration examples were built with. Both come from one function, refund_context in data/generate_refund_requests.py:

def refund_context(order: dict[str, Any], amount: float, reason: str) -> dict[str, Any]:
    """The evaluation context for one proposed refund: calibration and inference both use this."""
    return {
        "policy": REFUND_POLICY,
        "order": dict(order),
        "proposed_refund": {"amount": round(float(amount), 2), "reason": reason},
    }

The example defines the query, the guard’s context builder, and the calibration step (from finance_refund_agent.py):

REFUND_GATE = Gate(
    instructions=(
        "Under the refund policy in the context, is issuing the proposed refund for this order correct? "
        "Answer true only if every rule of the policy holds for the order record and the proposed refund."
    ),
    calibration_profile="refund-approvals-v1",
    guarantee="risk",
    target=0.05,
)
def refund_guard_context(args: dict[str, Any]) -> dict[str, Any]:
    """What the guard evaluates for a proposed issue_refund call.
 
    The order comes from the order system, not from the agent, and the
    context is built by the same function as every calibration example.
    An unknown order raises, and the guard then escalates (fails closed).
    """
    order = ORDERS.get(args["order_id"])
    if order is None:
        raise LookupError(f"no order {args['order_id']!r} in the order system")
    return refunds.refund_context(order, args["amount"], args["reason"])
client = LocalCLIClient(evidence, store=calibration.default_store(__file__, args.store),
                        sample_count=providers.sample_count(evidence_provider))
cached, _ = client.calibration_status(REFUND_GATE)
examples = calibration.load_jsonl("refund_requests.jsonl")
try:
    (profile,) = calibration.ensure_calibrated(client, [REFUND_GATE], examples, recalibrate=args.recalibrate)
except BackendError as exc:  # e.g. the vLLM / SGLang server is not running
    raise SystemExit(f"could not score the calibration set with --evidence-provider {evidence_provider}: "
                     f"{exc}") from exc

Here evidence = providers.evidence_backend(evidence_provider, mock_scorer=refund_scorer). With --provider mock that is a MockEvidenceBackend driven by a deterministic scoring function that reads the order the way a fallible model would. It never sees a label.

Wire the guard

One GuardRule per risky tool, and the guard as middleware. Interrupts need a checkpointer and a thread_id:

def build_guard(client: LocalCLIClient) -> ToolGuard:
    return ToolGuard(client, [GuardRule(tool="issue_refund", query=REFUND_GATE, context=refund_guard_context)])
def build_agent(model: Any, guard: ToolGuard, ledger: list[dict[str, Any]], *, human_review: bool = True,
                checkpointer: Any = None) -> Any:
    """``create_agent`` with the calibrated guard as middleware.
 
    Interrupts need a checkpointer. ``InMemorySaver`` is for a single
    process; in production use a durable checkpointer shared by every worker
    (the guard's decisions are stored in the checkpointed state, so a resume
    on another worker honours the decision the reviewer saw).
    """
    return create_agent(
        model,
        tools=make_tools(ledger),
        system_prompt=SYSTEM_PROMPT,
        middleware=cli_middleware(guard, human_review=human_review),
        checkpointer=checkpointer if checkpointer is not None else InMemorySaver(),
    )

lookup_order has no rule, so it always runs. issue_refund is checked before every execution.

Handle reviews and resume

Invoke with a thread_id. While pending_reviews(result) is non-empty, collect one decision per pending action, in order, and resume on the same thread with Command(resume={"decisions": [...]}). The guard’s decisions are in the checkpointed state; guard_decisions(result) returns them keyed by tool call id. From finance_refund_agent.py:

def run_scenario(agent: Any, scenario: Scenario, reviewer: Reviewer, edits: dict[str, dict[str, Any]],
                 ledger: list[dict[str, Any]], thread: str) -> Outcome:
    outcome = Outcome(scenario.case_id)
    already = len(ledger)
    config = {"configurable": {"thread_id": thread}}
    result = agent.invoke({"messages": [HumanMessage(scenario.message)]}, config, version="v2")
    shown = show_messages(result, 0, outcome)
    for _ in range(5):  # a real agent may propose more than one guarded call
        pending = pending_reviews(result)
        if not pending:
            break
        print("    paused:   HumanInTheLoopMiddleware interrupt; the run waits for a reviewer")
        decisions = [review(scenario, action, stored_decision_for(result, action), reviewer, edits, outcome)
                     for action in pending]
        _wrap("    resume:   ", f"Command(resume={json.dumps({'decisions': decisions})})")
        if any(d["type"] == "edit" for d in decisions):
            _wrap("    note:     ", "a reviewer's edit is an explicit human approval: the edited call runs as "
                  "written and is not scored again")
        result = agent.invoke(Command(resume={"decisions": decisions}), config, version="v2")
        shown = show_messages(result, shown, outcome)
    outcome.refunds_issued = ledger[already:]
    return outcome

review returns one of the three HumanInTheLoopMiddleware decision shapes that cli_middleware allows by default (approve, edit, reject). The reviewer sees the decision stored for the call: action, reason and guarantee statement:

def review(scenario: Scenario, action: dict[str, Any], decision: dict[str, Any], reviewer: Reviewer,
           edits: dict[str, dict[str, Any]], outcome: Outcome) -> dict[str, Any]:
    """Turn a pending action into a HumanInTheLoopMiddleware decision (approve / edit / reject)."""
    note = REVIEW_NOTES.get(scenario.case_id, "Declined by the reviewer.")
    if not reviewer.interactive and scenario.case_id in edits:
        print(f"    review request for {scenario.case_id}:")
        for line in format_request(decision).splitlines():
            print(f"      {line}")
        new_args = {**action["args"], **edits[scenario.case_id]}
        changes = ", ".join(f"{k} {action['args'].get(k)} -> {v}" for k, v in edits[scenario.case_id].items())
        _wrap(f"    [simulated {reviewer.role}] ", f"edited {changes}. {note}")
        outcome.reviews.append("edit")
        return {"type": "edit", "edited_action": {"name": action["name"], "args": new_args}}
    if reviewer.decide(scenario.case_id, decision):
        outcome.reviews.append("approve")
        return {"type": "approve"}
    outcome.reviews.append("reject")
    return {"type": "reject", "message": note}

In production the pause can last hours and the resume can run in another process. That works with a durable checkpointer, because the decision the reviewer saw is read back from the checkpoint rather than recomputed.

Example: refunds agent (finance)

examples/agents/langchain/finance_refund_agent.py is a card and e-commerce refunds assistant. It looks the order up with lookup_order, which is unguarded, and proposes issue_refund(order_id, amount, reason), which is guarded by Gate(guarantee="risk", target=0.05). The Gate is calibrated with conformal risk control on 320 synthetic labelled decisions from data/refund_requests.jsonl. Those labels come from a written policy covering the window, the amount, the category, one refund per order, fraud holds and photo evidence, plus manager-decided exceptions and 2% label noise, generated by data/generate_refund_requests.py.

git clone https://github.com/rahvis/cci-sdk && cd cci-sdk
python examples/agents/langchain/finance_refund_agent.py                     # mock: offline, keyless
python examples/agents/langchain/finance_refund_agent.py --no-human-review   # batch-job mode
python examples/agents/langchain/finance_refund_agent.py --interactive       # you are the reviewer

Output in mock mode (the first run also calibrates the profile, in about a second; the lookup results of R-1002 and R-1003 are elided):

==============================================================================
LangChain refunds agent: calibrated guard on issue_refund
==============================================================================
Synthetic data for demonstration only. Not financial, credit or legal advice.
Calibrated guarantees support, but do not replace, your model-risk-management,
fair-lending and compliance review.

agent model: mock   evidence model: mock   human review: on (HumanInTheLoopMiddleware)
profile refund-approvals-v1: CRC, n=320 synthetic labelled decisions, status serving (calibrated on this run)
------------------------------------------------------------------------------
R-1001: Damaged mug, small refund, well inside the window
    customer: Order ORD-90101 arrived with a cracked mug. I uploaded a photo.
              Can I get the $14.99 back?
    agent:    proposes lookup_order(order_id="ORD-90101")
    tool:     [success] {"amount_paid": 19.98, "category": "home_goods",
                        "customer_tier": "standard", "delivered_days_ago": 3,
                        "delivery_status": "delivered", "final_sale": false,
                        "item_price": 14.99, "open_chargeback": false,
                        "open_fraud_flag": false, "order_id": "ORD-90101",
                        "payment_method": "card", "photo_on_file": true,
                        "purchased_days_ago": 7, "refunded_to_date": 0.0,
                        "shipping_fee": 4.99}
    agent:    proposes issue_refund(amount=14.99, order_id="ORD-90101",
              reason="damaged_item")
    guard:    ALLOW: auto-approved under the calibrated risk bound (confidence
              0.967, calibrated threshold 0.863).
              guarantee: Expected rate of decisions that are auto-approved and
              wrong is at most 0.05 (conformal risk control, n=320).
    tool:     [success] Refund of $14.99 issued to the card on ORD-90101
                        (reason: damaged_item).
    agent:    "Done. Refund of $14.99 issued to the card on ORD-90101 (reason:
              damaged_item)."
------------------------------------------------------------------------------
R-1002: Change-of-mind return near the end of the window, shipping included
    customer: I changed my mind about the coat from order ORD-90106. Please
              refund everything I paid, $181.99.
    agent:    proposes lookup_order(order_id="ORD-90106")
    [...]
    agent:    proposes issue_refund(amount=181.99, order_id="ORD-90106",
              reason="changed_mind")
    guard:    ESCALATE: below the calibrated auto-approval threshold
              (confidence 0.548, calibrated threshold 0.863).
              guarantee: Expected rate of decisions that are auto-approved and
              wrong is at most 0.05 (conformal risk control, n=320).
    paused:   HumanInTheLoopMiddleware interrupt; the run waits for a reviewer
    review request for R-1002:
      action:    issue_refund({"amount": 181.99, "order_id": "ORD-90106", "reason": "changed_mind"})
      why:       below the calibrated auto-approval threshold (confidence 0.548, calibrated threshold 0.863).
      guarantee: Expected rate of decisions that are auto-approved and wrong is at most 0.05 (conformal risk control, n=320).
    [simulated refunds specialist] edited amount 181.99 -> 169.99.
                                   Change-of-mind return: rule 3 refunds the
                                   item price; the $12.00 shipping fee is not
                                   refundable.
    resume:   Command(resume={"decisions": [{"type": "edit", "edited_action":
              {"name": "issue_refund", "args": {"order_id": "ORD-90106",
              "amount": 169.99, "reason": "changed_mind"}}}]})
    note:     a reviewer's edit is an explicit human approval: the edited call
              runs as written and is not scored again
    tool:     [success] Note: a human reviewer replaced this tool call before
                        it ran. The call recorded in your message is the one
                        you produced, not the one that executed. This was
                        intentional and authorized. Do not re-issue your
                        original call. Executed instead: issue_refund with
                        arguments {"order_id": "ORD-90106", "amount": 169.99,
                        "reason": "changed_mind"}. Tool response: Refund of
                        $169.99 issued to the card on ORD-90106 (reason:
                        changed_mind).
    agent:    "A refunds specialist adjusted the amount before it was issued.
              Refund of $169.99 issued to the card on ORD-90106 (reason:
              changed_mind)."
------------------------------------------------------------------------------
R-1003: Second refund on an order that was already refunded
    customer: I was charged twice for the earbuds on order ORD-90103. Please
              refund the duplicate charge of $64.98.
    agent:    proposes lookup_order(order_id="ORD-90103")
    [...]
    agent:    proposes issue_refund(amount=64.98, order_id="ORD-90103",
              reason="duplicate_charge")
    guard:    ESCALATE: below the calibrated auto-approval threshold
              (confidence 0.007, calibrated threshold 0.863).
              guarantee: Expected rate of decisions that are auto-approved and
              wrong is at most 0.05 (conformal risk control, n=320).
    paused:   HumanInTheLoopMiddleware interrupt; the run waits for a reviewer
    review request for R-1003:
      action:    issue_refund({"amount": 64.98, "order_id": "ORD-90103", "reason": "duplicate_charge"})
      why:       below the calibrated auto-approval threshold (confidence 0.007, calibrated threshold 0.863).
      guarantee: Expected rate of decisions that are auto-approved and wrong is at most 0.05 (conformal risk control, n=320).
    [simulated refunds specialist] rejected
    resume:   Command(resume={"decisions": [{"type": "reject", "message": "A
              full refund of $64.98 was already issued on this order; it
              reversed the duplicate charge."}]})
    tool:     [error] User rejected the tool call for `issue_refund` with
                      reason: A full refund of $64.98 was already issued on
                      this order; it reversed the duplicate charge.
    agent:    "A refunds specialist reviewed this request and did not issue a
              refund. A full refund of $64.98 was already issued on this
              order; it reversed the duplicate charge."
------------------------------------------------------------------------------
Summary
    case     guard      human review   refund issued
    R-1001   allow      -              $14.99 on ORD-90101
    R-1002   escalate   edit           $169.99 on ORD-90106
    R-1003   escalate   reject         none
    guard evaluations: 3 (each proposed call is scored once; stored in the
    checkpointed agent state and reused on resume)
------------------------------------------------------------------------------
The bundled calibration sets are small so the example runs quickly. For
production, calibrate on at least the recommended number of labelled examples
from your own traffic (about 1,000 for alpha=0.10), audit on held-out data,
and monitor for drift.
  • R-1001 scores 0.967, above the calibrated threshold of 0.863, so the refund runs with no interrupt.
  • R-1002 asks for the full $181.99, including the $12.00 shipping fee, which rule 3 of the policy does not refund on a change-of-mind return. The request is also 27 days into a 30-day window. It scores 0.548 and pauses. The reviewer edits the amount to the item price, and LangChain runs the reviewer’s call and tells the model it was replaced.
  • R-1003 is a second refund on an order already refunded in full. It scores 0.007 and pauses; the reviewer rejects, and issue_refund never runs.

What the guarantee covers. guarantee="risk" bounds the rate of auto-approved and wrong decisions over all requests, not the error rate among approvals. On this calibration set 178 of 320 requests clear the threshold, so an expected per-request rate of 0.05 allows up to about 0.05 / (178 / 320) = 9% of auto-approved refunds to be wrong. If your policy is written per approved refund, use guarantee="fdr" (Gate). The escalated requests are not covered by the bound at all: they are exactly the ones a person decides.

In batch-job mode (--no-human-review) nothing pauses. The escalated calls come back to the agent as an error ToolMessage and can be queued:

    guard:    ESCALATE: below the calibrated auto-approval threshold
              (confidence 0.548, calibrated threshold 0.863).
              guarantee: Expected rate of decisions that are auto-approved and
              wrong is at most 0.05 (conformal risk control, n=320).
    tool:     [error] needs_human_review: The issue_refund action needs human
                      review before it can run: below the calibrated
                      auto-approval threshold (confidence 0.548, calibrated
                      threshold 0.863). Do not retry it; tell the user it has
                      been sent for review. (the guard decision is attached
                      under "cli")
    agent:    "This refund needs a refunds specialist's review before it can
              be issued. I have added it to the review queue; you will get an
              email when it is decided."
[...]
Summary
    case     guard      human review   refund issued
    R-1001   allow      -              $14.99 on ORD-90101
    R-1002   escalate   queued         none
    R-1003   escalate   queued         none
    guard evaluations: 3 (each proposed call is scored once)
    batch mode: R-1002, R-1003 refused with an error ToolMessage and queued
    for human review

A second run reuses the cached profile (status serving (reused from the profile store)) and makes the same decisions.

Example: patient-portal triage (healthcare)

examples/agents/langchain/healthcare_patient_triage.py triages new patient-portal messages and proposes send_triage_advice(message_id, level). The level is one of self_care, routine_appointment, urgent_care or emergency. The guard is a four-option Set with APS at alpha=0.05, calibrated on 320 synthetic labelled messages from data/triage_messages.jsonl. The labels come from an explicit synthetic triage protocol covering red flags, urgent signs, temperature, severity, age band and per-complaint durations, plus nurse-decided vague messages and 1.5% label noise:

TRIAGE_SET = Set(
    instructions=(
        "Under the triage protocol in the context, which triage level does this patient-portal message "
        "require? Apply the protocol's rules in order."
    ),
    options=dict(triage.TRIAGE_LEVELS),
    calibration_profile="portal-triage-v1",
    alpha=0.05,
    method="APS",
)
def build_guard(client: LocalCLIClient) -> ToolGuard:
    rule = GuardRule(
        tool="send_triage_advice",
        query=TRIAGE_SET,
        context=triage_guard_context,
        allow_labels=["self_care", "routine_appointment"],  # urgent_care and emergency always go to a nurse
        match_argument="level",  # the set must be exactly the level the agent proposed
    )
    return ToolGuard(client, [rule])

An automated reply goes out only when the calibrated 95% prediction set is exactly one low-acuity level and that level is the one the agent proposed. A set with two levels means the calibration needs both to reach 95% coverage, so a nurse decides. match_argument="level" compares the set with the level argument. When the set is a confident singleton for a different level than the agent proposed, the call escalates instead of running. urgent_care and emergency are not in allow_labels, so they are never automated.

git clone https://github.com/rahvis/cci-sdk && cd cci-sdk
python examples/agents/langchain/healthcare_patient_triage.py
python examples/agents/langchain/healthcare_patient_triage.py --no-human-review

Output in mock mode (M-2001 and M-2003 abbreviated):

==============================================================================
LangChain patient-portal triage: calibrated guard on send_triage_advice
==============================================================================
Synthetic data for demonstration only. Not medical advice and not a medical
device. A calibrated guarantee bounds error rates on data like the calibration
set; it does not make any single recommendation safe. Keep a qualified
clinician in the loop and follow your institution's clinical governance.

agent model: mock   evidence model: mock   nurse review: on (HumanInTheLoopMiddleware)
profile portal-triage-v1: APS, alpha=0.05, n=320 synthetic labelled messages, status serving (reused from the profile store)
------------------------------------------------------------------------------
M-2001: Mild cold for 3 days; the agent proposes self_care
    [...]
    evidence: self_care 0.996, routine_appointment 0.002, urgent_care 0.001,
              emergency 0.001
    guard:    ALLOW: the calibrated prediction set is exactly {self_care}.
              guarantee: Contains the correct answer at least 95% of the time
              on profile 'portal-triage-v1' (n=320).
    tool:     [success] Standard self_care advice sent to the patient for
                        message M-2001.
    [...]
------------------------------------------------------------------------------
M-2002: Chest tightness, no red flags; the agent proposes routine_appointment
    portal:   New portal message M-2002 (age band 18-39): I am 29 and I have
              had a tight feeling in my chest for 3 days. It feels mild. I
              have not taken my temperature.
    agent:    proposes send_triage_advice(level="routine_appointment",
              message_id="M-2002")
    evidence: routine_appointment 0.656, urgent_care 0.328, self_care 0.012,
              emergency 0.004
    guard:    ESCALATE: the calibrated prediction set is {routine_appointment,
              urgent_care}, not a single allowed label.
              guarantee: Contains the correct answer at least 95% of the time
              on profile 'portal-triage-v1' (n=320).
    paused:   HumanInTheLoopMiddleware interrupt; the run waits for a nurse
    review request for M-2002:
      action:    send_triage_advice({"level": "routine_appointment", "message_id": "M-2002"})
      why:       the calibrated prediction set is {routine_appointment, urgent_care}, not a single allowed label.
      guarantee: Contains the correct answer at least 95% of the time on profile 'portal-triage-v1' (n=320).
    [simulated nurse] approved
    resume:   Command(resume={"decisions": [{"type": "approve"}]})
    tool:     [success] Standard routine_appointment advice sent to the
                        patient for message M-2002.
    agent:    "Standard routine_appointment advice sent to the patient for
              message M-2002. A nurse can see this conversation."
------------------------------------------------------------------------------
M-2003: Stroke warning signs; the agent proposes emergency
    [...]
    guard:    ESCALATE: the calibrated prediction set is {emergency}, not a
              single allowed label.
    [...]
    [simulated nurse] approved
    [...]
    note:     Emergency advice is never automated here. A production portal
              also shows fixed emergency instructions to the patient at once,
              without waiting for the nurse queue.
------------------------------------------------------------------------------
M-2004: Cough for 25 days; the agent proposes self_care
    portal:   New portal message M-2004 (age band 40-64): I am 52 and I have
              had a cough for 25 days. It feels mild. My temperature is 37.1
              C.
    agent:    proposes send_triage_advice(level="self_care",
              message_id="M-2004")
    evidence: routine_appointment 0.991, self_care 0.004, urgent_care 0.004,
              emergency 0.001
    guard:    ESCALATE: the calibrated prediction set is
              {routine_appointment}, but the agent proposed 'self_care'.
              guarantee: Contains the correct answer at least 95% of the time
              on profile 'portal-triage-v1' (n=320).
    paused:   HumanInTheLoopMiddleware interrupt; the run waits for a nurse
    review request for M-2004:
      action:    send_triage_advice({"level": "self_care", "message_id": "M-2004"})
      why:       the calibrated prediction set is {routine_appointment}, but the agent proposed 'self_care'.
      guarantee: Contains the correct answer at least 95% of the time on profile 'portal-triage-v1' (n=320).
    [simulated nurse] edited level self_care -> routine_appointment. A cough
                      lasting more than 21 days calls for a routine
                      appointment under rule 3.
    resume:   Command(resume={"decisions": [{"type": "edit", "edited_action":
              {"name": "send_triage_advice", "args": {"message_id": "M-2004",
              "level": "routine_appointment"}}}]})
    note:     a nurse's edit is an explicit human approval: the edited call
              runs as written and is not scored again
    [...]
------------------------------------------------------------------------------
Summary
    case     guard      nurse     advice sent
    M-2001   allow      -         self_care
    M-2002   escalate   approve   routine_appointment
    M-2003   escalate   approve   emergency
    M-2004   escalate   edit      routine_appointment
    guard evaluations: 4 (each proposed call is scored once; stored in the
    checkpointed agent state and reused on resume)
  • M-2001: the set is exactly {self_care}, which is what the agent proposed, so the standard self-care advice is sent.
  • M-2002: chest discomfort without red flags. The evidence model splits between routine_appointment and urgent_care, and the 95% set keeps both. A nurse approves the routine appointment.
  • M-2003: stroke warning signs. The set is {emergency} and the agent agrees, but emergency is not an automated level, so a nurse confirms.
  • M-2004: a 25-day cough. The set is the singleton {routine_appointment}, while the agent proposed self_care. That is the case match_argument exists for. The nurse edits the level, and only the nurse’s level is sent.

What the guarantee covers. “Contains the correct answer at least 95% of the time” is marginal coverage over messages like the calibration set. It is not a statement about M-2001, and it is not conditional on complaint or age band (use group_by for per-group coverage, with enough examples per group). On the calibration messages, 215 of 320 sets are a single level and 105 have two. The two-level sets are the price of 95% coverage with this evidence model, and they are the messages a nurse sees.

The emergency pathway must not wait on a review queue. In this example the guard never sends emergency advice on its own, which is right for the automated channel. A production portal should also show fixed, rule-based emergency instructions (call emergency services) to the patient at once, whatever the agent or the guard decides, and route the message to a nurse at the same time. The protocol here is a synthetic teaching example, not clinical guidance.

Pitfalls

  • Use langchain.agents.create_agent. langgraph.prebuilt.create_react_agent is deprecated, and AgentExecutor and initialize_agent moved to the separate langchain-classic package. The middleware in this integration only works with create_agent.
  • Interrupts need a checkpointer and a thread_id. Without a checkpointer, human review cannot pause the run. Resume on the same thread_id. Use a durable checkpointer (for example Postgres) shared by every worker in production: the guard’s decisions live in the checkpoint, and a resume on another worker reads the decision the reviewer saw instead of scoring again.
  • One decision per pending action, in order. The resume command, Command(resume={"decisions": [...]}), must hold exactly as many decisions as pending_reviews(result) returned, and each must be one of approve, edit or reject (cli_middleware(guard, allowed_decisions=...) changes the set). A count mismatch or an unlisted type raises ValueError. When one model turn proposes several escalated calls, they arrive in a single interrupt.
  • An edit is a human approval. The reviewer’s arguments run as written and are not scored again, even if the edit changes the outcome. Keep the reviewer’s decision in your audit log alongside guard_decisions(result).
  • A refused call returns to the model. A real model may try again. The refusal message tells it not to retry. A retry is a new tool call: it is checked again and escalates again, so add ToolCallLimitMiddleware if repeated attempts would cost reviewer time.
  • Read version="v2" results through the helpers. invoke(..., version="v2") returns a GraphOutput (.value, .interrupts), and the default version="v1" returns a dict with "__interrupt__". pending_reviews and guard_decisions accept both.
  • Self-hosted models need tool calling switched on in the server. Start vLLM with --enable-auto-tool-choice --tool-call-parser gemma4, or SGLang with --tool-call-parser gemma4. Otherwise Gemma writes tool calls as text, the agent never calls a tool, and the guard never runs. For ChatOpenAI against these servers, pass a non-empty api_key ("EMPTY"), set use_responses_api=False, and make model equal the served model name.
  • Gemini through init_chat_model needs the google_genai: prefix. A bare gemini-... name resolves to Vertex AI.
  • Do not let the model grade itself. Never pass the agent’s own confidence as a tool argument to drive the guard: that is verbalized confidence, which is what the calibration replaces. The guard’s context should come from your system of record (here the order and message stores), built by the same function as the calibration examples.
  • Testing with fake models. LangChain’s fake chat models do not implement bind_tools, which create_agent calls. Subclass one and return self from bind_tools, as the examples do. Build a fresh fake per run, because FakeMessagesListChatModel keeps its position in the script on the instance.
  • Local mode: LocalCLIClient, profile files, fingerprints, sizes and fail-closed behavior.
  • Bring your own model: every agent and evidence model, access levels, and Gemma on vLLM and SGLang.
  • Risk-sensitive domains: what each example guards, why it uses its primitive, and what stays with people.
  • Gate and Set: the two primitives used here.
  • Guarantees: what each guarantee type does and does not promise.