@corti/agent-sdk 0.1.0-alpha

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).

Typed stateA plain TypeScript interface that accumulates across every node. Each node returns Partial<S> which is shallow-merged in.
NodesAsync functions (state) => Partial<S>. agentNode() wraps an AgentHandle into this shape.
EdgesStatic node names, END, or routing functions that inspect state and return the next node. Run after the node updates state.
Cycles + maxIterationsLoops are supported. The 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
NodeInput (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 ?? "",
  }),
)
ArgumentTypePurpose
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

  1. triage runs → sets severity: "urgent" → edge routes to "coder".
  2. coder runs → sets codes: "I21.9, R07.9" → static edge routes to "reviewer".
  3. reviewer runs → sees both the note and the codes → returns "rejected: codes lack specificity"approved is set to false.
  4. Routing function inspects state.approved === false → routes back to "coder".
  5. coder runs again — this time the reviewer's feedback is visible in state.reviewerFeedback (the coder's system prompt could reference it for a smarter retry).
  6. Eventually the reviewer approves, the edge returns END, and the graph terminates with terminatedBy: "end".
maxIterations is the circuit breaker. With 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

FieldTypeDescription
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()
ShapeOrdered list of stepsNamed nodes with explicit edges
Shared statePrevious response text onlyTyped object, accumulated across all nodes
Branchingwhen predicate (skip or run)Routing function — pick any node by name
CyclesNot supportedSupported, bounded by maxIterations
Best forKnown, fixed pipelinesConditional 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

Final state: Severity: urgent ICD-10 codes: I21.9, R07.4, R00.0 Reviewer feedback: approved: I21.9, R07.4, R00.0 Approved: true Execution trace: [triage] severity [coder] codes [reviewer] reviewerFeedback, approved Iterations: 3 Terminated by: end
If the reviewer rejects the codes, the trace would show [coder] and [reviewer] multiple times — once per cycle. iterations would reflect the total node executions, not the number of unique nodes.

Example with a rejection loop

Execution trace: [triage] severity [coder] codes [reviewer] reviewerFeedback, approved ← rejected [coder] codes ← second attempt [reviewer] reviewerFeedback, approved ← approved Iterations: 5 Terminated by: end

Next steps