Getting started
Build a graph that pauses for a human and resumes without redoing its work — in about five minutes. Pick your language once with the tabs below; the choice follows you across every page.
Install
- TypeScript
- .NET (C#)
npm install @ilmek/core
@ilmek/core has zero runtime dependencies. Durable threads that outlive the
process are opt-in via a checkpointer package:
npm install @ilmek/checkpoint-sqlite # single-process, one file
# or
npm install @ilmek/checkpoint-postgres # threads shared across processes
Requires Node ≥ 22.5 for the SQLite checkpointer (built-in node:sqlite).
dotnet add package Ilmek.Core
Ilmek.Core has no third-party dependencies. Durable threads that outlive the
process are opt-in via a checkpointer package:
dotnet add package Ilmek.Checkpointer.Sqlite # single-process, one file
Targets .NET 9.
Your first graph
A graph is a set of channels (the state), nodes (the work), and edges (the flow). The state is typed: it lives in one named place, so a node body and the update it returns are both checked against it.
- TypeScript
- .NET (C#)
import { graph, channel, START, END, run } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";
/** Support state — one append-only channel of messages. */
const SupportState = {
messages: channel.append<string>(),
} satisfies ChannelMap;
type SupportState = StateOf<typeof SupportState>; // { messages: string[] }
const g = graph("support", SupportState)
.node("agent", async (state, ctx) => ({ messages: ["hi"] })) // state.messages: string[]
.edge(START, "agent")
.edge("agent", END)
.compile();
const { status, state } = await run(g, { messages: ["hi"] });
console.log(status, state.messages); // "done" [ "hi", "hi" ]
using Ilmek;
/// <summary>Support state — one append-only channel of messages.</summary>
sealed class SupportState
{
[Append] public List<string> Messages { get; set; } = new();
}
var g = Graph.Create<SupportState>("support")
.Node("agent", (state, ctx) =>
Update.For<SupportState>().Append(s => s.Messages, "hi")) // state.Messages: List<string>
.Edge(Graph.Start, "agent")
.Edge("agent", Graph.End)
.Compile();
var input = Update.For<SupportState>().Append(s => s.Messages, "hi");
var result = await IlmekRuntime.RunAsync(g, input);
Console.WriteLine($"{result.Status} [{string.Join(", ", result.State!.Messages)}]");
Prefer events? The same run is one canonical stream:
- TypeScript
- .NET (C#)
import { stream } from "@ilmek/core";
for await (const ev of stream(g, { messages: ["hi"] })) console.log(ev);
await foreach (var ev in IlmekRuntime.Stream(g, input)) Console.WriteLine(ev);
The move that matters
Wrap side effects in a step. On the resume pass, a completed step returns its
journaled value instead of running again — and a node can interrupt to pause
for a human, given a durable thread (a threadId + a checkpointer).
- TypeScript
- .NET (C#)
import { graph, channel, START, END, run, resume, InMemoryCheckpointer } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";
// Stand-in services — swap for your real order + payment code.
const Orders = { create: (cart: string[]) => ({ id: "ord-9001", total: 249.9 }) };
const Payments = { charge: (order: { id: string; total: number }, ok: string) => "charged" };
/** Checkout state — the cart in, an append-only log out. */
const CheckoutState = {
cart: channel.lastWrite<string[]>([]),
log: channel.append<string>(),
} satisfies ChannelMap;
type CheckoutState = StateOf<typeof CheckoutState>;
const g = graph("checkout", CheckoutState)
.node("checkout", async (state, ctx) => {
/** Called once, ever. On resume this returns the journaled order. */
const order = await ctx.step("create_order", () => Orders.create(state.cart));
// First pass halts here; resume pass returns the human's answer.
const ok = await ctx.interrupt<string>({ question: `Charge ${order.total}?` });
await ctx.step("charge", () => Payments.charge(order, ok));
return { log: ["done"] };
})
.edge(START, "checkout").edge("checkout", END)
.compile();
const opts = { threadId: "conv-42", checkpointer: new InMemoryCheckpointer() };
const paused = await run(g, { cart: ["coffee", "mug"] }, opts); // status: "interrupted"
const done = await resume(g, "yes", opts); // status: "done"
using Ilmek;
// Stand-in services — swap for your real order + payment code.
static class Orders { public static (string Id, decimal Total) Create(IReadOnlyList<string> cart) => ("ord-9001", 249.9m); }
static class Payments { public static string Charge((string Id, decimal Total) order, string ok) => "charged"; }
/// <summary>Checkout state — the cart in, an append-only log out.</summary>
sealed class CheckoutState
{
public List<string> Cart { get; set; } = new();
[Append] public List<string> Log { get; set; } = new();
}
var g = Graph.Create<CheckoutState>("checkout")
.Node("checkout", async (state, ctx) =>
{
// Called once, ever. On resume this returns the journaled order.
var order = await ctx.StepAsync("create_order", () => Orders.Create(state.Cart));
// First pass halts here; resume pass returns the human's answer.
var ok = await ctx.InterruptAsync<string>(new { question = $"Charge {order.Total}?" });
await ctx.StepAsync("charge", () => Payments.Charge(order, ok));
return Update.For<CheckoutState>().Append(s => s.Log, "done");
})
.Edge(Graph.Start, "checkout")
.Edge("checkout", Graph.End)
.Compile();
var opts = new RunOptions { ThreadId = "conv-42", Checkpointer = new InMemoryCheckpointer() };
var input = Update.For<CheckoutState>().Set(s => s.Cart, new List<string> { "coffee", "mug" });
var paused = await IlmekRuntime.RunAsync(g, input, opts); // Status: Interrupted
var done = await IlmekRuntime.ResumeAsync(g, "yes", opts); // Status: Done
InMemoryCheckpointer is enough to feel the model. For a pause that survives a
process restart, swap it for SQLite or
Postgres — nothing else in your code changes. And
resume from the line means exactly this: not a restored call stack, but an
effect that cannot happen twice.
Run the demos
Clone the repo and watch it happen — pause, answer, and see create_order run
exactly once:
- TypeScript
- .NET (C#)
cd ts && pnpm build
pnpm --filter @ilmek/examples demo # interactive checkout
pnpm --filter @ilmek/examples demo:stream # tokens streaming + a mid-stream cancel
pnpm --filter @ilmek/examples demo:mapreduce # fan-out + routing + safe retry
cd dotnet
dotnet run --project examples/Ilmek.Examples # interactive checkout
dotnet run --project examples/Ilmek.Examples -- mapreduce # fan-out + routing + safe retry
Next
- Concepts — why replay is invisible, and the contract that keeps it that way.
- Interrupts & resume — multiple pauses, loops, and the
idvskeyrule you should know up front.