LangChain.js callback handler
LangChain.js (
@langchain/core@^0.3) is the dominant TS agent stack. SpendGuard ships asSpendGuardCallbackHandler— a stockBaseCallbackHandleryou drop onto anyChatOpenAI/ChatAnthropic/BaseChatModelviacallbacks: [handler]. No model subclassing, no proxy fork. The same handler covers LangGraph because LangGraph builds onBaseChatModel.
Install
Section titled “Install”pnpm add @spendguard/sdk @spendguard/langchain @langchain/core @langchain/openaiBoth @spendguard/sdk AND @langchain/core are declared as peer dependencies
so the adapter pins neither — your project’s lockfile wins. Node 20.10+ is
required (the substrate uses await using + stable AsyncLocalStorage).
Quick start
Section titled “Quick start”Minimal end-to-end wire — connect a SpendGuardClient to the sidecar UDS, hand
it to a SpendGuardCallbackHandler, drop the handler onto ChatOpenAI’s
callbacks: array:
import { ChatOpenAI } from "@langchain/openai";import { HumanMessage } from "@langchain/core/messages";import { SpendGuardClient } from "@spendguard/sdk";import { SpendGuardCallbackHandler } from "@spendguard/langchain";
const client = new SpendGuardClient({ socketPath: "/var/run/spendguard/adapter.sock", tenantId: "00000000-0000-4000-8000-000000000001", runtimeKind: "langchain-js",});await client.connect();await client.handshake();
const handler = new SpendGuardCallbackHandler({ client, budgetId: "44444444-4444-4444-8444-444444444444",});
const model = new ChatOpenAI({ model: "gpt-4o-mini", callbacks: [handler],});
try { const res = await model.invoke([new HumanMessage("hello langchain")]); console.log(res.content);} finally { await client.close();}What happens on each model.invoke():
- LangChain’s
RunManagerfireshandleChatModelStartBEFORE the OpenAI HTTP call. The handler derives the canonical idempotency key from(tenantId, runId, parentRunId)and callsclient.reserve(...)withtrigger="LLM_CALL_PRE". - On
DecisionDenied(DENY / STOP / APPROVAL_REQUIRED), the handler throws —raiseError = trueis wired so the throw propagates throughCallbackManagerand haltsmodel.invoke()BEFORE the provider HTTP call. - On success, the inflight
(decisionId, reservationId)is stashed keyed by LangChain’srunId. The OpenAI HTTP call fires. handleLLMEndconsumes the inflight entry, extracts the provider’s reportedtokenUsage, and emits aSUCCESScommit viaclient.commitEstimated(...).handleLLMErrorsymmetrically emits aPROVIDER_ERRORcommit with the error message threaded ontoactualErrorMessage.
Configuration
Section titled “Configuration”The handler options surface supports unitId pass-through (set
SPENDGUARD_UNIT_ID in the runner environment and pass it as the unitId
option). The richer windowInstanceId / pricing / claimEstimator fields
the design anticipates are deferred to a later slice.
| Option | Type | Required | Description |
|---|---|---|---|
client | SpendGuardClient | YES | Configured substrate client from @spendguard/sdk. The adapter does NOT own the client lifecycle — the consumer calls connect() / handshake() / close(). |
tenantId | string | NO | Tenant UUID override. Defaults to whatever tenant the client was configured with at construction time. |
budgetId | string | NO | Budget UUID used as the projected claim’s scopeId. When unset, the handler falls back to tenantId as the scopeId. |
A future minor (v0.x) extends this surface field-for-field with the Python
SpendGuardChatModel constructor (windowInstanceId, unit, pricing,
claimEstimator, route, callSignatureFn, claimEstimate,
onApprovalRequired). The extensions are additive optional fields — no
breaking change to the v0.1.0 shape.
Limitations
Section titled “Limitations”SpendGuard’s LangChain.js adapter is callback-based pre-call + end-of-stream commit only. The handler gates BEFORE the upstream provider HTTP call, and commits AFTER the stream completes. A few important things to know before you ship it in front of long streaming responses or rely on D04/5’s full design surface:
- Callback-based architecture, not a model subclass. LangChain.js prefers
callback handlers; SpendGuard ships a
BaseCallbackHandlerrather than aBaseChatModelsubclass wrapper. The throw-to-halt mechanism relies onraiseError = trueandawaitHandlers = true— both pinned on the handler instance. If you override them downstream, the budget gate stops working. raiseError = truepropagation is load-bearing. Without it,CallbackManagerswallows the throw fromhandleChatModelStartand the budget gate never blocks the LLM call. The adapter pins it explicitly to defend against future@langchain/coredrift; do NOT override it on the handler instance.- No mid-stream cap on streamed responses. SpendGuard reserves the
predicted budget at pre-call time. The commit happens at end-of-stream
against the real
tokenUsage. The handler never tears down a mid-flight stream to halt token emission — overruns land in the audit chain and reflect at commit time, but the tokens were already emitted. - DEGRADE patch application surfaces as
MutationApplyFailed. Matches the Pythonspendguard-sdk[langchain]v0.5.1 stance. Built-in claim mutation lands in a later slice. - No tool-call mid-loop gating. Each tool call is its own LangChain event; the substrate handles per-call PRE/POST. Cross-tool budget enforcement is the contract layer’s job, not the adapter’s.
A bundled docker-compose demo proves the full ALLOW + DENY + STREAM matrix
end-to-end against a real ChatOpenAI + sidecar + counting-stub provider:
make demo-up DEMO_MODE=langchain_tsThe mode boots postgres + sidecar + langchain-runner + counting-stub, then
runs three invocations from the Node example at
examples/langchain-ts/:
- ALLOW — small message within budget.
handleChatModelStartfires PRE,client.reserve()returns ALLOW, theChatOpenAIHTTP call fires, counter +1,handleLLMEndemits aSUCCESScommit with real token usage. - DENY —
spendguard_estimate_override=2000000000blows past the seeded 1B hard-cap. The sidecar contract evaluator emitsSPENDGUARD_DENY,client.reserve()throwsDecisionDenied,model.invoke()halts BEFORE the OpenAI HTTP call. Counting-stub counter UNCHANGED (proves the gate fires pre-call). - STREAM —
streaming: truekeeps the SSE chunked path. PRE fires once at stream open, POST commits once at stream end against realusage.completion_tokens.
Success line on a clean run:
[demo] langchain_ts ALL 3 steps PASS (ALLOW + DENY + STREAM)Full details and the verify-SQL gates are in
deploy/demo/langchain_ts/README.md.
Troubleshooting
Section titled “Troubleshooting”Common boot-time and first-call errors. The substrate’s typed exceptions are
re-exported from @spendguard/langchain so you can pattern-match on
DecisionDenied / SidecarUnavailable without taking a second import.
-
DecisionDenied: reserve() denied by contract— Expected behaviour when the budget is exhausted or a contract rule emittedSPENDGUARD_DENY. The handler rethrows,model.invoke()halts, and the OpenAI HTTP call never fires. Check the sidecar logs for the rule that matched; the decision row in the ledger carriesdecision_context.contract_rule. -
Budget gate not blocking — provider call fires anyway. Almost always caused by
raiseError = falsesomewhere on the call path (override on the handler instance, or a wrappingCallbackManagerthat strips it). The adapter pinsraiseError = trueandawaitHandlers = trueat construction; do not override either. Reproduce by settingLANGCHAIN_VERBOSE=true— theCallbackManagerwill log the swallowed throw. -
SidecarUnavailable: handshake timeout— The sidecar’s UDS path (SPENDGUARD_SIDECAR_UDS, default/var/run/spendguard/adapter.sock) is not reachable. Check that the sidecar container is up, the socket file exists, and your Node process has read/write permission on it. In the demo overlay this is wired bydeploy/demo/langchain_ts/docker-compose.yaml; outside the demo, mount the same socket path into your Node container’s/var/run/spendguard/. -
handleLLMEnd: no inflight entry for runId=...— The adapter logs a warn-and-return when it sees a POST commit for arunIdthat has no inflight reservation. This is benign in the degradation case (SidecarUnavailableon the PRE — the adapter let the LLM call proceed without a gate). If you see it without a precedingSidecarUnavailable, suspect a duplicatehandleLLMEnddelivery from the framework — the substrate’s in-processDecisionCachecollapses the duplicate at commit time. -
TypeError: client.reserve is not a function— You imported@spendguard/langchainagainst a@spendguard/sdkversion that predates thereserve()rename. The v0.1.0 adapter requires@spendguard/sdk@^0.1.0. Upgrade withpnpm add @spendguard/sdk@latest.
Related
Section titled “Related”- Quickstart — full SpendGuard stack up in 5 minutes
- LangChain & LangGraph (Python) — Python sibling of this adapter
- Contract YAML reference — author allow/stop rules
- Other adapter integrations: Pydantic-AI · OpenAI Agents SDK · Microsoft AGT · LiteLLM proxy guardrail