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; // model↔tool round-trips; default 25. Tool calls don't consume turns —
// a round that fires five tools still costs one turn.
maxToolCalls?: number; // total tool invocations across the run; default 25
policy?: Readonly<Record<string, ToolPolicy>>;
defaultPolicy?: ToolPolicy;
stream?: boolean; // live text deltas; default true
emptyReply?: string;
budgetReply?: string;
},
): Promise<string>;

The loop is budgeted twice. maxTurns counts model rounds — how many times the model may run again after calling tools, default 25. Individual tool invocations do not consume turns: a round that fires five tools still costs one turn. maxToolCalls (default 25) separately caps total tool invocations across the run; rather than cutting a batch off halfway, the loop settles with budgetReply before executing a batch that would overrun the cap. Node-level looping stays budgeted by ilmek's recursionLimit, which tool calls never consume.

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.

withClientTools — the frontend's tools​

The connected client can declare tools of its own — open its date picker, render one of its cards — via client tools (PROTOCOL.md §11). withClientTools turns the turn's accepted declarations into LangChain tools so the model calls the UI the same way it calls a server tool:

import { withMekikTools, withClientTools, runAgent } from "@mekik/langchain";

.node("agent", async (state, ctx) => {
const tools = [
...withMekikTools(ctx, serverTools, policy), // the server's own tools
...withClientTools(ctx, { tags: ["billing"] }), // the frontend's, scoped by tag
];
return { reply: await runAgent(ctx, model(), { system: SYSTEM, input: state.input, tools }) };
})

Signature:

function withClientTools(
ctx: Context<any>,
filter?: { tags?: readonly string[]; mode?: "call" | "notify" },
): StructuredToolInterface[];

Each wrapper's executor is mekik.callClientTool: a "call"-mode tool parks the loop durably (the agent state is journaled, so the resume replays the model's decisions instead of re-paying for them), a "notify"-mode tool returns a delivery note the model can read, and a handler error comes back as an error observation — the loop stays alive and the model can route around it. The declared JSON Schema is handed to the model verbatim, so the tool call it produces binds to the client's handler unchanged.

filter.tags is the scoping lever: the frontend tags the tools it wants restricted to particular nodes, untagged tools are visible everywhere, and the server's clientTools policy has already allowlisted the whole set before this call ever sees it.

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.
  • Examples — sql-agent, weather-agent, and concierge drive real LangChain-style agents through this.