Skip to main content

RemoteGraph, and what every other framework does instead

· 13 min read
Vadim Nicolai
Senior Software Engineer

LangGraph ships a class that turns an agent running on another machine into a node in your graph. You construct a RemoteGraph, pass it to builder.add_node(...), and the call site reads exactly like a local subgraph. It subclasses PregelProtocol, so as far as your parent graph is concerned there is no network there at all.

I wanted to know what that costs, and what the other frameworks offer in its place. Two of them can be measured: the same parent-and-child pair runs against a real LangGraph server and against a LlamaIndex workflow server, with the same five contract mismatches pushed through each boundary. CrewAI, the OpenAI Agents SDK and A2A are read from their docs and specs, because two of the three turn out to have no remote-agent primitive to measure.

The headline result is an inversion. RemoteGraph looks typed and validates nothing at the boundary: I renamed one field in the child, redeployed it, left the parent untouched, and the parent returned its own input with no error and no warning — a result indistinguishable from the child never running. LlamaIndex has no RemoteGraph at all; its server-and-client pair looks like a raw HTTP call, publishes a JSON Schema, and rejects a malformed request with a 400 and per-field errors. The same rename throws a KeyError on the line I wrote.

Everything below was measured on 2026-09-22 in a clean Python 3.12 virtualenv — langgraph 1.2.12, langgraph-sdk 0.4.5, llama-index-workflows 2.24.1, llama-agents-client 0.4.0, llama-agents-server 0.8.0 — against a real langgraph dev server on http://127.0.0.1:2024 and a real WorkflowServer under uvicorn (the RemoteGraph API reference, the workflow-server docs). No mocks, except one logging stub used to read the wire format, named where it appears.

Why a Remote Node Exists at All

RemoteGraph is an answer to a problem worth stating once. One repo per agent means separate owners, separate CI, separate release cadence — and two versions of one contract alive at the same time, because the child ships on Tuesday and the parent ships on Thursday. An in-process subgraph makes that impossible by construction: one import, one version, one deploy. Move the child into its own repo and the type checker stops covering the boundary. Something else has to.

That something is not a new idea. In 1999, Cremonini, Omicini and Zambonelli argued that once agents are distributed over a network, topology stops being a deployment detail and becomes part of the coordination model (Cremonini, Omicini and Zambonelli (1999)). I read that as a 27-year-old prediction, and I went looking for a counterexample in two 2026 frameworks. I did not find one. The choice of boundary primitive determines what a failure looks like from the caller's side — and in one of the two frameworks I tested, failure looks like success.

Here is the rule of thumb I'll defend: put the seam wherever state ownership, failure domains or trust boundaries change, not wherever your repository layout changes. Necessary. Not sufficient.

Place the seam correctly, then cross it with a primitive that hides the crossing. The bug now lives in neither repo. It lives in the assumption that the two sides agree, and nothing in either codebase is named after that assumption. If you're lucky it costs you a day of tracing. If you're not, it costs you a wrong number reported to a customer, on a path where every alert stays green because nothing raised.

RemoteGraph Is a Deployment Binding Wearing a Node's Clothes

inspect.signature on langgraph 1.2.12 returns a constructor taking an assistant id, plus optional url, api_key, headers, LangGraph clients, and tracing flags (the RemoteGraph API reference). The docstring is load-bearing: "The RemoteGraph class is a client implementation for calling remote APIs that implement the LangGraph Server API specification" (the RemoteGraph how-to) — a way to call a LangGraph deployment, not an agent anywhere. Nothing is checked at construction. Point it at a dead port, wire it as a node, compile:

remote = RemoteGraph("child", url="http://127.0.0.1:9999")   # nothing is listening
b = StateGraph(State); b.add_node("child", remote)
b.add_edge(START, "child"); b.add_edge("child", END)
app = b.compile()

It compiles to a CompiledStateGraph, list(app.nodes) is ['__start__', 'child'] (the RemoteGraph how-to). Follow-on results: remote.get_graph() raises ConnectError [Errno 61] Connection refused; remote.get_input_jsonschema() raises a pydantic SchemaError (the RemoteGraph API reference). No usable local schema for a remote node on this version. Build, lint, type check and deploy all pass on a node pointed at port 9999.

The Whole Call Fits in One POST, and That's the Tell

RemoteGraph invoked once against a logging stub sent a single request:

POST /runs/stream
{"input":{"text":"hi"},"config":{"metadata":{"langgraph_step":1,"langgraph_node":"child",
"langgraph_triggers":["branch:to:child"],"langgraph_path":["__pregel_pull","child"],
"langgraph_checkpoint_ns":"child:bac9c6da-..."},"configurable":{}},
"stream_mode":["values","updates"], ...}

That is a streaming call (the RemoteGraph API reference). The parent's node identity, step number, trigger, path and checkpoint namespace all travel to the child as run metadata. Not a message between agents — one Pregel run executing a step on another machine. Hence the ergonomics: the node genuinely is a node. Hence the absent contract: a graph assumes its nodes. The parent's checkpoint namespace is asserted onto a process owned by a different team on a different release train, and nothing in the payload says check me.

The Rename That Returns Success

The child repo has its own deployment manifest, served by langgraph dev on port 2024:

class ChildState(TypedDict):
text: str
def shout(state): return {"text": state["text"].upper()}

The parent declares a wider state (text: str, count: int) and adds the child as a node (the RemoteGraph how-to):

caseresult
happy path{'text': 'HELLO', 'count': 0}
parent state carries count, never declared by the child{'text': 'HELLO', 'count': 7} — passes through untouched
text sent as an intRemoteException: {'error': 'AttributeError', 'message': "'int' object has no attribute 'upper'"}

No exception, no warning, clean exit: the parent got back exactly what it sent in, which is what it would get if the child had never run. A renamed field and a dead child are the same observation at the parent (the RemoteGraph API reference).

Then the child team ships v2, renames its state field textbody, and redeploys.

parent after the child renamed text -> body: {'text': 'hello', 'count': 0}

Five skew cases through a stub that forces any payload the wire allows (the RemoteGraph how-to) are all silent:

what the child returnedwhat the parent ended up with
{"txet": "hello"} (renamed key){'text': 'start', 'count': 0}
{"text": "hello"} (correct){'text': 'hello', 'count': 0}
{"text": "hello", "count": "not-an-int"}{'text': 'hello', 'count': 'not-an-int'} — a str in a field declared int
{"text": "hello", "brand_new_field": 42}{'text': 'hello', 'count': 0} — extra key dropped
{}{'text': 'start', 'count': 0}

TypedDict is not a runtime validator, and this is documented behaviour of a shared-state channel (the RemoteGraph how-to). The point is narrower and worse: a shared-state channel across a repo boundary gives you no place to put the check.

LlamaIndex Has No RemoteGraph, and That Accident Is the Feature

Nothing in LlamaIndex composes a remote workflow into a local one. WorkflowClient is imported nowhere in the installed tree except its own package (the workflow-server docs). What exists is a server, a client, and you writing the call. WorkflowServer(...) with add_workflow(name, workflow, additional_events=None). The client's verified method set is is_healthy, list_workflows, run_workflow, run_workflow_nowait, wait_for_handler, get_handler, get_handlers, get_result, cancel_handler, send_event, get_workflow_events, get_workflow_events_schema, get_workflow_schema, get_workflow_graph (the Python client docs).

One signature matters more than that list:

run_workflow(workflow_name: str, handler_id: str | None = None,
start_event: StartEvent | dict | None = None,
context: Context | dict | None = None) -> HandlerData

A serialized Context can cross the wire.

class Parent(Workflow):
@step
async def call_remote(self, ctx: Context, ev: StartEvent) -> CalledEvent:
client = WorkflowClient(base_url="http://127.0.0.1:8099")
h = await client.run_workflow("child", start_event={"text": ev.text})
return CalledEvent(payload={"status": str(h.status), "result": h.result})

Over loopback it is fast — 0.012 s, 0.008 s on a re-run (the Python client docs). But:

{'status': 'completed',
'result': EventEnvelopeWithMetadata(value={'result': {'upper': 'HELLO FROM THE PARENT'}},
qualified_name='workflows.events.StopEvent',
type='StopEvent', types=None)}

You do not get your typed event back, only an envelope with a qualified name. workflows.client, workflows.server and workflows.protocol still import on llama-index-workflows 2.24.1 and emit a deprecation pointing at llama_agents.client. Before that the pair lived in llama_deploy (the llama-deploy announcement). Same five skew cases, same forced payloads (the Python client docs):

what the child returnedwhat the parent step saw
{"txet": "hello"} (renamed key)KeyError on the parent's own line
{"text": "hello"}'hello'
{"text": "hello", "count": "not-an-int"}'hello' — the bad field stays visible in the payload, not silently coerced into declared state
{"text": "hello", "brand_new_field": 42}'hello', extra key present in the payload
{}KeyError

Two of five now fail loudly, in the parent's code. Neither framework validates the response body — StopEvent.result is typed Any (the Python client docs). The difference is where the mismatch surfaces.

The Request Half Is Enforced, and I Didn't Expect It

get_workflow_schema("child") returns the JSON Schema of the child's declared start event (the Python client docs):

{"properties": {"text": {"title": "Text", "type": "string"},
"count": {"default": 0, "title": "Count", "type": "integer"}},
"required": ["text"], "title": "Ask", "type": "object"}

A payload that violates it is refused before the child runs:

400 Bad Request for POST http://127.0.0.1:8097/workflows/child/run
{"detail":"Validation error for 'start_event': Failed to deserialize event:
2 validation errors for Ask\ntext\n Input sho..."}

get_workflow_graph("child") returns the child's whole topology at runtime (the workflow-server docs). The comparison is not typed versus untyped: ship the LangGraph pattern and a moved contract surfaces as a wrong production answer no alert fires on; ship the llama-agents pattern and it is a 400 that names the field.

A Protocol Standardises the Envelope, Not Your Payload

OpenAI's Agents SDK handoffs "stay within a single run" (handoffs docs); its remote boundary is MCP at the tool level (MCP integration docs). CrewAI's adapters host a foreign-framework agent in your process (bring-your-own-agent guide) — not a foreign-repo answer.

The cross-repo answer is a protocol. A2A is at v1.0.0 (specification): rejected and failed are distinct, and both distinct from "came back unchanged" — a failure mode not representable in the protocol. Yang et al. (2025) place A2A in the inter-agent quadrant, MCP in the context-oriented one (survey). A2A standardises the envelope, not your payload: it gives you somewhere to report the failure, not to detect it.

Kujanpää et al. (2026) compiled versioned, validated tools before deployment: tool calls cut p50 latency 42%, up to 53% end-to-end error rate on 1,500 historical alarms (Kujanpää et al.). Chu, Xiang and Zhang (2026) route among equivalent providers by expected answer quality per service cycle: +2.18 F1 over SW-UCB on web search, up to +18 accuracy points on StrategyQA (Chu et al.).

Practical Takeaways: Which Primitive to Reach For

Start from ownership, not file layout. If one team owns both repos and they ship together, hiding the network boundary is fine (the RemoteGraph how-to). If two teams own them, the hidden boundary becomes coupling that appears in no diff.

Put the contract on the request side. llama-agents enforces inbound with a 400 and per-field errors. LangGraph enforces nothing, so on RemoteGraph you own pre-flight validation. Validate the response in both cases — neither framework does.

propertyLangGraph RemoteGraphllama-agents server + clientA2A
what crosses the wireshared graph statea start event plus optional serialized Contexta task with typed message parts
who must implement the far endthe LangGraph Server API specificationany WorkflowServerany A2A server
build-time checknone — compiles against a dead hostnonenone
request validation at the boundarynone measured400 with per-field errorsper the Agent Card
self-descriptionget_graph() over the network, no local input schemaget_workflow_schema and get_workflow_graphthe Agent Card
a renamed field in the childsilent, returns the parent's own inputKeyError in the parent's stepstill silent inside a part
integration cost in the parentone constructor + add_nodeabout four lines in a @stepnot measured in this test
where a failure shows upin state nobody diffedon the line you wrotein an explicit task state

Decide whether "child failed", "child rejected the task" and "child returned nothing" must be distinguishable. A shared state dict cannot express the difference; a task state machine can. Version the boundary: Kujanpää et al.'s latency and error-rate cuts came from compiling the seam into versioned artifacts (Kujanpää et al. (2026)). Untyped seams don't drift less; they drift silently.

Test the seam by toggling one convention at a time. I tested composition, contracts and skew — not throughput, retries under partial failure, or long-running human-in-the-loop resumption.

So: Should You Use RemoteGraph?

Cost-Efficiency, Safety and Robustness in Agent Interoperability

Cost-efficiency, safety and robustness remain unsettled (Yehudai et al. (2025)), and agents that model other agents assume behaviour stable enough to be learned or predicted (Albrecht and Stone (2018)).

The seam is who owns the state on each side, and who finds out when they disagree. Every failure I measured was a failure of that second question.

RemoteGraph makes one system look like one system — right when it is one, wrong when it is two. LlamaIndex's server-and-client pair is more honest by accident of design. A2A is more honest on purpose, and a protocol is the only one of the three with somewhere to put "rejected." Pick the option whose seam is visible from the side that must maintain it.