LangChain and LangGraph
Run Klent on the evaluate hot path inside a LangGraph graph or any LangChain agent. Klent does not replace your orchestrator — it sits next to it.
Klent's first-class orchestrators (runAnthropicAgent, runOpenAIAgent) own
the whole loop. If you already run an orchestrator — LangGraph, LangChain
AgentExecutor, Mastra, your own runner — you keep it and drop runTool (or
evaluateAction / logEvent directly) next to your tool invocations.
This page shows the common shapes.
LangGraph: wrap a tool-executing node
In LangGraph the usual pattern is a node that, given state with a pending tool
call, executes it and returns state with the observation. Put runTool
between the state read and the actual execution.
import { StateGraph } from '@langchain/langgraph';
import { runTool, KlentClient } from '@klent/sdk';
type AgentState = {
executionId: string;
pendingToolCall: { name: string; arguments: Record<string, unknown> } | null;
messages: Array<{ role: string; content: string }>;
};
const klent = new KlentClient({ apiKey: process.env.KLENT_API_KEY! });
const tools: Record<string, (input: Record<string, unknown>) => Promise<unknown>> = {
transfer_funds: async (input) => transferFunds(input),
send_email: async (input) => sendEmail(input),
};
async function toolNode(state: AgentState): Promise<Partial<AgentState>> {
const call = state.pendingToolCall;
if (!call) return {};
const handler = tools[call.name];
if (!handler) {
return {
messages: [...state.messages, { role: 'tool', content: `Unknown tool "${call.name}"` }],
pendingToolCall: null,
};
}
const result = await runTool(klent, {
execution_id: state.executionId,
tool: call.name,
input: call.arguments,
execute: (input) => handler(input),
});
const message =
result.status === 'denied'
? `Blocked by Klent: ${result.reason}`
: result.status === 'error'
? `Tool failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`
: typeof result.output === 'string'
? result.output
: JSON.stringify(result.output);
return {
messages: [...state.messages, { role: 'tool', content: message }],
pendingToolCall: null,
};
}
const graph = new StateGraph<AgentState>({
channels: {
/* … */
},
})
.addNode('tool', toolNode)
// …other nodes
.compile();The rest of your graph is untouched. Klent only sees the boundary — one evaluate per proposed tool call, and the usual event trail.
LangChain AgentExecutor: wrap each tool's func
Classic AgentExecutor agents take an array of DynamicStructuredTools. Wrap
the func passed to each tool — the agent never notices.
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import { runTool, KlentClient } from '@klent/sdk';
const klent = new KlentClient({ apiKey: process.env.KLENT_API_KEY! });
function withKlent<Schema extends z.ZodObject>(opts: {
name: string;
description: string;
schema: Schema;
executionId: () => string;
run: (input: z.infer<Schema>) => Promise<unknown>;
}) {
return new DynamicStructuredTool({
name: opts.name,
description: opts.description,
schema: opts.schema,
func: async (input) => {
const result = await runTool(klent, {
execution_id: opts.executionId(),
tool: opts.name,
input: input as Record<string, unknown>,
execute: () => opts.run(input),
});
if (result.status === 'denied') throw new Error(`Blocked by Klent: ${result.reason}`);
if (result.status === 'error') throw result.error;
return result.output;
},
});
}executionId is a thunk because agents usually create it once per run, and
you want it available to every tool fired in that run. A common pattern is to
stash it on the RunnableConfig metadata and read it in the thunk.
Starting the execution
Whatever orchestrator you use, you still call klent.startExecution once per
agent run. With LangChain this usually lives in the request handler that
invokes the agent:
const execution = await klent.startExecution({
agent_id: 'support-agent',
metadata: { user_id: request.userId },
});
const result = await agent.invoke(
{ input: request.message },
{ configurable: { executionId: execution.id } },
);What Klent sees
Regardless of orchestrator, the events on the execution timeline look the same:
action_requestedper tool invocationdecision/action_blockedfromevaluateActionaction_executedorerrorfrom the handler
The orchestrator's own prompts and intermediate LLM messages are not logged
by Klent — it stays out of that path. If you want to include them, log them
explicitly with klent.logEvent({ type: 'decision', payload: { … } }).
What about streaming?
runTool is synchronous with respect to the tool call itself. Streaming
responses (where the LLM returns tokens progressively) happen outside Klent's
view — Klent only intercepts when a tool is about to fire. This keeps the
hot path narrow: one evaluate round-trip per tool call, no impact on token
streaming.