07 — State graph
stateGraph() is the most powerful composition primitive. Unlike workflow(), it carries a typed state object across nodes and routes dynamically between them — including cycles. This example implements a clinical triage pipeline: triage → code → review → (loop back if rejected).
Partial<S> which is shallow-merged in.(state) => Partial<S>. agentNode() wraps an AgentHandle into this shape.END, or routing functions that inspect state and return the next node. Run after the node updates state.maxIterations option (default 25) acts as the safety net against infinite cycles.Run it
npm run state-graph
The script logs the final state (severity, codes, reviewer feedback, approval), the execution trace (every node that ran), the total iteration count, and the termination reason.
Graph structure
The example models a three-node clinical triage pipeline with a reviewer loop:
// Diagram
//
// triage ──(urgent?)──► coder ──► reviewer ──(approved?)──► END
// │ │
// ▼ (routine) ▼ (rejected)
// END coder ← loop, bounded by maxIterations
| Node | Input (from state) | Output (state patch) |
|---|---|---|
triage |
state.note |
{ severity: "urgent" | "routine" } |
coder |
state.note |
{ codes: "I21.9, R07.9" } |
reviewer |
"Note: ...\n\nProposed codes: ..." |
{ reviewerFeedback: "...", approved: true|false } |
agentNode()
agentNode() wraps an AgentHandle into a node function. It takes three arguments:
agentNode(
agent, // AgentHandle to call
(state) => state.note, // getInput: extract the agent's input from state
(response, state) => ({ // merge: return the state patch
codes: response.text ?? "",
}),
)
| Argument | Type | Purpose |
|---|---|---|
agent |
AgentHandle |
The agent to invoke. |
getInput(state) |
(state: S) => string | MessagePart[] |
Called with the current state to produce the agent's input message. |
merge(response, state) |
(r: MessageResponse, s: S) => Partial<S> |
Called after the agent responds. Return the state patch to merge in. |
You can also write a raw node function directly if you need more control:
graph.addNode("custom", async (state) => {
const result = await myAgent.run(state.note);
return { severity: result.text?.trim() ?? "" };
})
Walkthrough
1 · Define the state type
interface TriageState {
note: string; // the original clinical note — never changes
severity: string; // set by triage node: "urgent" or "routine"
codes: string; // set by coder node: comma-separated ICD-10 codes
reviewerFeedback: string; // set by reviewer: "approved: ..." or "rejected: ..."
approved: boolean; // derived from reviewerFeedback
}
State is a plain TypeScript interface. Every node sees the full accumulated state — later nodes can read values set by earlier nodes.
2 · Create and wire the agents
const triageAgent = await agents.create({
name: "sg-triage",
systemPrompt: 'Reply with exactly one word: "urgent" or "routine". No punctuation.',
});
const coderAgent = await agents.create({
name: "sg-coder",
systemPrompt: "Suggest up to three ICD-10 codes. Format: comma-separated codes only.",
});
const reviewerAgent = await agents.create({
name: "sg-reviewer",
systemPrompt:
'Your reply MUST begin with exactly "approved:" or "rejected:" (lowercase, followed by a colon).',
});
Each system prompt is engineered for deterministic, parseable output. The reviewer's "approved:" / "rejected:" prefix is what the routing function inspects.
3 · Build the graph
const graph = stateGraph<TriageState>()
.addNode("triage",
agentNode(triageAgent, (s) => s.note,
(r) => ({ severity: r.text ?? "" })))
.addNode("coder",
agentNode(coderAgent, (s) => s.note,
(r) => ({ codes: r.text ?? "" })))
.addNode("reviewer",
agentNode(reviewerAgent,
(s) => `Note: ${s.note}\n\nProposed codes: ${s.codes}`,
(r) => ({
reviewerFeedback: r.text ?? "",
approved: (r.text ?? "").trim().toLowerCase().startsWith("approved"),
})))
// triage: only code urgent cases
.addEdge("triage", (s) =>
s.severity.toLowerCase().includes("urgent") ? "coder" : END)
// coder always goes to reviewer
.addEdge("coder", "reviewer")
// reviewer: loop back to coder if rejected
.addEdge("reviewer", (s) => (s.approved ? END : "coder"));
4 · Run the graph
const initialState: TriageState = {
note: "Patient presents with sudden onset chest pain ...",
severity: "",
codes: "",
reviewerFeedback: "",
approved: false,
};
const result = await graph.run("triage", initialState, { maxIterations: 10 });
graph.run(startNode, initialState, options) — the first argument is the name of the first node to execute.
5 · How the reviewer loop works
triageruns → setsseverity: "urgent"→ edge routes to"coder".coderruns → setscodes: "I21.9, R07.9"→ static edge routes to"reviewer".reviewerruns → sees both the note and the codes → returns"rejected: codes lack specificity"→approvedis set tofalse.- Routing function inspects
state.approved === false→ routes back to"coder". coderruns again — this time the reviewer's feedback is visible instate.reviewerFeedback(the coder's system prompt could reference it for a smarter retry).- Eventually the reviewer approves, the edge returns
END, and the graph terminates withterminatedBy: "end".
maxIterations: 10, the graph will stop after 10 total node executions even if the reviewer never approves. Check terminatedBy === "maxIterations" if you need to handle this case.
Result shape
| Field | Type | Description |
|---|---|---|
state |
S |
The final accumulated state after all nodes ran. |
steps |
StateGraphStep<S>[] |
Per-node history. Each entry: { node, delta, state }. Repeated nodes (from cycles) each get their own entry. |
iterations |
number |
Total node executions, including repeated nodes. Always equals steps.length. |
terminatedBy |
"end" | "maxIterations" | "noEdge" |
Why the graph stopped. "end" = edge returned END. "maxIterations" = cycle budget exhausted. "noEdge" = a node had no registered edge. |
Each step entry in the trace:
interface StateGraphStep<S> {
node: string; // which node ran
delta: Partial<S>; // what it added/changed
state: S; // full state after this node's delta was merged
}
vs workflow()
workflow() | stateGraph() | |
|---|---|---|
| Shape | Ordered list of steps | Named nodes with explicit edges |
| Shared state | Previous response text only | Typed object, accumulated across all nodes |
| Branching | when predicate (skip or run) | Routing function — pick any node by name |
| Cycles | Not supported | Supported, bounded by maxIterations |
| Best for | Known, fixed pipelines | Conditional flows, review loops, dynamic routing |
Full code
Source: examples/ts/07-state-graph.ts
/**
* 07 — Stateful graph routing.
*
* Unlike a linear Workflow, a StateGraph accumulates typed shared state
* across nodes and uses routing functions to decide what runs next —
* including cycles (bounded by maxIterations).
*
* This example models a clinical triage pipeline:
*
* triage ──► coder ──► reviewer ──► END
* ▲ │
* └───────────┘ (re-codes if reviewer rejects)
*
* Run: `npm run state-graph`
*/
import { AgentsClient, END, agentNode, stateGraph } from "@corti/agent-sdk";
import { makeClient } from "./_client";
interface TriageState {
note: string;
severity: string;
codes: string;
reviewerFeedback: string;
approved: boolean;
}
async function main() {
const agents = new AgentsClient(makeClient());
const triageAgent = await agents.create({
name: "sg-triage",
description: "Classifies clinical urgency.",
systemPrompt:
'Read the clinical note and reply with exactly one word: "urgent" or "routine". No punctuation.',
});
const coderAgent = await agents.create({
name: "sg-coder",
description: "Assigns ICD-10 codes to a clinical note.",
systemPrompt:
"Suggest up to three ICD-10 codes for the clinical note. Format: comma-separated codes only.",
});
const reviewerAgent = await agents.create({
name: "sg-reviewer",
description: "Reviews proposed ICD-10 codes.",
systemPrompt:
'Review the proposed ICD-10 codes for the clinical note. Your reply MUST begin with exactly "approved:" or "rejected:" (lowercase, followed by a colon). No preamble, no other leading text. After the colon include the codes (if approved) or a brief reason (if rejected).',
});
const graph = stateGraph<TriageState>()
.addNode(
"triage",
agentNode(
triageAgent,
(s) => s.note,
(r) => ({ severity: r.text ?? "" }),
),
)
.addNode(
"coder",
agentNode(
coderAgent,
(s) => s.note,
(r) => ({ codes: r.text ?? "" }),
),
)
.addNode(
"reviewer",
agentNode(
reviewerAgent,
(s) => `Note: ${s.note}\n\nProposed codes: ${s.codes}`,
(r) => ({
reviewerFeedback: r.text ?? "",
approved: (r.text ?? "").trim().toLowerCase().startsWith("approved"),
}),
),
)
// Only code urgent cases; discharge routine ones immediately.
.addEdge("triage", (s) =>
s.severity.toLowerCase().includes("urgent") ? "coder" : END,
)
.addEdge("coder", "reviewer")
// Loop back to coder if reviewer rejects; maxIterations acts as the safety net.
.addEdge("reviewer", (s) => (s.approved ? END : "coder"));
const initialState: TriageState = {
note: "Patient presents with sudden onset chest pain radiating to the left arm, diaphoresis, and shortness of breath for 45 minutes.",
severity: "",
codes: "",
reviewerFeedback: "",
approved: false,
};
const result = await graph.run("triage", initialState, { maxIterations: 10 });
console.log("Final state:");
console.log(" Severity: ", result.state.severity);
console.log(" ICD-10 codes: ", result.state.codes);
console.log(" Reviewer feedback:", result.state.reviewerFeedback);
console.log(" Approved: ", result.state.approved);
console.log("\nExecution trace:");
for (const step of result.steps) {
console.log(` [${step.node}]`, Object.keys(step.delta).join(", "));
}
console.log("\nIterations:", result.iterations);
console.log("Terminated by:", result.terminatedBy);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
What to expect
[coder] and [reviewer] multiple times — once per cycle. iterations would reflect the total node executions, not the number of unique nodes.