Authoring helpers
Inside an ilmek node you shape the conversation with a handful of helpers. They're the entire authoring surface — everything a turn can produce on the wire comes from one of them. Each takes ilmek's context (no ambient storage; ilmek already threads it through every node) and emits a custom event the mapper turns into a frame.
- TypeScript
- .NET
import { mekik } from "@mekik/core";
// one node of a compiled ilmek graph
.node("desk", async (state, ctx) => {
const id = state.input as string;
const order = await mekik.tool(ctx, "get_order", { id }, () => Orders.get(id)); // journaled tool
mekik.text(ctx, "Looking that up… "); // streamed prose
mekik.ui(ctx, "order-card", { id: order.id, total: order.total }); // mount a component
const ok = await mekik.approve<{ approved: boolean }>( // pause for a human
ctx,
{ title: `Refund ${order.total}?` },
{ ui: { component: "approval-form", props: { orderId: order.id } } },
);
return { reply: ok.approved ? "done" : "cancelled" };
})
using Mekik;
using Ilmek;
// one node of a compiled ilmek graph
.Node("desk", async (State state, IContext ctx) =>
{
var id = state.Get<string>("input");
var order = (Order)(await Shuttle.Tool(ctx, "get_order", // journaled tool
new Dictionary<string, object?> { ["id"] = id },
() => (object?)Orders.Get(id)))!;
Shuttle.Text(ctx, "Looking that up… "); // streamed prose
Shuttle.Ui(ctx, "order-card", // mount a component
new Dictionary<string, object?> { ["id"] = order.Id, ["total"] = order.Total });
var ok = await Shuttle.Approve<Dictionary<string, object?>>( // pause for a human
ctx,
new Dictionary<string, object?> { ["title"] = $"Refund {order.Total}?" },
ui: new Dictionary<string, object?>
{
["component"] = "approval-form",
["props"] = new Dictionary<string, object?> { ["orderId"] = order.Id },
});
return Update.Of("reply", ok.GetValueOrDefault("approved") is true ? "done" : "cancelled");
})
The one name, two ways
In TypeScript the single mekik export is both the app factory and the helpers — index.ts folds the helper functions onto the callable factory, so both read naturally. In .NET they split: the app is MekikApp, and the helpers live on a static Shuttle class (a static class named Mekik would clash with the namespace — see Parity).
- TypeScript
- .NET
import { mekik } from "@mekik/core";
const app = mekik({ graph }); // app factory — called once, at the top level
mekik.ui(ctx, "card", {}); // helper — called inside a node
// helpers are also available as named imports:
import { ui, tool, approve } from "@mekik/core";
using Mekik;
var app = new MekikApp(new MekikOptions { Graph = graph }); // app — constructed once
Shuttle.Ui(ctx, "card", new Dictionary<string, object?>()); // helper — called inside a node
The helpers
| TypeScript | .NET | Emits | Awaits? | Guide |
|---|---|---|---|---|
mekik.text(ctx, content) | Shuttle.Text(ctx, content) | a genui text chunk | no | Generative UI |
mekik.streamText(ctx, deltas, select?) | Shuttle.StreamText(ctx, deltas, …) | one genui text chunk per delta | yes — returns the joined text | Generative UI |
mekik.ui(ctx, component, props?) | Shuttle.Ui(ctx, component, props?) | a genui ui chunk | no | Generative UI |
mekik.event(ctx, name, payload?) | Shuttle.Event(ctx, name, payload?) | a genui event chunk | no | Generative UI |
mekik.mount(ctx, component, props?) | Shuttle.Mount(ctx, component, props?) | a genui ui chunk + an update handle | no | Typed components |
mekik.message(ctx, type, data) | Shuttle.Message(ctx, type, data) | a persistent rich message frame | no | Rich messages |
mekik.tool(ctx, name, params, fn) | Shuttle.Tool(ctx, name, params, fn) | a tool_call trace + runs fn once | yes — returns the result | Tools |
mekik.approve(ctx, payload, opts?) | Shuttle.Approve(ctx, payload, …) | an interrupt frame; the run pauses | yes — returns the answer | Human-in-the-loop |
mekik.choose(ctx, payload, options) | Shuttle.Choose<T>(ctx, payload, options) | an interrupt frame whose actions are the options | yes — returns the pick | HITL → Buttons |
text, ui, event and message are fire-and-forget: they emit and return. streamText is the token-by-token convenience — it drives an async delta source through text and returns the joined string to hand back as the reply. tool, approve and choose await because they wrap ilmek machinery (ctx.step / ctx.StepAsync for tool, ctx.interrupt / ctx.InterruptAsync for the other two).
On top of these sit the typed catalogs, which bind a component or message name once so the payload is compiler-checked: mekik.component / mekik.genui for components, mekik.messageKind / mekik.messages for messages, and mekik.action for chips. They add nothing to the wire — same frames, fewer hand-written object literals.
Each message type also has a describe form — messages.card.spec(data) / Messages.CardSpec(…) — returning the message as a value instead of emitting it, for the places that send one without a ctx. That's how the greeting carries a card or buttons rather than only a paragraph.
text / ui / event
Stream a chunk of generative UI. text streams prose deltas; ui mounts or updates a registered component by name; event dispatches a named event to a mounted component:
- TypeScript
- .NET
mekik.text(ctx, "Analyzing your order… ");
mekik.ui(ctx, "data-table", { rows, columns });
mekik.event(ctx, "highlight", { rowId: 3 });
Shuttle.Text(ctx, "Analyzing your order… ");
Shuttle.Ui(ctx, "data-table", new Dictionary<string, object?> { ["rows"] = rows, ["columns"] = columns });
Shuttle.Event(ctx, "highlight", new Dictionary<string, object?> { ["rowId"] = 3 });
All three carry the same AIChunk shape chativa renders. Chunks under one turn share a streamId; the mapper assigns each an incrementing id and closes the stream at run end. Details and the component contract: Generative UI.
tool
Run a side effect exactly once and surface it as a tool_call trace:
- TypeScript
- .NET
const order = await mekik.tool(ctx, "get_order", { id }, () => Orders.get(id));
var order = (Order)(await Shuttle.Tool(ctx, "get_order",
new Dictionary<string, object?> { ["id"] = id },
() => (object?)Orders.Get(id)))!;
It emits tool_call{running}, runs fn inside ilmek's ctx.step (which journals the result), then emits tool_call{completed, result} — or tool_call{error} if fn throws. The journaling is the point: on a resume pass the node re-runs, but fn returns its recorded value instead of executing again. This is what stops a refund from charging twice. Full story: Tools.
approve
Pause the run for a human and resume with their answer:
- TypeScript
- .NET
const ok = await mekik.approve<{ approved: boolean }>(
ctx,
{ title: "Deploy to production?" },
{
ui: { component: "approval-form", props: { env: "prod" } }, // a rich form…
actions: [ // …or chip fallback
{ label: "Approve", value: { approved: true } },
{ label: "Cancel", value: { approved: false } },
],
},
);
if (ok.approved) await deploy();
var ok = await Shuttle.Approve<Dictionary<string, object?>>(
ctx,
new Dictionary<string, object?> { ["title"] = "Deploy to production?" },
ui: new Dictionary<string, object?> // a rich form…
{
["component"] = "approval-form",
["props"] = new Dictionary<string, object?> { ["env"] = "prod" },
},
actions: new List<object> // …or chip fallback
{
new Dictionary<string, object?> { ["label"] = "Approve", ["value"] = new Dictionary<string, object?> { ["approved"] = true } },
new Dictionary<string, object?> { ["label"] = "Cancel", ["value"] = new Dictionary<string, object?> { ["approved"] = false } },
});
if (ok.GetValueOrDefault("approved") is true) await Deploy();
The node suspends at that await on the first pass — it never returns. The engine emits an interrupt frame and ends the run interrupted. When the client answers, the graph re-runs the node from the top and the await returns the answer. The authoring contract — and why anything before the await must be journaled — is Human-in-the-loop.
The presentation/journaling options — a form via ui, chips via actions, a key when a node pauses more than once — read like this (omit both ui and actions for default Approve/Cancel chips):
- TypeScript
- .NET
interface ApproveOptions {
ui?: UiRef; // mount a form instead of relying on chips
actions?: MessageAction[]; // chips; omit both for default Approve/Cancel
key?: string; // journal key, when a node pauses more than once
}
ValueTask<T> Approve<T>(
IContext ctx,
IReadOnlyDictionary<string, object?> payload,
IReadOnlyDictionary<string, object?>? ui = null, // mount a form instead of chips
IReadOnlyList<object>? actions = null, // omit both for default Approve/Cancel
string key = "interrupt"); // journal key, when a node pauses more than once
Low-level trace primitives
For integrations that execute tools themselves (a LangChain or Semantic Kernel agent calling its own functions), mekik exports the primitives behind tool so the same trace can be produced without re-deriving the reserved payload shape:
- TypeScript
- .NET
import { nextToolCallId, toolTrace } from "@mekik/core";
const id = nextToolCallId(ctx);
toolTrace(ctx, { id, name: "search", status: "running", params });
const result = await runTheToolYourself(params);
toolTrace(ctx, { id, name: "search", status: "completed", result });
var id = Shuttle.NextToolCallId(ctx);
Shuttle.ToolTrace(ctx, new Dictionary<string, object?> { ["id"] = id, ["name"] = "search", ["status"] = "running", ["params"] = @params });
var result = await RunTheToolYourself(@params);
Shuttle.ToolTrace(ctx, new Dictionary<string, object?> { ["id"] = id, ["name"] = "search", ["status"] = "completed", ["result"] = result });
toolTrace / Shuttle.ToolTrace emits one tool_call frame (upsert by id); nextToolCallId / Shuttle.NextToolCallId mints a replay-stable id. You rarely call these directly — the agent integrations use them internally. Reach for them only when wrapping a tool runner mekik doesn't already integrate.
Reading per-conversation context
Helpers emit; to read per-conversation data (a user id, a locale, auth claims), use ilmek's context metadata, which mekik populates:
- TypeScript
- .NET
.node("desk", async (state, ctx) => {
const locale = (ctx.meta.mekik as { locale?: string })?.locale ?? "en";
const claims = ctx.meta.auth; // verified auth claims, if an Authenticator ran
return { reply: greet(locale) };
})
.Node("desk", async (State state, IContext ctx) =>
{
var mekikMeta = ctx.Meta.GetValueOrDefault("mekik") as IReadOnlyDictionary<string, object?>;
var locale = mekikMeta?.GetValueOrDefault("locale") as string ?? "en";
var claims = ctx.Meta.GetValueOrDefault("auth") as IReadOnlyDictionary<string, object?>; // verified auth claims
return Update.Of("reply", Greet(locale));
})
meta.mekik is your context selector's output; meta.client is the allowlisted client meta; meta.auth is the auth verdict's claims. See Concepts → Graph context.
Where to go next
- Generative UI — the chunk model and the component contract.
- Typed components — bind a component name once; every chativa built-in, worked through.
- Rich messages — standalone transcript entries: images, cards, carousels, files.
- Tools — exactly-once, redaction, and the trace lifecycle.
- Human-in-the-loop — the durable-pause authoring rules.