I shipped a small demo last week. A LangChain.js agent invokes two tools, an AgentLairCallbackHandler posts a signed audit event for each tool call, the agent issues a Bonded Credibility Credential summarising the run. Curl the verifier URL, get valid:true. Three steps.
The first version returned an empty event list every time.
The repro
Here is a minimal reproduction. The handler does one thing on tool start: POST to an audit endpoint and push the response into this.events.
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
class AuditHandler extends BaseCallbackHandler {
name = 'AuditHandler';
events: any[] = [];
async handleToolStart(_t: any, input: string) {
const res = await fetch('https://example.com/audit', {
method: 'POST',
body: JSON.stringify({ input }),
});
this.events.push(await res.json());
}
}
const handler = new AuditHandler();
await myTool.invoke({ q: 'hello' }, { callbacks: [handler] });
console.log(handler.events.length); // 0
The tool returns. The audit POST completes a few milliseconds later. By then console.log has already printed 0 and the program has moved on.
Why
BaseCallbackHandler runs its hooks in the background. Inside langchain-core, callback execution checks an env var (LANGCHAIN_CALLBACKS_BACKGROUND) which defaults to "true" in Node. Background mode means await tool.invoke(...) resolves as soon as the tool itself is done. The callback's promise is detached. The handler keeps running on its own; the calling code does not wait.
This is fine when callbacks are pure observability. Fire a metric. Tee a log line. Best effort. Background mode trades await-correctness for not-blocking-the-hot-path, and most observability tooling can live with that.
It is not the right default when the callback is the audit trail. If you read handler.events after tool.invoke returns, you read whatever has landed so far. Sometimes that is everything. Sometimes that is nothing. There is a real ordering bug waiting for you in production, and it survives every unit test that runs the handler standalone.
The one-line fix
BaseCallbackHandler accepts a constructor option called _awaitHandler. Set it to true and the framework awaits each hook synchronously before the parent runnable resolves.
class AuditHandler extends BaseCallbackHandler {
name = 'AuditHandler';
events: any[] = [];
constructor() {
super({ _awaitHandler: true });
}
async handleToolStart(_t: any, input: string) {
const res = await fetch('https://example.com/audit', {
method: 'POST',
body: JSON.stringify({ input }),
});
this.events.push(await res.json());
}
}
Same handler. Different ordering guarantee. Now tool.invoke(...) does not return until the audit POST has resolved and the response is in this.events. Reading the array after invoke is correct by construction.
The leading underscore in _awaitHandler is hostile naming. It looks private. It is not. It is the public escape hatch for handlers that need before-and-after ordering. The rename is worth a PR upstream.
Why this matters for attestation
Audit logs you read before they finish writing are worse than no audit log. They look credible (the handler reports events.length: 4) but the chain has gaps in production. The hash chain on the server stays consistent. The client's belief about which events belong to which run is the corrupted part.
I hit this building agentlair-langchain-attestations-demo. The handler signs each tool invocation into an Ed25519 audit chain on agentlair.dev, then mints a Bonded Credibility Credential at the end of the run that anchors first_event_id and last_event_id from the chain. The BCC is publicly verifiable, no account needed:
curl https://agentlair.dev/v1/bcc/bcc_lgqfH7XRthR40JGr7Ask/verify
valid:true. 4 audit events. Issued 2026-05-08 07:39 UTC against production.
Without _awaitHandler: true, issueBcc() reads the event buffer before the events have arrived. The BCC ships with audit_event_count: 0 and a null first_event_id. The credential signs cleanly. It is also a lie about what was on the chain at the time it was issued, and the lie is signed.
Honest limits
Two things you should know if you wire this up:
- The audit chain on agentlair.dev is computed per Cloudflare worker isolate. Concurrent isolates run independent chains. Cross-isolate ordering is not guaranteed in v1.
- The
_awaitHandlerflag changes scheduling for that handler. If a handler does heavy synchronous work, it will block the runnable. Use it for I/O that is correctness-critical, not for everything.
The demo is at github.com/piiiico/agentlair-langchain-attestations-demo. bun install && bun run demo ships a real BCC against agentlair.dev. No credentials needed up front; the first run registers an anonymous account.
If you build attestation, observability, or audit trails on top of LangChain.js, set _awaitHandler: true. The default is faster. The default also lies when you treat its events as ground truth.
Top comments (0)