Skip to main content

Graph

Normative: MODEL.md §3.

A graph is a set of channels (the state), nodes (the work), and edges (the flow). It is always data: the compiled form is derived from a spec, never the other way round.

import { graph, channel, START, END, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";

type Cart = { items: string[] };

/** Support state — messages, a cart, and the detected intent. */
const SupportState = {
messages: channel.append<string>(),
cart: channel.lastWrite<Cart>({ items: [] }),
intent: channel.lastWrite<string>(""),
} satisfies ChannelMap;

type SupportState = StateOf<typeof SupportState>;

// Stand-in node bodies — swap for your real agent + checkout code.
const Agent = { run: async (state: SupportState) => ({ intent: "buy" }) };
const Checkout = { run: async (state: SupportState) => ({ messages: ["order placed"] }) };

const g = graph("support", SupportState)
.node("agent", Agent.run)
.node("checkout", Checkout.run)
.edge(START, "agent")
.edge("agent", "checkout", (state) => state.intent === "buy") // conditional
.edge("checkout", END)
.compile();

const { status } = await run(g, { messages: ["hi"] });
console.log(status); // "done"

Nodes​

A node is an async (state, ctx) => update function. It reads state, does its work through ctx, and returns a partial update that the engine folds into channels. Two node names are reserved:

  • START (__start__) — the virtual entry.
  • END (__end__) — the virtual exit.

Both are implicit and have no body.

Edges​

A plain edge connects two nodes unconditionally. A conditional edge takes a predicate; when it passes, the edge is taken:

import { graph, channel, START, END, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";

/** Routing state — an intent that decides the next hop. */
const RouteState = {
intent: channel.lastWrite<string>(""),
log: channel.append<string>(),
} satisfies ChannelMap;

type RouteState = StateOf<typeof RouteState>;

const g = graph("route", RouteState)
.node("agent", async (state) => ({ intent: "buy" }))
.node("checkout", async (state) => ({ log: ["order placed"] }))
.edge(START, "agent")
.edge("agent", "checkout", (state) => state.intent === "buy") // taken only when it passes
.edge("checkout", END)
.compile();

const { status, state } = await run(g, { intent: "" });
console.log(status, state.log); // "done" [ "order placed" ]

Routers​

A router returns the next hop dynamically — a node name, a list of names (fan-out), or END:

import { graph, channel, START, END, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";

/** Plan state — a flag that routes to tools or a direct response. */
const PlanState = {
needsTool: channel.lastWrite<boolean>(false),
log: channel.append<string>(),
} satisfies ChannelMap;

type PlanState = StateOf<typeof PlanState>;

const g = graph("plan", PlanState)
.node("plan", async (state) => ({}))
.node("tools", async (state) => ({ log: ["ran tool"] }))
.node("respond", async (state) => ({ log: ["answered"] }))
.router("plan", (state) => (state.needsTool ? "tools" : "respond")) // next hop, chosen dynamically
.edge(START, "plan")
.edge("tools", END)
.edge("respond", END)
.compile();

const { status, state } = await run(g, { needsTool: true });
console.log(status, state.log); // "done" [ "ran tool" ]

A router (and any conditional predicate) must be pure. It runs inside planning, not inside a task — so it has no journal and must not perform side effects. Route on state; compute in nodes. For data-driven fan-out, see send; for a node that decides its own next hop after seeing its own update, see command.

Graphs are data​

Because a graph compiles from a serializable spec, it round-trips:

import { fromSpec, toSpec, START, END } from "@ilmek/core";
import type { GraphSpec, NodeRegistry } from "@ilmek/core";
import assert from "node:assert/strict";

// A stored spec — exactly the document a drag-and-drop builder would save.
const spec: GraphSpec = {
name: "support",
channels: { messages: { reducer: "append" }, intent: { reducer: "last_write" } },
nodes: [
{ id: "classify", type: "set_intent", config: { intent: "buy" } },
{ id: "buy", type: "say", config: { text: "bought" } },
],
edges: [
{ from: START, to: "classify" },
{ from: "classify", to: "buy", when: { channel: "intent", eq: "buy" } },
{ from: "buy", to: END },
],
};

/** Registry — maps each node `type` to a builder that returns the node body. */
const registry: NodeRegistry = {
set_intent: (config) => () => ({ intent: config.intent }),
say: (config) => () => ({ messages: [config.text] }),
};

const g = fromSpec(spec, registry).compile();

// Round-trip is a conformance test: the compiled graph serializes back to the same data.
assert.deepEqual(toSpec(g), spec);

This is the foundation for a drag-and-drop builder — a CRUD app over a JSON document. Nothing in the engine knows the builder exists. See Graphs as data.