Custom agents guide
Install, instrument, the event methods, a worked example, and common problems.
Using Python?
The same events, the same wire format, the same spool — from Python.
This SDK and the Python one write the same events into the same spool. A fleet with Node agents and Python agents produces one set of sessions, not two, and nothing in the dashboard distinguishes them. Pick per service, not per company.
Install
instrument().
Connect the Failproof daemon
Identical to the Python SDK: create anevents:add key under Admin → Keys, then connect the daemon on the agent machine. The SDK writes to disk; the daemon ships.
Configuration
Nothing is applied unless all of it validates, so a rejected call leaves the SDK exactly as it was rather than with a new
baseDir and the old interval.
Set by environment variable instead:
Route the SDK’s own log lines into your logger with
failproofai.setLogger({ debug, info, warn, error }).
Shutdown
Buffered events are flushed onprocess.on("exit").
A process killed by a signal never reaches that, and Node’s default for SIGTERM is to terminate without running exit handlers — so a containerised agent loses whatever the last interval had not written.
A short-lived script or a serverless handler should await failproofai.flush() before returning — the interval alone does not guarantee delivery.
Identity
Every event belongs to a session and an agent. The scopes fill both in, so you rarely pass them:sessionId or agentId explicitly still works and wins. With neither bound nor passed, the call throws rather than emitting an event Cloud would quietly discard.
Identity rides on
AsyncLocalStorage. It follows await, .then(), timers and any callback created inside the scope. It does not follow a callback stored during one run and invoked during another, or work handed across a worker_threads boundary — wrap those in failproofai.propagate() or their events land unattached.Scopes
A synchronous body stays synchronous:
agent("x", () => 1) returns 1, not a promise.
toolCall records the body’s resolved value as the tool’s output, unless you assign call.output yourself.
Exit semantics
Exit semantics
The error is always re-thrown.A tool failure is recorded on the leaf —
tool_result with an error string — and emits no run-level error event. One the agent loop catches is not a run failure, and one that propagates is reported exactly once, by the enclosing agent().The using form
The using form
When the work is not a single function — a scope opened in a constructor and closed in a teardown, or one that straddles existing control flow:Both forms emit byte-identical events. Prefer the callback form: it runs inside
AsyncLocalStorage.run(), so there is nothing to unwind and the whole class of “opened here, closed over there” bugs is unreachable.A using block that catches its own failure reports it with span.fail(error) — the disposer has no exception channel of its own.Event catalog
The same fifteen methods as the Python SDK, in camelCase. Most come in pairs — you call the opener, then the closer, and the SDK times the gap.
Three stand alone:
error, humanPause, humanInterrupt.
Every field, per method
Every field, per method
Every method also takes
sessionId and agentId, which the scopes fill in for you. Anything omitted is dropped rather than sent as JSON null.Any other key you add becomes a custom payload field. Namespace anything framework-specific
fw_*; a name that collides with a declared field is refused rather than silently overwriting a promoted column.Framework adapters
Every range is tested against real framework releases, at both ends, as an ES module and as CommonJS, on every CI run.
The mapping is the Python SDK’s, so the same program draws the same tree in either language. A construct is an agent only if it owns an LLM decision loop — a graph or chain run, an AI SDK
generateText/streamText call, a Mastra agent, a LlamaIndex agent run. A LangGraph node or a workflow step is a hook (hook_triggered/hook_completed), never a nested agent. Model calls are model_request/model_response pairs with token counts; tool calls carry the model’s own tool call id. A failure is recorded once, on the event it happened in.
An adapter that fails to install is logged and skipped; the others still install, because a broken LlamaIndex should not cost you LangGraph.
instrument() with no argument detects a framework by whether it resolves, not by whether it is already imported — Node exposes no equivalent of Python’s sys.modules for ES modules. A framework you have installed but do not use will be imported and patched. Name the one you want if that matters.Most of these frameworks ship an ES-module build and a CommonJS build, which Node loads as two unrelated copies. The adapters patch the copy your application loads (and the CommonJS copy too if something already
required it), so both module systems work. A framework bundled into your own output by esbuild or webpack is out of reach — use the call-site helpers there: langchainHandler(), telemetry(), wrapTool().LangChain without patching
instrument() and never double-records. instrument("langchain") takes sessionId, captureContent, includeChains, graphCallbacks and captureLimit, as the Python adapter does; metadata: { failproofai_sdk_session_id } on a call picks the session for that invocation.
Vercel AI SDK
The AI SDK exports plain functions from an ES module, and an ES module namespace is immutable by specification — there is nowhere to patch. It uses the extension points the SDK itself documents:ai 4–6 read the tracer it carries, ai 7 the telemetry integration.
instrument("ai") does the same process-wide on ai 7: every call, through the AI SDK’s global telemetry-integration list, which is additive and takes nothing from anybody else’s.
On ai 4–6, instrument("ai") records nothing by itself, and logs one warning saying so. The only process-wide hook those majors have is the global OpenTelemetry tracer provider — a single slot OpenTelemetry refuses to hand over once taken. Registering ours would silently refuse your own NodeSDK.start() later in startup and send your http/database spans to a tracer that exports nothing. Use telemetry() at the call site or wrapModel there. If the process runs no OpenTelemetry of its own, opt in with instrument("ai", { registerGlobalTracer: true }): it then records every call that passes experimental_telemetry: { isEnabled: true }, and only takes the slot if it is still empty. registerGlobalTracer: false keeps the default and silences the warning.
If you would rather wrap the model once, wrapModel sees model calls only, because tool calls happen above the model layer. A wrapped model called with nothing around it is recorded as its own run. A streamed call closes however the stream stops — stop_reason: "cancelled" when the consumer cancels it, "error" with the error when it fails part-way:
functionId names the agent span. Keep it low-cardinality — it lands in agent_id, the primary dashboard facet.
Next.js
next build bundles your server’s dependencies by default, and a framework bundled into the build is a copy instrument() cannot reach. Wrap the config once and call instrument() from Next’s startup hook:
withFailproofai adds LangChain, Mastra, LlamaIndex and the SDK itself to serverExternalPackages, keeping your own list. Without it, instrument() warns once per framework it cannot reach rather than failing silently; if you list the packages yourself, set FAILPROOFAI_NEXT_EXTERNALS=1. The Vercel AI SDK and the call-site helpers work either way. An Edge route gets a no-op build: importing the SDK is safe and records nothing.
Token counts on streamed calls
OpenAI-compatible APIs only report usage on a stream when the client asks. LangChain and the Vercel AI SDK ask; for LlamaIndex passadditionalChatOptions: { stream_options: { include_usage: true } } to its OpenAI LLM, and for Mastra build the model with usage enabled (for example createOpenAICompatible({ includeUsage: true })). Otherwise streamed model calls carry no token counts.
Runtimes
Node ≥ 20.9, Bun and Deno — every framework, as an ES module and as CommonJS, is tested on each against Node’s trace. The SDK runs beside thefailproofaid daemon, which ships what it writes.
Your own agent — no framework
For an agent loop you wrote yourself, or a framework without an adapter. You emit the events with the same API the adapters use underneath, so the trace has the same shape and quality. You don’t need to know how the agent is organised. Every hand-built agent already has three places, whatever its functions are called, and those three are the whole integration:agent() lands on that run’s session without taking an id, and nothing else in the program changes — including whatever the agent already writes to its own database.
- A service or a worker: pass your own request or job id as
sessionId, so a session on the dashboard and the record in your own logs or database are the same string. - Sub-agents: nest
agent()calls. The inner one joins the session with the outer as itsparent_id. - Emit the pairs. A
modelRequestwith nomodelResponseis a span the dashboard shows as running forever — hence thecatch.
sdk/typescript/examples/research-agent.ts in the repository is the complete, runnable version: a real OpenAI tool loop instrumented exactly like this, run in CI on every change as an ES module and as CommonJS.

