> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Use the LangGraph adapter

> Route an approve decision through LangGraph's own interrupt() with ctrlrun-langgraph.

`ctrlrun-langgraph` makes an `approve` decision surface as a LangGraph `interrupt()` instead of
an `ApprovalRequired` raised past your graph. The human answers where LangGraph users already
answer, with `Command(resume=...)`, and one core provider writes the grant through the same
calls `ctrlrun approve` makes. With `carries_approved_arguments=True` the resumption carries the
arguments the human saw and core re-checks them against the action hash: that is prevention.

You probably do not need this. `@protect` already covers a LangChain tool or a graph node with
no adapter. Use it when your deployment has a place where a human answers a LangGraph interrupt
and you want approvals to land there.

**Prerequisites:** `pip install ctrlrun-langgraph`, `langgraph>=1.0,<2.0`, a graph compiled
with a checkpointer. The blocks below are the adapter's own example; the adapter's tests run
them against a real LangGraph install in this repository's CI.

<Steps>
  <Step title="Build the Control, and hand it over">
    The operator chooses the policy, the store, the identity provider and the authority
    document. The adapter is one argument to the approval provider and never constructs a
    `Control` or supplies a principal.

    ```python theme={null}
    from ctrlrun import Control, InterruptApprovalProvider, protect
    from ctrlrun_langgraph import LangGraphInterrupt

    control = Control(
        policy, store,
        approvals=InterruptApprovalProvider(
            store, LangGraphInterrupt(carries_approved_arguments=True)
        ),
        identity=..., authority=...,
    )


    @protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
    def issue_refund(payment_id: str, amount: int) -> str:
        return stripe.Refund.create(payment_intent=payment_id, amount=amount)
    ```

    `wait=True` is the whole difference: it routes the `approve` through the provider, and so
    through `interrupt()`, instead of raising.
  </Step>

  <Step title="Answer the interrupt">
    ```python theme={null}
    graph = builder.compile(checkpointer=InMemorySaver())
    config = {"configurable": {"thread_id": "..."}}

    result = graph.invoke({"payment_id": "txn_1", "amount": 2000}, config)
    if "__interrupt__" in result:
        pending = graph.get_state(config).tasks[0].interrupts[0].value
        # `pending` is JSON: the action, its arguments, the resource, the principal, the hash
        # and the request's expiry. Put it in front of a human however you already do.
        graph.invoke(
            Command(resume={
                "approved": True,
                "approver": "ada@example.com",
                "arguments": pending["arguments"],   # what they answered against
            }),
            config,
        )
    ```

    `Command(resume=True)` grants as `langgraph:interrupt`; `Command(resume=False)` refuses;
    the mapping form carries the approver and the arguments. `approved` must be a real boolean;
    a truthy string is refused with a message naming your resume value.
  </Step>

  <Step title="Choose prevention">
    `carries_approved_arguments` has no default, because the default somebody assumes is the
    one that does not check. `True`: the resumption must carry `arguments`, core rebuilds the
    proposal with them and compares the hash, and an answer given against €5 that arrives for
    a €5,000 action is refused with `ApprovalMismatch`. `False`: only the verdict comes back,
    the binding across the interrupt is LangGraph's checkpoint, and CTRLRun records who
    answered without being able to re-check what about; the conformance kit reports `binding`
    as not applicable, never as a pass. Choose `False` only if your console cannot echo what it
    displayed.
  </Step>
</Steps>

## Where LangGraph shows through

* **The node runs twice**, once to ask and once on resume, so there are two `action_id`s and
  two approval requests for one refund; the first stays `pending` for its TTL. `action_hash` is
  continuous, which is why the binding is about content and never about an id.
* **The resumed pass re-checks everything**: principal expiry, authority and policy at
  resumption time, so an authority revoked while the human deliberated refuses the action then.
* **The TTL does not bound deliberation**; your checkpoint does. Expire the thread if it
  matters.
* **The kernel's exceptions arrive as themselves.** LangGraph propagates a node's exception, so
  `except DuplicateEffect` works with nothing to unwrap.

## If it didn't work

* `ApprovalMismatch` on resume: the `arguments` you sent back are not the ones the human saw;
  send `pending["arguments"]` verbatim.
* `ApprovalRequired` raised past the graph: the decorator lacks `wait=True`, or the function
  is bound to a different `Control` than the one carrying the provider.
* `InvalidArgument: approved must be True or False`: the resume value carried a string.

## Next

* [Use the OpenAI Agents SDK adapter](/guides/openai-agents-adapter): the other shape, and why its binding is attribution.
* [Approval binding](/concepts/approval-binding).
* [The adapter's README](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/langgraph/README.md) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [LangGraph with interrupt()](/cookbook/langgraph-interrupt.md)
- [Use the OpenAI Agents SDK adapter](/guides/openai-agents-adapter.md)
- [Three ways in](/get-started/three-ways-in.md)
- [CTRLRun and framework human-in-the-loop](/compare/framework-hitl.md)
- [Adapters](/adapters.md)
