Skip to main content

LangChain

:::note TypeScript integration LangChain is a TypeScript library, so this page is TypeScript-only by nature. On .NET, the equivalents are Microsoft.Extensions.AI and Semantic Kernel — same policy shape, same three capabilities. :::

@mekik/langchain runs a LangChain agent inside an ilmek node and gives each of its tools the mekik treatment: a tool_call trace, optional human approval, and exactly-once across a pause/resume. You wrap the tools once before handing them to the agent; the wrappers keep their name, description, and schema, so the model binds them exactly as before.

pnpm add @mekik/langchain @mekik/core

@langchain/core is a peer dependency — bring your own version.

runAgent — the loop, packaged

Most nodes don't need to hand-roll the model↔tool loop. runAgent drives it: it wraps your tools with withMekikTools, runs each model call inside ctx.step (a resume replays the decision instead of re-paying for it), streams text deltas live as one growing bubble, and returns the consolidated reply for you to hand back:

import { runAgent } from "@mekik/langchain";

.node("agent", async (state, ctx) => ({
reply: await runAgent(ctx, model, {
system: SYSTEM,
input: state.input,
tools: [getOrder, refundPayment, internalLookup],
policy: {
get_order: { show: true },
refund_payment: { show: true, approve: true }, // pauses the graph for a human
},
}),
}))
function runAgent(
ctx: Context<any>,
model: BaseChatModel, // any tool-calling chat model (bindTools)
options: {
system: string;
input: string;
tools?: readonly StructuredToolInterface[];
maxTurns?: number; // default 6
policy?: Readonly<Record<string, ToolPolicy>>;
defaultPolicy?: ToolPolicy;
stream?: boolean; // live text deltas; default true
emptyReply?: string;
budgetReply?: string;
},
): Promise<string>;

You return the result as your node's reply ({ reply }). When streaming (the default), the answer is delivered live as the durable message (streamed chunks persist and replay), so runAgent returns an empty string{ reply: "" } emits nothing extra, no duplicate. With stream: false, it returns the full text for the consolidated text reply. Reach for withMekikTools directly when you need to drive the loop yourself (a custom agent framework, a non-standard message shape).

withMekikTools

import { withMekikTools } from "@mekik/langchain";

.node("agent", async (state, ctx) => {
const tools = withMekikTools(ctx, [getOrder, refundPayment, internalLookup, charge], {
get_order: { show: true }, // trace shown
refund_payment: { show: true, approve: true }, // ask the human first
internal_lookup: { show: false }, // runs, not shown
charge: { show: true, redact: ["cardNumber"] }, // shown, masked
});

// `createAgent` from langchain v1 — the prebuilt `createReactAgent` it
// replaced still works, but it is the legacy entry point.
const agent = createAgent({ model, tools });
const out = await agent.invoke({ messages: [new HumanMessage(state.input)] });
return { reply: lastText(out) };
})

Signature:

function withMekikTools<T extends StructuredToolInterface>(
ctx: Context<any>,
tools: readonly T[],
policy?: Readonly<Record<string, ToolPolicy>>,
options?: { defaultPolicy?: ToolPolicy }, // applied to tools with no entry; default { show: true }
): StructuredToolInterface[];

Each returned tool, when the agent calls it: emits a tool_call trace (unless show:false), optionally pauses for approval, then executes inside ctx.step so it runs exactly once across an interrupt/resume.

Why wrapping, not just callbacks

A LangChain agent invokes its own tools. That leaves the two gaps mekik.tool normally closes:

  1. Nothing emits a tool_call frame, so the UI never learns a tool ran.
  2. When a node pauses for a human and the graph resumes, the node re-runs from the top — and the agent calls its tools again. Only ctx.step makes an effect survive that replay.

withMekikTools closes both because it owns the invocation. A callback handler can only close the first.

Policy

interface ToolPolicy {
show?: boolean; // surface the trace (default true)
approve?: boolean | ApproveSpec; // pause for a human first (default false)
redact?: readonly string[]; // mask these fields in the surfaced trace
}

redact masks only what is surfaced — the tool itself receives the real values. When the human declines an approve tool, it's never executed and the agent gets a plain observation back (denyMessage, default "The user declined to run <tool>.") so its loop can continue.

ApproveSpec

approve: true uses defaults; pass an ApproveSpec to customize:

interface ApproveSpec {
title?: string; // question shown to the human. Default: `Run <tool>?`
actions?: MessageAction[]; // chips. Default: Approve/Reject carrying { approved: true|false }
ui?: UiRef; // mount a form instead of chips
denyMessage?: string; // what the tool returns to the agent on decline
}
refund_payment: {
approve: {
title: "Approve this refund?",
ui: { component: "approval-form", props: { kind: "refund" } },
denyMessage: "Refund not approved by the operator.",
},
},

Approvals reach the client as ordinary mekik interrupt frames — chativa renders chips, or a form if you pass ui. Each tool gets its own stable interrupt key, so several approvals in one node stay separately addressable across a resume.

What the human sees

When an approve tool fires, the agent's own loop suspends inside the tool call while the graph pauses. The interrupt payload carries the tool name and its (redacted) params:

{ "type": "interrupt", "id": "…", "data": {
"payload": { "title": "Run refund_payment?", "tool": "refund_payment",
"params": { "orderId": "ORD-42" } },
"actions": [ { "label": "Approve", "value": { "approved": true } },
{ "label": "Reject", "value": { "approved": false } } ] } }

On resume, the node re-runs — but the tools that already completed are journaled, so only the approved tool proceeds to execute. The library accepts a range of answer shapes ({approved:true}, true, or a yes-ish string) since clients vary.

mekikCallbacks — the fallback

When you cannot wrap the tools (a prebuilt agent that owns them), attach a callback handler instead:

import { mekikCallbacks } from "@mekik/langchain";

const out = await agent.invoke(input, { callbacks: [mekikCallbacks(ctx, policy)] });

Visibility only. It emits tool_call traces but cannot journal the tool, so after a pause and resume the agent will invoke its tools a second time. Prefer withMekikTools; keep the tools behind this one side-effect free.

withMekikToolsmekikCallbacks
tool_call traces
human approval
exactly-once across resume
requires wrapping the toolsyesno

route — classify into one node

The router pattern (classify the turn, then goto a focused expert node) is one call. route builds a strict classification prompt from your route names + descriptions, journals the choice (a resume replays the same route), and normalizes the model's answer to a valid route — falling back when it answers off-list:

import { route } from "@mekik/langchain";

.node("router", async (state, ctx) => {
const target = await route(ctx, model, [
{ name: "reporting", description: "sprint reports and metrics" },
{ name: "billing", description: "invoices and charges" },
{ name: "general", description: "everything else" },
], state.input, { fallback: "general" });
return command(update({ route: target }), target); // set channel + goto node
})

Where to go next

  • Agent integrations → Overview — the shared policy shape and the three integrations.
  • Tools — the mekik.tool mechanism these mirror.
  • Examplessql-agent, weather-agent, and concierge drive real LangChain-style agents through this.