Should interrupt() be split into two primitives — one for human input, one for s2s data fetching?

We’re exploring a deployment pattern where the agent is intentionally
restricted from making outbound calls — treating it like an ML model
(text in, text out, no credentials, no network access). The serving
layer owns all I/O and s2s calls; the agent declares what data it needs,
and the serving layer fulfills it.

To implement this with LangGraph Server, we’re using interrupt() for
data fetches — but the contract feels wrong for this use case:

  • interrupt() is indeterminate — a human may never respond
  • A data fetch always resumes, has a strict SLA, is automated

Why this pattern matters

Treating the agent as a pure model unlocks two things that are hard today:

  1. Clean A/B testing — swap agent versions behind the serving layer the
    same way you’d swap model checkpoints. If the agent makes outbound calls
    itself, you can’t isolate its behavior from the data layer during
    evaluation. A fetch() primitive makes that boundary explicit and testable.

  2. Replay and offline evaluation — log the fetch requests and responses
    in production, then replay real conversations against a new agent version
    without hitting live services. With interrupt() today this is possible
    but requires inspecting payloads to know which interrupts to replay vs.
    which to surface to a human.

interrupt() can mechanically implement this today, but the semantic mismatch
leaks into every layer: monitoring, API clients, and serving layer code all
need to know “this interrupt is actually a data fetch, not a human decision.”

Proposed: fetch() primitive

# Instead of:
raw = interrupt({"resource": "transactions", "start_date": start_date})

# A semantically distinct call:
raw = fetch("transactions", start_date=start_date)

At minimum a thin wrapper over interrupt() with a reserved marker, but the
framework could expose tasks[i].fetches vs tasks[i].interrupts separately,
and LangGraph Server could surface /fetch as a distinct endpoint — so
serving layer code becomes unambiguous:

snapshot = graph.get_state(config)

for task in snapshot.tasks:
    for req in task.fetches:           # always automated — fulfill and resume
        result = data_layer.get(req.resource, **req.params)
        graph.invoke(Command(resume=result), config=config)

    for val in task.interrupts:        # always human — surface to UI
        ui.show(val)
        break

Question for maintainers: Is this distinction intentional — i.e., are
users expected to use interrupt() for s2s data dependencies? Or is there a
cleaner pattern we’re missing?

Hello @rajkiran2190, welcome to langchain community.
interrupt() is intentionally human-in-the-loop only; there is no fetch() primitive. The cleanest path today is a typed marker convention, not a framework change.

Interrupt has no semantic type field

@final
@dataclass(init=False, slots=True)
class Interrupt:
    value: Any
    id: str
    ...

class PregelTask(NamedTuple):
    id: str
    name: str
    path: tuple[str | int | tuple, ...]
    error: Exception | None = None
    interrupts: tuple[Interrupt, ...] = ()   # single bucket — no `fetches`
    state: None | RunnableConfig | StateSnapshot = None
    result: Any | None = None

Use a typed envelope as the interrupt value and route at the serving layer by inspecting it:


from dataclasses import dataclass
from typing import Any
from langgraph.types import interrupt

@dataclass
class FetchRequest:
    _type: str = "fetch"          # reserved discriminator
    resource: str = ""
    params: dict = None

def my_node(state):
    raw = interrupt(FetchRequest(resource="transactions", params={"start_date": ...}))
    ...

Serving layer:

config = {"configurable": {"thread_id": "some-thread-id"}}
graph.invoke(initial_input, config)
snapshot = graph.get_state(config)   # returns StateSnapshot
for task in snapshot.tasks:
    for intr in task.interrupts:
        if isinstance(intr.value, dict) and intr.value.get("_type") == "fetch":
            result = data_layer.get(intr.value["resource"], **intr.value["params"])
            graph.invoke(Command(resume={intr.id: result}), config)
        else:
            ui.show(intr.value)

Thanks @keenborder786 for the reply.

Do you think using interrupt() with a typed envelope like this is a common practice in production deployments?

More broadly — in enterprise settings, what are your thoughts on agents making service-to-service calls directly? Especially when those calls have security obligations like JWT token exchange, mutual TLS, or scoped service accounts.

My instinct is that treating the agent as a pure model — structured output in, structured response out, no credentials, no network access — is safer and more maintainable. The calling layer interprets the response and takes action. This also makes A/B testing and offline replay much cleaner, since the agent’s behavior is fully isolated from the data layer.

Is this pattern discussed or recommended anywhere in the LangGraph ecosystem? Curious if others are building this way.

On the “typed envelope” pattern — is it common in production?

No, not as a formally established pattern. There is no precedent for it anywhere in this codebase, the official docs, or the LangGraph ecosystem. Teams that have landed on it did so independently by reasoning their way to it, the same way you did. It works mechanically, but it’s a convention you own - the framework gives you no tooling to enforce or introspect it.

On agents making direct s2s calls - the security problem is real

Your instinct is correct, and here’s precisely why it matters:

The framework’s own RemoteGraph injects credentials through constructor args or environment variables. For agent nodes that make s2s calls, the same pattern applies - credentials go into config["configurable"] and the node reads them at runtime. This means:

  1. Credentials must be present in the checkpoint-serialized config - _sanitize_config strips non-primitives but passes string tokens through. Your JWT is in the checkpoint store.

  2. Every replay re-uses the credential snapshot from when the run was created - which is either stale (JWT expired) or a security liability if logs are long-lived.

  3. You cannot swap the data layer independently of the agent for evaluation without also swapping or mocking the credential context.

What the framework actually provides for your use case

There are two hooks that are relevant and already exist:

1. config["configurable"] as a passthrough lane

The serving layer can inject a callable or a stub into configurable before each resume. The agent node reads it by name; no credentials baked in, no network access, just a callable the serving layer controls:


# Serving layer — controls what the agent can call

config = {"configurable": {"data_fetcher": my_scoped_fetcher}}

graph.invoke(Command(resume=result), config)

This is not documented as a pattern for this use case, but it’s exactly what configurable was designed for, per-run, per-resume injection.

2. interrupt() with typed values + resume by id

This is the only framework-native mechanism for the agent to declare a data need and pause. The Interrupt.id field (stable since v0.2.24) lets the serving layer match resume values to specific interrupt instances unambiguously across replays.

Is the “pure model” pattern discussed or recommended in the LangGraph ecosystem?

Not explicitly. The framework’s design assumes agents have network access - tools are async callables, nodes are expected to call LLMs and APIs. The “pure model” pattern you’re describing is not a documented deployment posture.

However, nothing in the framework prevents it. The gap is purely semantic tooling - there’s no fetch() primitive, no task.fetches accessor, and no /fetch endpoint in LangGraph Server. What you’d be building is a convention layer on top of interrupt().

TLDR

  1. interrupt() is human-in-the-loop only - using it for s2s fetches is a semantic mismatch the framework doesn’t help you with.
  2. Your “pure model” instinct is correct for enterprise (credential isolation, replay, A/B testing), but LangGraph has no native fetch() primitive for it.
  3. Best path today: typed envelope in interrupt().value + route at the serving layer by inspecting it. Resume by Interrupt.id for correctness across replays.
  4. The framework change needed is small and additive - a kind field on Interrupt + task.fetches property.

Thanks for the detailed breakdown — this is exactly the kind of clarity we were looking for.

The JWT-in-checkpoint detail is exactly the kind of concrete harm that makes this worth formalizing. Stale tokens and credential leakage in long-lived checkpoint stores are audit failures in regulated environments — not just design preferences.

The config["configurable"] callable pattern is useful for in-process scenarios, but it doesn’t help when the agent runs as a LangGraph Server deployment and the serving layer is a separate process. The only cross-process mechanism available today is interrupt().

Given that the framework change is small and additive — a kind field on Interrupt and a task.fetches property — would a GitHub issue / PR be welcome? The typed envelope convention works today, but teams that need this will keep reinventing it independently. First-class support would make the “pure model” deployment posture an explicitly supported pattern rather than something you reason your way to.

Happy to draft the issue if that helps move it forward.

Sure, you can ask for this by stating the use case in GitHub - langchain-ai/langgraph: Build resilient language agents as graphs. Available in TypeScript! · GitHub issue, and wait for an official maintainer to assign it to you.