2. Graph, state, and ownership
QuoteGraph has no shared “current quote” object that every stage edits. Each accepted fact lives
in the state channel of the node that validated it, and the registry of those channels is the only
state shape the graph has. This lesson reads the two files that define the graph and its state.
The goal
- Read a complete
GraphDefinitionand know what each field controls. - Register a topology in which every transition is a runtime choice.
- Declare domain types once and build the node-state registry from them.
- Know which node writes each value, by which mechanism, and who reads it.
The graph definition
From quote-graph.ts:
const DEFAULT_IDLE_MS = 30 * 60_000;
export class QuoteGraph extends BaseGraph<QuoteGraphStateType> {
static getGraphDefinition(): GraphDefinition {
return {
llmConfig: ModelCatalog.model("openai:gpt-5.4", {
retries: 3,
reasoningEffort: "low",
}),
endNode: GRAPH_END_NODE,
historySpaces: [
[DriverNode, "quote-intake"],
[VehicleNode, "quote-intake"],
[HistoryNode, "quote-incidents"],
[CoverageNode, "quote-intake"],
[QuoteNode, "quote-present"],
[TerminateSessionNode, "quote-terminal"],
],
};
}
constructor(llmGateway: LlmGateway) {
super(llmGateway, QuoteGraph.getGraphDefinition());
}
// onRestoreSessionDoc() and buildGraph() below
}
| Field | Value | Effect |
|---|---|---|
llmConfig |
openai:gpt-5.4, three retries, reasoningEffort: "low" |
The default model for every node. ModelCatalog.model() checks the parameters against the model’s catalog entry, so a parameter the model does not accept is a type error. |
endNode |
GRAPH_END_NODE ("end") |
The currentNode value of a completed session |
historySpaces |
four spaces | Which conversation each node reads and appends to; see lesson 7 |
The state schema declares DriverNode as the initial currentNode. EZGraph
resolves that cursor before appending the first user message, so the message
uses its "quote-intake" history mapping. Later turns use the persisted current
node’s mapping; unmapped nodes use "default".
Everything else uses the framework defaults: eight model-and-tool rounds per node invocation, no
per-request timeout, an internal "Start" message to seed an empty history, and two nudges
after an empty model reply. GraphEngine constructs the graph and passes it a gateway for this
model; the graph never sees provider credentials.
The class comment says model authentication uses “the local Codex OAuth session”. That is the separate openai-auth provider. This graph selects openai:gpt-5.4, which goes through the application's openai provider and its OPENAI_API_KEY, as registered in app.module.ts.
Topology
protected buildGraph() {
const graph = this.createStateGraph(QuoteGraphState);
graph.registerTurnNodes(
DriverNode, VehicleNode, HistoryNode, CoverageNode, QuoteNode, TerminateSessionNode,
);
graph.addEdge(TerminateSessionNode, END);
return graph.compile();
}
registerTurnNodes() adds each class to the graph and to the START branch that resumes a later
turn at the persisted currentNode. Registration is also an allowlist: a handler can only
go() to a registered class. The one fixed edge connects TerminateSessionNode to END. Forward
moves, the backward move from QuoteNode to CoverageNode, and completion are all builders a
handler returns at run time.
compile() validates every node before the graph can serve a turn. Each @Tool handler must
match a tool some node defines, and no tool may be defined twice. LlmNode inherits a
terminate_session handler, which is why TerminateSessionNode, the node that defines that tool,
must be registered even though no forward edge leads to it.
Domain types first
quote-graph.state.ts starts with the business vocabulary, independent of EZGraph:
export type DriverProfile = {
fullName: string;
dateOfBirth: string; // YYYY-MM-DD
licenseState: string; // two-letter U.S. state code, uppercase
licenseStatus: "valid" | "permit";
yearsLicensed: number;
};
export type VehicleUse = {
vehicleId: string; // catalog ID resolved during the vehicle stage
ownership: "own" | "finance" | "lease";
annualMileage: number;
parking: "garage" | "driveway" | "street";
};
export type InsuranceHistory = {
currentlyInsured: boolean;
coverageLapse: boolean;
incidents: { type: IncidentType; date: string }[]; // YYYY-MM, last five years
};
export type CoverageSelection = {
liability: "state-minimum" | "standard" | "premium";
collisionDeductible: 250 | 500 | 1000 | null; // null = no collision coverage
comprehensiveDeductible: 250 | 500 | 1000 | null;
extras: ("rental" | "roadside")[];
startDate: string; // YYYY-MM-DD, today through +60 days
};
export type QuoteTier = {
tier: "saver" | "selected" | "shield";
coverage: CoverageSelection;
monthlyPremium: number;
};
The types encode decisions the rest of the code relies on. licenseStatus has no "suspended":
the driver tool’s schema accepts it so the handler can refuse it politely, but a saved driver can
never be suspended. null deductibles mean “no coverage”, which is how the lender rule is
expressed.
The state registry
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;
}>;
};
/** Domain state owned by one quote node, excluding framework metadata. */
export type QuoteGraphNodeState<NodeName extends keyof QuoteGraphNodes> = Omit<
NonNullable<QuoteGraphNodes[NodeName]>,
"model"
>;
export const QuoteGraphState = createGraphStateAnnotation(
DriverNode.name,
() => ({}) as QuoteGraphNodes,
);
export type QuoteGraphStateType = typeof QuoteGraphState.State;
- The first argument to
createGraphStateAnnotation()makesDriverNodethe entry node of every new session. NodeStateValue<...>adds framework metadata to each channel, such as themodela node used when it overrides the graph default.QuoteGraphNodeState<"VehicleNode">strips that metadata for handler code. Nodes extendLlmNode<QuoteGraphStateType>with a single generic, so where a handler needs the exact channel type it casts once:this.getState() as QuoteGraphNodeState<"VehicleNode">.
Who writes what
| Value | Owner | Written by | Read by |
|---|---|---|---|
driver |
DriverNode |
capture_driver, with saveState() |
buildRatingSubject(); VehicleNode’s entry prompt |
resolvedVehicleId |
VehicleNode |
resolve_vehicle, when exactly one vehicle matches |
VehicleNode’s prompt and capture_vehicle_use |
vehicle |
VehicleNode |
capture_vehicle_use |
CoverageNode’s prompt and handler; buildRatingSubject() |
history |
HistoryNode |
capture_history |
buildRatingSubject() |
coverage |
CoverageNode |
select_coverage; also adjust_quote in QuoteNode, with graph.saveNodeState() |
adjust_quote, as the base for the next change |
tiers |
QuoteNode |
select_coverage in CoverageNode, with graph.saveNodeState(); adjust_quote |
QuoteNode’s prompt; accept_quote |
acceptedTier, referenceNumber |
QuoteNode |
accept_quote |
the tests |
Two cross-node writes stand out, and both happen inside a tool handler rather than on a transition:
CoverageNodeseedsQuoteNode.tiersbefore it returnsgo(QuoteNode). That is the classic hand-off: the target starts with exactly the list it may present.go(QuoteNode).withState({ tiers })would express the same thing on the transition itself.QuoteNodewritesCoverageNode.coverageon every adjustment. The customer’s coverage really has changed, and a laterrevise_coverageshould start from it. But it means two nodes write one value, so the registry’s “one owner” is a convention rather than a guarantee.
Shared writes are easy to miss in review. If you keep them, name them in a comment at both call sites, as a table like the one above does. The alternative is to keep an adjusted selection in QuoteNode's own channel and have CoverageNode read it when the customer revises. That is more code, but each value then has a single writer.
Why it is written this way
When DriverNode saves a validated driver, no other stage has to infer from the transcript which
name or birth date was accepted. The rating engine reads committed channels, tests assert them
directly, and a customer’s correction to coverage cannot silently change their driver record.
Domain types in the state file give every one of those readers the same vocabulary.
Common mistakes
- One shared mutable “quote” object. It makes every stage responsible for every field; keep one channel per stage.
- Forgetting
TerminateSessionNode. A graph with aLlmNodefails to compile without it. - Casting state on every line. Cast once to
QuoteGraphNodeState<...>at the top of a handler. - Cross-node writes without a note. Document every place a node writes another node’s channel.
- Credentials in the graph. The graph names a model; the application registers providers and keys.
Next
Continue to 3. Anatomy of a conversation turn.