No EZGraph key required. Free production use. No runtime fee. Bring your own model provider. Run in your own infrastructure. Optional support is available.

v1.0 TypeScript · runs on LangGraph 1.4 · no account required

LangGraph's missing layer
is EZGraph

LangGraph is a runtime, not an application framework. Every team building a reliable guided chatbot still writes the same agent loop, the same tool dispatch, the same concurrency bugs. EZGraph is the layer on top: same engine, one node contract, 51.9% less framework-facing code — measured on one chatbot built twice.

  • No EZGraph account or key
  • Production use, no runtime fee
  • Public TypeScript declarations
  • No cloud control plane
  • Private source/debug with support
QuoteGraph guided quote: Driver, Vehicle, History, Coverage, then Quote. Jamie Rivera finances a 2019 Camry SE; the graph rejects liability-only because the lender requires collision, presents three tiers, adjusts deductibles, and locks in Selected as QT-482910.
QuoteGraph — five stages, one session, no dropped state
The problem

You didn't set out to build a session store

You set out to build a chatbot that collects a driver, a vehicle, and a coverage selection, then quotes a price — reliably, for months, at 3 a.m. Here is what LangGraph hands back to you instead. Every item below is documented by LangGraph's own docs, its v1 feedback thread, or a public production postmortem.

01

A schema change silently kills live threads

Checkpointers store framework snapshots as an opaque blob. Add one field to your state and every checkpoint written before that change deserializes into a shape your code no longer expects — successful writes, failed reads, no migration error.

One team lost ~200 agent runs over three weeks to exactly this. Another watched checkpoint tables reach 40 GB in six weeks with no retention policy. postmortem, three rewrites EZGraph: versioned session document + ordered migrations, applied before your code sees it.
02

Nothing stops two turns on one conversation

A user double-taps send. Two pods load the same checkpoint and both write. LangGraph does not arbitrate this — and PostgresSaver's instance-level lock is a throughput bottleneck, not a correctness guarantee.

Reported as silent lost updates, forked checkpoints, and cross-thread contamination under concurrency. langgraphjs#2040, write-skew EZGraph: exclusive turn lease + compare-and-swap. Second turn gets 409. Zero app code.
03

"Wait for the user" has no first-class form

The core move of a guided conversation. interrupt() re-runs the node from the top on resume, so the obvious "loop until the answer validates" pattern is quietly wrong — LangGraph's own docs warn it causes exponential re-execution.

The documented workaround is to rebuild a state machine around the primitive: one interrupt per node, error in state, conditional edge back. interrupts doc EZGraph: stay(). The wait is a persisted string. Nothing re-executes.
04

The model↔tool loop gets rewritten per stage

Bind tools, call the model, execute every call in the batch, append one ToolMessage each, bound the iterations. create_agent covers one ReAct loop — not five gated stages with per-stage tool sets. So you hand-roll, five times, and the copies drift.

In our own benchmark the hand-rolled version quietly regressed: it can only service one tool call per pass, so a real multi-tool batch drops a ToolMessage — which providers reject. EZGraph: one ConversationRunner, full batch semantics, round cap. 0 app lines.
05

Bad tool arguments become exceptions, not conversation

The model sends malformed JSON or a field that fails your schema. The right product behavior is to tell the model exactly what was wrong and let it fix itself. The default behavior is a stack trace and a 500.

Measured in our benchmark: 8 hand-written try/catch parse blocks and 14 tool-name dispatch checks — one more copy for every tool you add. EZGraph: returns { accepted: false, error } to the model and keeps going. A schema violation becomes a follow-up question.
06

Every team invents its own convention

The cost that compounds. Because state shape, persistence, waiting, routing, and tool dispatch are all left to the application, two teams solving the same problem ship two unrecognizable codebases. Onboarding starts from zero each time.

LangGraph's v1 thread is full of this: "the first node in my graphs is always an init_graph node that just sets everything up." langgraph#4973 EZGraph: five members per node, same order, every graph, every team.
To be clear

None of this means LangGraph is bad — it means LangGraph is low-level, which is exactly what it says on the tin. EZGraph does not replace it. Your graph still compiles to a real StateGraph and runs on the LangGraph engine. The choice here isn't between two runtimes; it's between writing the application layer yourself and adopting one.

The proof

One chatbot. Built twice. Measured.

Marketing claims are cheap, so we built the same product both ways and counted. Sequoia Auto Insurance — a five-stage guided quoting chatbot with 8 tools, 3 isolated history channels, real slot validation, backward transitions, and idle expiry — implemented once on EZGraph (quote-graph) and once directly on LangGraph with no EZGraph import (quote-langgraph).

51.9%

less framework-facing code for identical behavior

617 vs 1,283 normalized executable lines

Scope EZGraph LangGraph Δ
Framework-facing graph code 617 1,283 −51.9%
Shared domain backend 287 287 identical
NestJS controller 47 74 −36.5%
Raw lines, nothing removed 924 1,588 −41.8%

The rating engine, prompts, vehicle catalog, and clock helpers are the same in both and measure 287 lines each — which is how we know the delta is framework cost and not a difference in what the two products do.

0 hand-written reducers
LangGraph: 17 channels
0 lines of session store
LangGraph: 177
0 manual arg-parse blocks
LangGraph: 8
5 vs 14 edit sites to add a stage
lower is more modular

Where the 666 lines went

The additive source rows prove the headline. The repetition rows show which application work disappears without pretending that the same code can be added twice.

Concern LangGraph EZGraph Delta
Source artifacts — additive normalized lines
Main graph and all stage implementations980551−429
State definition and reducers8366−17
Session store — 3 backends + serialize/hydrate1770−177
Standalone domain type module430 types live with node state−43
Framework-facing graph code1,283617−666
Repetition counts — not additive line buckets
Manual Schema.parse(call.args) blocks80−8
Tool-name dispatch comparisons140−14
Explicit route: state writes160−16
Termination handling sites in graph code70 built-in node−7
Reducer helpers / channels2 × 170framework-owned
Domain tool declarations88same product scope
Explicit response sitesroute/branch updates5 go, 8 stay, 1 direct, 1 finishvisible contract
The count is intentionally narrow. It covers the current graph application code, excludes EZGraph itself, and excludes the matching domain backend. The direct graph's single implementation owns the repeated agent loop, dispatch, persistence boundary, and routing; EZGraph keeps the application-specific part in a graph definition and five nodes.
Same behavior, different code

Four excerpts that make the case

Real code from both implementations. Nothing here is a strawman — the LangGraph side is a competent, working, reviewed implementation of the same chatbot.

Every channel in LangGraph needs a reducer and a default. Get one wrong and you get a silent overwrite that surfaces three turns later.

quote-langgraph.state.ts 83 lines
const replace = <T>(_: T, next: T): T => next;
const appendMessages = (cur: BaseMessage[], up: BaseMessage | BaseMessage[]) =>
  cur.concat(Array.isArray(up) ? up : [up]);

export const QuoteLanggraphState = Annotation.Root({
  phase: Annotation<Phase>({ reducer: replace, default: () => "driver" }),
  route: Annotation<Route>({ reducer: replace, default: () => "end" }),
  completed: Annotation<boolean>({ reducer: replace, default: () => false }),
  response: Annotation<string>({ reducer: replace, default: () => "" }),
  userInput: Annotation<string>({ reducer: replace, default: () => "" }),
  inputConsumed: Annotation<boolean>({ reducer: replace, default: () => false }),
  config: Annotation<Record<string, unknown>>({
    reducer: (cur, up) => ({ ...cur, ...up }),
    default: () => ({}),
  }),
  driver: Annotation<DriverProfile | undefined>({
    reducer: replace, default: () => undefined,
  }),
  // ...9 more domain channels, each with reducer + default...
  intakeMessages: Annotation<BaseMessage[], BaseMessage | BaseMessage[]>({
    reducer: appendMessages, default: () => [],
  }),
  // ...2 more message channels...
});
17 channels · 2 hand-rolled reducers · 2 string unions to keep in sync by hand
quote-graph.state.ts 19 lines · −77%
export type QuoteGraphNodes = {
  DriverNode?: NodeStateValue<{ driver?: DriverProfile }>;
  VehicleNode?: NodeStateValue<{
    resolvedVehicleId?: string;
    vehicle?: VehicleUse;
  }>;
  HistoryNode?: NodeStateValue<{ history?: InsuranceHistory }>;
  CoverageNode?: NodeStateValue<{ coverage?: CoverageSelection }>;
  QuoteNode?: NodeStateValue<{
    tiers?: QuoteTier[];
    acceptedTier?: QuoteTierName;
    referenceNumber?: string;
  }>;
};

export const QuoteGraphState = createGraphStateAnnotation(
  DriverNode.id(),
  () => ({} as QuoteGraphNodes),
);

export type QuoteGraphStateType = typeof QuoteGraphState.State;
Zero reducers · per-node slices, so two stages cannot collide on a key
Note what the EZGraph version cannot express: two stages writing the same state key. Each slice is keyed by node, so a collision is a compile error instead of a debugging session.

Capturing a driver. Both versions run the same 25 lines of insurance validation — the difference is everything wrapped around it.

quote-langgraph.ts → driverTools dispatch + parse + transition
private readonly driverTools = async (state) => {
  const call = latestToolCall(state.intakeMessages);
  if (!call) return { route: "end" };
  if (call.name === "terminate_session")
    return terminateUpdate("intakeMessages", call);
  if (call.name !== "capture_driver") {
    return invalidToolUpdate("intakeMessages", call,
      `Tool '${call.name}' is not available here.`, "driverAgent");
  }
  let parsed: z.infer<typeof captureDriverSchema>;
  try {
    parsed = captureDriverSchema.parse(call.args);
  } catch (error) {
    return invalidToolUpdate("intakeMessages", call, zodError(error), "driverAgent");
  }
  const reject = (e: string) =>
    invalidToolUpdate("intakeMessages", call, e, "driverAgent");

  // ...25 lines of actual driver validation...

  return {
    driver,
    intakeMessages: toolResult(call, { accepted: true, driver }),
    phase: "vehicle",
    response: "",
    route: "vehicleAgent",
  };
};
Five invariants you must remember to set together, on every return path
nodes/driver.node.ts handler + decision
export class DriverNode extends ConversationNode<QuoteGraphStateType> {
  @Tool("capture_driver")
  async captureDriver(input: DriverInput): Promise<ToolResponse> {
    // ...the same 25 lines of deterministic driver validation...
    const driver = validateDriver(input);
    if (!driver.ok) return stay(JSON.stringify(driver.error));

    this.saveState({ driver: driver.value });
    return go(VehicleNode);
  }
}
The node saves its own valid fact, then returns one explicit response
saveState({ driver }) writes the owning node channel and go(VehicleNode) transitions immediately. The handler no longer depends on a context object, a post-tool hook, or an outcome router.

"Actually, raise my deductible." The customer's current message has to travel backward with them into the coverage stage — the transition hand-rolled graphs get subtly wrong most often.

quote-langgraph.ts → quoteTools 7 fields, by hand
return {
  presentMessages: toolResult(call, { accepted: true }),
  intakeMessages: new HumanMessage(
    "Review and update the coverage selections.",
  ),
  phase: "coverage",
  // Forward the customer's current message into the coverage stage.
  inputConsumed: false,
  response: "",
  route: "coverageAgent",
};
Miss inputConsumed: false and the customer has to repeat themselves
nodes/quote.node.ts → reviseCoverage 1 call
this.graph.appendHistory(this.graph.historySpace(CoverageNode.id()), [
  new HumanMessage(this.graph.input(this.graph.graphState())),
]);
return go(CoverageNode).withToolFeedback(
  "Review and update the coverage selections.",
);
The actual customer message is deliberately appended to the target history

Routing in LangGraph lives in three places that must agree — a state field, a router function, and an edge map — and nothing checks that they do. An unmapped return is a KeyError at invoke time, not at compile().

quote-langgraph.ts → buildGraph 68 lines with the routers
return new StateGraph(QuoteLanggraphState)
  .addNode("driverAgent", this.driverAgent)
  .addNode("driverTools", this.driverTools)
  .addNode("vehicleAgent", this.vehicleAgent)
  .addNode("vehicleTools", this.vehicleTools)
  // ...6 more addNode calls...
  .addConditionalEdges(START, routeFromPhase, agentRoutes)
  .addConditionalEdges("driverAgent",
    (s) => afterAgent(s, "driver"), { driverTools: "driverTools", end: END })
  .addConditionalEdges("vehicleAgent",
    (s) => afterAgent(s, "vehicle"), { vehicleTools: "vehicleTools", end: END })
  // ...3 more agent branches...
  .addConditionalEdges("driverTools", routeAfterTools, agentRoutes)
  .addConditionalEdges("vehicleTools", routeAfterTools, agentRoutes)
  // ...3 more tool branches...
  .compile();

// Plus: the agentRoutes map, routeFromPhase(), afterAgent(),
// routeAfterTools(), a Route string union in another file, and
// 16 inline `route:` writes scattered across the tool nodes.
Every node name appears as a string literal in at least three places
quote-graph.ts → buildGraph explicit registration
protected buildGraph() {
  const graph = this.createStateGraph(QuoteGraphState);
  graph.registerTurnNodes(
    DriverNode,
    VehicleNode,
    HistoryNode,
    CoverageNode,
    QuoteNode,
    TerminateSessionNode,
  );
  graph.addEdge(TerminateSessionNode, END);
  return graph.compile();
}
Endpoints are classes, validated at compile() — a typo is a build error, not a 3 a.m. KeyError
Tool-returned go(TargetNode) responses select conversational handoffs. The graph declares registered turn nodes and only genuine fixed worker edges.
How it works

The whole mental model, in five concepts

That's the entire thing. If you know these five, you can read any EZGraph graph in any team's repository.

A session is a document
One JSON row per conversation, keyed by session id, holding the chat histories, the active node, your per-node state, accumulated token usage, warnings and errors. Nothing lives in process memory between turns — and you can open the row in a database browser and read it.
A turn is one run()
engine.run({ graphName, sessionId, userMessage }) loads the document, takes an exclusive lease, runs the graph, saves, and returns an HTTP-shaped result. The turn is the unit of concurrency, cancellation, logging, and persistence.
A node is a stage
A class with a prompt, some tools, and a decision. It does not own persistence, message plumbing, or routing tables. Nodes are constructed once, shared across sessions, and frozen — so a per-turn value stored on this throws immediately instead of leaking across customers.
A tool response is the decision
stay(), go(Next), direct(text), or finish(text). Nodes save their durable facts before returning the response. The response then controls collection, transition, direct presentation, or completion without a separate context/hook lifecycle.
currentNode resumes
The document remembers which stage the conversation is sitting in. The next request enters that stage directly. That is why an interrupt here is a persisted string instead of a suspended coroutine — and why nothing re-executes on resume.

The contract every team learns once

Verified mechanically across all five stages of the benchmark app. Each node keeps the same small shape: prompt, tool definitions, and decorated handlers. The quote node adds getLlmConfig because it pays for a stronger presentation model.

NodeMembers
DriverNodegetPrompt · defineTool · @Tool ×1
VehicleNodegetPrompt · defineTool · @Tool ×2
HistoryNodegetPrompt · defineTool · @Tool ×1
CoverageNodegetPrompt · defineTool · @Tool ×1
QuoteNodegetPrompt · getLlmConfig · defineTool · @Tool ×3

The LangGraph version's five stages are not uniform: each is split across two arrow-function properties plus entries in four module-level maps, and each organizes its dispatch differently depending on how many tools it owns.

Adding a sixth stage

The honest test of modularity: how many places do you touch? In EZGraph, a stage is a file.

  • 1. Write nodes/discounts.node.ts — prompt, tools, handlers, validation, transition, all in one place
  • 2. Add one key to QuoteGraphNodes
  • 3. Add one line to historySpaces
  • 4. Add the class to registerTurnNodes(...)
  • 5. Point the upstream advance() at it
Steps 3–5 take the class, so forgetting one is a compile error. Routing needs no edit. The LangGraph equivalent is 14 edit sites, eleven of them one-line changes to shared maps and string unions that nothing cross-checks.
Enterprise-grade means it survives real models

The failure modes, handled by default

A demo works on the happy path. Production is providers refusing requests, models inventing arguments, users double-tapping send, and pods dying mid-turn. All of the following is automatic — no configuration, no application code.

Malformed tool arguments

Invalid JSON or a field that fails your Zod schema returns { accepted: false, error } to the model as the tool result, with the exact per-field message, plus a persisted warning. The loop continues and the model corrects itself. No throw, no hidden extra model call.

Empty & blocked responses

Classified as blocked, truncated, malformed_tool_call, complete, or unspecified. Retryable reasons get nudged and retried; the rest reach onEmptyModelResponse() with the classification, so a safety block can answer gracefully instead of returning silence.

Overlapping turns

An in-process guard plus an exclusive turn lease with compare-and-swap on revision. The second turn gets 409 SESSION_BUSY; a stale writer cannot overwrite a newer document. Leases expire, so a crashed pod never wedges a conversation forever.

Multi-tool batches

Every call in an assistant-message batch is executed and every corresponding ToolMessage appended before a stop is honored — because providers reject a conversation with a dangling tool call. A stop can never leave one unanswered.

Runaway loops & slow providers

maxAgentRounds bounds one node invocation instead of burning tokens forever. llmTimeoutMs bounds each provider request. The caller's AbortSignal cancels in-flight calls when the user disconnects, reaching shared node instances through AsyncLocalStorage.

Fails at boot, not mid-conversation

Unknown models, illegal model parameters, invalid round caps, non-contiguous migrations, duplicate tool definitions across nodes, handled-but-undefined tools, unregistered edge endpoints — all validated up front, before a customer ever sends a message.

Boilerplate you simply never write

Message array plumbing · tool-call ↔ tool-message pairing · tool argument parsing · session load and save · optimistic-concurrency retries · state reducers · resume dispatch from a persisted node · token counters · per-node model bookkeeping · provider client construction and caching · a mock gateway for tests.

For the enterprise

No account. No key. No control plane.

EZGraph is a library, not a platform. There is nothing to sign up for, no licence key to install, no telemetry endpoint, and no vendor observability cloud in the critical path. That is a deliberate architectural position, not a pricing tier.

Everything can stay inside your firewall

  • No LangSmith requirement. No EZGraph cloud, no mandatory tracing vendor.
  • Your database. SQLite, self-managed MongoDB, Cosmos DB, or an in-memory store — or implement SessionDocumentContainer for your own.
  • Your models. OpenAI, Azure OpenAI, Anthropic, Google — or an in-network endpoint via a custom provider adapter.
  • Your audit trail. The session document is the operational record: histories, node state, token totals, warnings, errors, and which model actually answered.
  • Credentials never enter a cache key. Provider config is excluded from the model cache identifier by construction.

Said precisely, because you will check: EZGraph itself needs no key. You still bring your own model credentials — or point it at a self-hosted endpoint, in which case no data leaves your network. Choosing a hosted model or database obviously sends data to that provider; that is your choice, not a framework requirement.

The commercial runtime licence, in plain terms

Production use is permitted with no runtime fee. The public package ships minified runtime code and TypeScript declarations; an active support subscription includes a private source and debug package.

Commercial SaaS built on itallowed
Internal enterprise productionallowed
Client and consulting workallowed
Keeping your app code closedallowed
Using the published runtime in productionallowed
Paying a runtime licence feenot required
Private source/debug package with active supportincluded
Repackaging it as a rival agent SDKnot allowed
Reselling it as a hosted competing servicenot allowed

In one sentence: build and run your application with it — just do not repackage it as a competing library, framework, or hosted service. Read the licence.

The demo

A running reference app, not a snippet

ezgraph-demo is a real NestJS + Fastify service with Swagger, three graphs, and both sides of the benchmark. Clone it and the whole comparison on this site is reproducible on your machine. The QuoteGraph walkthrough is the reading guide.

Q

QuoteGraph

The five-stage insurance quoting chatbot on EZGraph. Slot validation, a deterministic rating engine, three history channels, per-node model override, backward transitions, idle expiry.

L

QuoteLanggraph

The identical product written directly on LangGraph, importing no EZGraph code. This is the control group — read it and judge the comparison yourself.

E

ExpenseGraph

A JSON-mode graph that extracts structured expense data from an uploaded receipt — showing provider file uploads, cleanup ownership, and validated JSON output.

try it
# One HTTP call per turn. The SESSION_ID header carries the conversation.
curl -s localhost:8000/ai/run \
  -H 'content-type: application/json' \
  -H 'SESSION_ID: demo-1' \
  -d '{"graphName":"QuoteGraph","message":"I need car insurance"}'

# => { "success": true, "completed": false,
#      "message": "Happy to help. Can I start with your full name..." }
#
# Send the next message with the same SESSION_ID days later,
# from a different pod. It resumes at exactly the right stage.
wiring it to your framework the whole adapter
@Post("run")
async run(
  @Res() reply: FastifyReply,
  @Body() body: ApiRunBodyDto,
  @Headers("SESSION_ID") sessionId?: string,
) {
  const result = await this.graphEngine.run({
    graphName: body?.graphName,
    userMessage: body?.message,
    sessionId,
  });

  if (result.session) reply.header("SESSION_ID", result.session);
  return reply.status(result.status).send(result.body);
}
run() returns { status, body, session } — HTTP shaped, with no HTTP dependency in the framework. Express, Fastify and NestJS are all pass-throughs.
$ git clone https://github.com/picoflowio/ezgraph-demo.git
Read this before you adopt

What EZGraph does not do

Any comparison that finds no downsides is marketing. These are real, and we would rather you hit them on this page than in week three.

The big one

No streaming. Turns are request/response today. LangGraph's token-by-token streaming and astream_events have no EZGraph equivalent. If your product needs streaming output now, use LangGraph directly — this is disqualifying and we are not going to pretend otherwise.

  • No escape hatch to StateGraph. The underlying graph is private and there is no sanctioned wrapLangGraph() adapter. Exotic LangGraph features are reachable only if the facade exposes them. That is the price of validated topology.
  • No arbitrary node functions. Every node is a class registered through the facade, so the framework can construct it, register its tools, and verify its durable id.
  • Topology validation is lazy. buildGraph() runs on first access, so a duplicate tool definition surfaces as a 400 on the first message rather than at boot. Work around it with void new MyGraph(gateway).graph; after registration. A proper eager- validation entry point is a known gap.
  • Class names are durable ids. Renaming a node or graph class orphans live sessions unless you override static id() to return the old string. This is the sharpest edge in the framework.
  • Fixed top-level state shape. You extend nodes and config; you don't add top-level fields.
  • Not built yet: history compaction and context budgets, per-session token or cost budgets, OpenTelemetry spans.
  • TypeScript / Node only. Node ≥ 22.5. If you're in Python, this isn't for you.
  • The 51.9% figure is one data point. One five-stage guided conversation, written by the same authors on both sides. The per-concern table is far more portable than the headline percentage, because it tells you which work disappears — persistence, request boundary, tool dispatch, agent loop, reducers. Need all five? Expect a similar result. Need none of them? Don't adopt this.
Still the right call when: the product is a multi-turn guided conversation on a TypeScript backend — ordered stages with gates, slot filling with real validation, waits measured in days, an auditable per-conversation record, ordinary HTTP semantics. Above all when more than one team will build these, because a shared contract compounds in a way that a line count never does.
Quick start

Three minutes to a running graph

Install the published @picoflow/ezgraph npm package, then follow the tutorial to build a real two-stage conversation end to end.

$ npm install @picoflow/ezgraph
$ npm install @langchain/openai # or @langchain/anthropic, @langchain/google
main.ts
import "reflect-metadata";          // once, at the very top of your entry point
import { GraphEngine } from "@picoflow/ezgraph";
import { WeatherGraph } from "./weather-graph.js";

const engine = await GraphEngine.create({ graphs: [WeatherGraph] });

const result = await engine.run({
  graphName: "WeatherGraph",
  sessionId: "demo-1",
  userMessage: "hi",
});

// { status: 200, body: { success: true, completed: false,
//                        message: "Which city would you like the weather for?" } }
// Persisted: currentNode = "CollectNode"
Two setup gotchas, up front

Tool handlers use a method decorator, so your tsconfig.json needs experimentalDecorators and emitDecoratorMetadata. And reflect-metadata must be imported once, before any node module loads — without it, @Tool metadata isn't collected and your nodes will look like they have no tools. Both are covered in §3 of the developer guide.

FAQ

The questions a LangGraph developer actually asks

Does this replace LangGraph?

No — it runs on it. Your graph compiles to a real StateGraph and LangGraph executes it. EZGraph replaces the layer around the runtime: checkpointers become an application-owned session document, hand-written reducers become a fixed state shape, conditional-edge tables become semantic outcomes, and the model↔tool loop moves into the framework. LangGraph and LangChain stay in your dependency tree.

Is it really free for commercial and enterprise use?

Yes. The EZGraph Commercial Runtime License permits commercial SaaS, internal production, client work, and closed application code with no runtime licence fee or key. You may not repackage it as a competing agent SDK, developer library, plugin framework, or competing hosted service. Optional support is separately priced and includes the private source and debug package while the subscription is active.

Do I need an API key or an account?

Not for EZGraph. There is no signup, no licence key, no telemetry endpoint and no cloud control plane. You do need credentials for whichever model provider you choose — or you can point EZGraph at a self-hosted in-network endpoint through a custom provider adapter, in which case nothing leaves your firewall.

What happens to my existing LangGraph app?

The developer guide has the current migration checklist. Per-stage fields become typed node-state channels; each node function becomes a class with getPrompt(), defineTool(), and decorated handlers; the hand-rolled model↔tool loop gets deleted; and stage flags plus conditional edges become explicit stay()/go()/direct()/finish() returns. Register conversational nodes with registerTurnNodes(...) and retain addEdge(...) only for genuine fixed worker edges.

Does it add runtime overhead?

The execution path is LangGraph's. What EZGraph adds per turn is a session-document read, a lease compare-and-swap, and a write — the same work a production LangGraph app does with a checkpointer, minus a snapshot per super-step. Node instances are constructed once and shared, the compiled graph is built once and cached, and provider clients are cached with credentials excluded from the cache key.

How do I test a conversation without burning tokens?

ezgraph/testing is a separate entry point, so nothing in it ships in your runtime path. scriptedGateway() queues model turns declaratively — .text(), .callsTool(), .callsTools(), .empty(finishReason) to simulate a safety block, .fail() for a provider outage — and records what the graph actually asked for. createTurnHarness() drives real GraphEngine.run() calls against an in-memory store, so a test exercises the whole path: lease, restore, invoke, persist.

Can I use multiple models in one graph?

Yes, and the benchmark app does exactly that: collection stages run on gpt-4o-mini and only the stage that explains price trade-offs overrides to gpt-5.1. Override getLlmConfig() per node. Model parameters are typed against that model's family profile, so temperature on a reasoning model is a compile error. You can also declare requiredCapabilities (pdfInputs, toolCalling, structuredOutput…) and have startup fail loudly if the model can't do it. The effective model is persisted into the session document, so you always know which one answered.

What if I need something the facade doesn't expose?

Then this is the honest answer: you may be stuck. The underlying StateGraph is deliberately private, because raw access would bypass node registration, tool validation, and endpoint checks — the very things that turn a routing typo into a build error. Fixed edges, fan-in, explicit branchBy() routing and fully custom run() node bodies are all supported. Beyond that, read the limits before you commit.

Is the source available? Can I audit it?

The public distribution contains a minified runtime and public TypeScript declarations, with selective obfuscation of implementation-sensitive internals. It has no phone-home. An active support subscription includes a private source and debug package for audit and troubleshooting. For a security review the interesting surfaces are the session document, the lease compare-and-swap, and the provider adapters.

An on-ramp, not a forced migration

Stay with LangGraph. Move toward PicoFlow only when the fit changes.

EZGraph is a complete production option for teams that want to keep LangGraph as their graph runtime. PicoFlow is a separate, broader application model for teams that want customer-facing business flows expressed as ordinary composable steps.

EZ

Choose EZGraph

You already use LangGraph, want its execution model to remain underneath your application, and want the repeated session, tool-loop, persistence, and testing machinery supplied without an EZGraph key or runtime fee.

PF

Consider PicoFlow later

Your requirements have shifted from graph-level orchestration toward a shared conversational-application runtime built around named flows, domain steps, durable business state, and normal program composition.

See how PicoFlow works · Compare PicoFlow with LangGraph

You already picked LangGraph. Don't write the application twice.

Read the benchmark, judge the code, and decide in an afternoon. Nothing to sign up for, and the control-group implementation is in the repo so you can check every number on this page.