Skip to main content

ctrlrun-langgraph

Route a CTRLRun APPROVE through LangGraph's own interrupt(), so the human answers where your LangGraph users already answer.

  • Supported kernel range: ctrlrun>=0.5,<0.12
  • Supported framework range: langgraph>=1.0,<2.0
  • Primitive reused: interrupt() and Command(resume=...), with a checkpointer. Read 2026-09-05.
  • Framework shape: resumed in place (SPEC-v0.5 §3.5).
  • Conformance: 6/6, every suite, with carries_approved_arguments=True.

You probably do not need this

@protect already covers anything running in your process — a LangChain tool, a raw model call, a plain function — with no adapter and no framework support at all. Most people reading this need @protect and nothing else.

This buys exactly one thing over it: when the policy says a human must approve, the request goes out through LangGraph's interrupt instead of ApprovalRequired being raised past your graph. If your deployment has nowhere for a human to answer, or you are happy handling ApprovalRequired in your own code, stop here.

There is a third way in that is not an adapter at all: ctrlrun gateway puts the same guarantees in front of an MCP tool server, in any language, with no agent change.

Install

$ pip install ctrlrun-langgraph

Use

The operator wires it, on the line where the policy and the store are chosen. This adapter never constructs a Control (SPEC-v0.5 §2.3), so everything it must not choose — the identity provider, the authority document, the environment, the mode — is chosen by the person deploying it, in the file they already look at.

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 what routes the APPROVE through the provider — and therefore through interrupt() — instead of raising past your graph. It is the entire difference this adapter makes.

Call issue_refund from a node, on a graph compiled with a checkpointer:

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,
    )

What you may send back

Command(resume=True)                       # granted, approver "langgraph:interrupt"
Command(resume=False)                      # refused
Command(resume={"approved": True,
                "approver": "ada@example.com",
                "arguments": {...}})       # the arguments the human answered against

approved must be True or False. A truthy string is not a yes, and is refused with a message that names your resume value. This is a payload shape for LangGraph's own resumption channel, not a token: nothing is minted, nothing is stored, and there is no id here this adapter invented.

The binding: prevention or attribution

carries_approved_arguments has no default, because the default somebody assumes is the one that does not check.

True — prevention. Your resume value must carry arguments, and CTRLRun rebuilds the proposal with them and compares the action hash. An answer given against €5 that arrives for a €5,000 action is refused with ApprovalMismatch, the approval is left grantable, and nothing runs. This is the setting the conformance results above were produced with, and it is right for almost every deployment: your console already knows what it showed the human.

False — attribution. You send back only a verdict. CTRLRun still binds the approval to the action that executes — that is v0.1 §4.2 A1 and it holds unconditionally — but the binding across the interrupt is LangGraph's checkpoint, not CTRLRun's hash. If the checkpoint replayed a different call than the one a human read, evidence will show it afterwards; nothing refuses it beforehand. That is attribution, in that word, and the conformance kit reports binding: not_applicable with the reason rather than a pass. Choose it only if your console genuinely cannot echo what it displayed.

Where LangGraph's behaviour shows through the contract

SPEC-v0.5 §7 item 5 asks every adapter to record this, and for a resumed-in-place framework there are three (§3.2.1).

The node runs twice, so the primitive is reached twice. LangGraph replays the node from the checkpoint, so @protect builds a new Action with a new action_id and creates a new approval request on the resumed pass. interrupt() is therefore called once to ask and once to receive. None of that is a defect and none of it is unsafe — the resumed pass re-runs principal expiry, authority and policy at resumption time, so an authority revoked while the human deliberated refuses the action then.

action_id is not continuous. The action_id in the payload a human saw is the first pass's; the one on the receipt is the second's. Both are in the event log under their own ACTION_PROPOSED and APPROVAL_REQUESTED, and correlating them is a reader's work. action_hash is continuous, because action_id is excluded from the canonical form — which is why the binding check above is about content and never about an id.

The first pass's request is orphaned. It stays pending and grantable by ctrlrun approve for its full TTL, for the same action_hash. Not a hole — an approval is single-use and hash-bound and is consumed atomically with the reservation — but an operator watching a queue will see two requests for one refund.

The approval TTL does not bound the human's deliberation. They answer against the first pass's request; the grant lands on the second pass's, created after they answered. What bounds the interval is your checkpoint, which may hold it for a month. If that matters to you, expire the thread.

Retries. LangGraph's retry is explicit and opt-in — a node takes a RetryPolicy — and this adapter attaches none. Measured on langgraph 1.2.11 against a remote that commits and then drops the connection, the prebuilt agent surfaced the failure and stopped: one effect, one request, five runs out of five (research/framework-probe/results/2026-09-05.json). That is behaviour, not quality, and it is not a promise about your graph.

The kernel's exceptions arrive as themselves. SPEC-v0.5 §7 item 6. LangGraph propagates a node's exception unchanged, so an ActionDenied, a DuplicateEffect, an AmbiguousEffect or a NotExecuted raised inside a protected node reaches your except clause as itself. There is nothing to call and nothing to unwrap, and this adapter ships no helper for it.

That is worth saying rather than leaving to be inferred, because the other reference adapter is the opposite case: the OpenAI Agents SDK turns a tool's exception into text for the model by default, and ctrlrun-openai-agents has to ship protected_tool and unwrap to undo it (SPEC-v0.5 §12.7). An operator moving between the two should know which side of that line they are on, and "the README said nothing" is not an answer to it.

What this adapter does not do

It is not a second approval path: it reuses interrupt() and reimplements nothing — no prompt, no queue, no polling loop, no resume token of its own. It grants nothing: the answer it returns is recorded by InterruptApprovalProvider, in core, through the same two store calls ctrlrun approve makes. It constructs no Control and supplies no principal — an adapter sees one and never supplies one.

And it is not a compliance claim. "Conformance" here names a suite of the CTRLRun repository's own acceptance tests, run against this adapter. It certifies nothing.

Versioning

adapters-langgraph-MAJOR.MINOR, never a kernel version. This adapter answers to two upstreams and neither is the CTRLRun roadmap: it breaks when LangGraph makes a breaking release, on that project's schedule. Its major version tracks whichever of the two forced the break, and the two ranges at the top are what its CI actually ran against.

Metadata

Release files for ctrlrun-langgraph 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ctrlrun-langgraph 1.2.0
File Size Uploaded
ctrlrun_langgraph-1.2.0.tar.gz 7.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ctrlrun-langgraph 1.2.0
File Interpreter ABI Platform
ctrlrun_langgraph-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 16.1 kB

Release files / ctrlrun_langgraph-1.2.0.tar.gz

Download URL ctrlrun_langgraph-1.2.0.tar.gz
Size 7.7 kB
Tags Source
SHA-256 checksum
How to use checksums
92971671f4953c72d2ac04ba29c4423d7f66b5e903f312408d929986e564d678
BLAKE2b-256 checksum
How to use checksums
5f39e1640f06503df178545c2ac60c1c54c52240c414a5693a2ee1b190f82941
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release files / ctrlrun_langgraph-1.2.0-py3-none-any.whl

Download URL ctrlrun_langgraph-1.2.0-py3-none-any.whl
Size 8.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f73b1d73de876e8def3f95371012cfed39d18da9dce232a79575c0aa45970a98
BLAKE2b-256 checksum
How to use checksums
81ab277b2668a102b14667c2989e2b92d049ec0817bc1554ad2d75c6772c4fcd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.

Transparency log

Release history Release notifications | RSS feed

1.3.0

2 release files

This release

1.2.0 This release

2 release files

1.1.0

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page