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

Graph topology and deterministic policy

Register conversation ownership and internal work, then keep validation, authorization, and commit decisions in code.

Build topology explicitly

Register conversational entry points, then declare fixed worker edges. Tool responses select conversational handoffs or conditional fan-out.

protected buildGraph() {
  const graph = this.createStateGraph(QuoteGraphState);
  graph.registerTurnNodes(
    DriverNode,
    VehicleNode,
    HistoryNode,
    CoverageNode,
    QuoteNode,
    TerminateSessionNode,
  );
  graph.addEdge(TerminateSessionNode, END);
  return graph.compile();
}

Conversational LlmNodes inherit terminate_session. Graphs with these nodes must register TerminateSessionNode, including one-shot extraction graphs whose nodes own responses and completion, then connect it to END. Internal LLM workers do not offer that tool.

Conditional fan-out and join

Return fanout(Child1Node, Child2Node) only when the parent has accepted and saved the input. In DemoGraph, the movie-recording tool is that boundary:

@Tool("recordMovieIdea")
async recordMovieIdea(submitted: MovieIdea): Promise<ToolResponse> {
  this.saveState({ movieIdea: submitted });
  return fanout(Child1Node, Child2Node);
}

Both children are ordinary LlmNodes that save their output in onResponse(). Register all nodes, identify the conversational owners, and connect an explicit two-source barrier. This excerpt shows the fan-out portion of the graph:

protected buildGraph() {
  const graph = this.createStateGraph(DemoGraphState);
  graph.nodes(
    InContextNode,
    Child1Node,
    Child2Node,
    DobNode,
    TerminateSessionNode,
  );
  graph.registerTurns(InContextNode, DobNode, TerminateSessionNode);
  graph.addEdge([Child1Node, Child2Node], DobNode);
  graph.addEdge(TerminateSessionNode, END);
  return graph.compile();
}

Leaving the children out of registerTurns() makes them internal work. Each uses the shared runner, model config, tools, response handling, and error hooks, but publishes only its own node state and token usage. The state reducers merge the independent child channels and sum their usage.

The multi-target transition sets inputConsumed: true and clears the current reply while leaving currentNode with InContextNode. Neither child takes ownership of the next user turn. The array-source edge waits for both children and runs DobNode once with their results; its conversational reply makes DOB the next turn owner. If a child fails without recovery, DOB does not run.

Do not add unconditional child edges out of the conversational parent. Static edges run independently of a command’s destinations, including when the parent returns an ordinary reply or routes to termination. Conditional fanout() keeps rejected movie arguments, ordinary replies, and termination out of the fan-out. See response effects for multi-target builder rules.

Sequential and fixed-entry workers

The same child classes can run sequentially. With a turn registry, leave them out of registerTurns() and connect the worker pipeline. For a fixed-entry graph, workers() registers missing classes and marks them as internal. Connect START and continuation edges explicitly. For example, an internal-only pipeline can use:

const WorkerState = createGraphStateAnnotation(Child1Node.id());
const graph = this.createStateGraph(WorkerState);
graph.workers(Child1Node, Child2Node);
graph.addEdge(START, Child1Node);
graph.addEdge(Child1Node, Child2Node);
graph.addEdge(Child2Node, END);
return graph.compile();

Scheduling differs; the node implementations do not. Nested callers use the same invocation contract.

Keep policy deterministic

The model may collect a request, but deterministic code owns eligibility, prices, IDs, durable commits, and irreversible transitions.

const adjudication = PolicyEngine.adjudicate(order, request.lineIds, request.reason);
if (adjudication.decision === "review") {
  return go(ApprovalNode).withState({
    pending: { request, quote: adjudication.quote!, reasons: adjudication.reasons },
  });
}

For an approval gate, generate the first pending-refund presentation from the saved quote in code. The model should not invent a money amount, RMA, ticket identifier, or completion claim.