Skip to main content

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.

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" };
})

The one name, two ways

In TypeScript the single mekik export is both the app factory and the helpersindex.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).

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";

The helpers

TypeScript.NETEmitsAwaits?Guide
mekik.text(ctx, content)Shuttle.Text(ctx, content)a genui text chunknoGenerative UI
mekik.streamText(ctx, deltas, select?)Shuttle.StreamText(ctx, deltas, …)one genui text chunk per deltayes — returns the joined textGenerative UI
mekik.ui(ctx, component, props?)Shuttle.Ui(ctx, component, props?)a genui ui chunknoGenerative UI
mekik.event(ctx, name, payload?)Shuttle.Event(ctx, name, payload?)a genui event chunknoGenerative UI
mekik.mount(ctx, component, props?)Shuttle.Mount(ctx, component, props?)a genui ui chunk + an update handlenoTyped components
mekik.message(ctx, type, data)Shuttle.Message(ctx, type, data)a persistent rich message framenoRich messages
mekik.tool(ctx, name, params, fn)Shuttle.Tool(ctx, name, params, fn)a tool_call trace + runs fn onceyes — returns the resultTools
mekik.approve(ctx, payload, opts?)Shuttle.Approve(ctx, payload, …)an interrupt frame; the run pausesyes — returns the answerHuman-in-the-loop
mekik.choose(ctx, payload, options)Shuttle.Choose<T>(ctx, payload, options)an interrupt frame whose actions are the optionsyes — returns the pickHITL → 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:

mekik.text(ctx, "Analyzing your order… ");
mekik.ui(ctx, "data-table", { rows, columns });
mekik.event(ctx, "highlight", { 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:

const order = await mekik.tool(ctx, "get_order", { id }, () => 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:

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();

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

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
}

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:

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 });

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:

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

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.