Skip to main content
For an agent you wrote yourself, or a framework Failproof AI has no adapter for. There is nothing to instrument: you emit the events. This is the same API the four framework adapters call underneath. They are translation tables over it.

Install

No extras, and no dependencies.

Instrument

Read it top to bottom and it says what it means: And what each one actually emits: Everything inside can omit session_id and agent_id. The scopes bind identity on context variables and every event call reads it back, so you never thread ids through your functions. All three work under async with as well as with. Nesting agents builds the tree. parent_id and depth are computed from the stack:

How a scope closes

agent() handles exceptions for you: The error is emitted before agent_end, because the dashboard closes the span at agent_end and anything after it is attributed to nothing. A cancellation is not a failure, so cancelled runs do not pollute the errors surface. The exception is always re-raised: a scope never swallows.

The event methods

Fifteen methods in six families. Most come in pairs — you emit the opener, then the closer, and the SDK measures the span between them.
Prefer the scopes — agent() and tool_call() — wherever they fit. They guarantee the closing event even when the body raises. Reach for these methods directly when your control flow doesn’t nest, such as a model call inside a helper.
The two human families point in opposite directions.No framework signals the second pair, so it is always yours to emit.
Pass request_id when model calls run concurrently. Without it, requests and responses pair in arrival order per agent — and concurrent calls mispair, attaching each response to the wrong request.

Example

A tool-calling loop against the OpenAI API, with no agent framework:
That produces the same six event types an adapter would give you. The complete runnable version, with the tool definitions, ships in the SDK repository under docs/manual/examples/.

Threads and async

Context variables propagate into asyncio tasks automatically. They do not propagate into new threads, because a thread starts with an empty context.
Without propagate(), the worker’s events raise a TypeError naming the fix rather than landing on no session. That is deliberate: an event with no session is skipped by ingest and answered 200, which is the silent failure the identity layer exists to prevent.

Instrument a framework without an adapter

Every agent framework gives you the same three seams. Map them and you have a complete trace — the four shipped adapters do nothing more than this.
1

Bracket the run

2

Bracket each tool

In whatever the framework calls a tool wrapper or middleware.
3

Pair each model call

Got a node, step or middleware boundary worth seeing? Wrap it in a hook pair — hook_triggered / hook_completed — not a nested agent(). agent_id is a low-cardinality facet, and one entry per node drowns it. Hook spans render the same way and give you per-node latency.
Manual and automatic compose. An adapter running inside a hand-written scope joins that session and parents to that agent, so you get one tree rather than two — useful when you instrument one framework yourself alongside a supported one.
Two reasons, and the three seams above are the answer to both:
  • autogen-core has been unmaintained since September 2025.
  • AG2 exposes no process-wide registration point equivalent to the other frameworks’ hooks, so instrumenting it means wrapping every agent at every construction site.
Mapping the seams by hand records the same events, at the same fidelity, as a shipped adapter would.

Going deeper

How the recording actually works. None of it is needed to get started.
Every recording has the same shape: a span opens, work nests inside it, and each opening event gets a closing one.The pair is the unit. Each closing event carries a duration the SDK measures from its opening one.Below is one real run per framework — captured from the examples that ship with the SDK, model name normalised. Note how much comes back from a single call.
14 events
Nodes become hook pairs, so you get per-node latency without them crowding the agent list.
There is no session-end event. A session is not something you close — it is a group of events sharing a session_id.Status is derived from the shape of the trace:So a session ends when every pair is closed. The adapters emit agent_end for you, and on teardown they close anything still open and mark it incomplete — a crashed run settles as done with a visible gap rather than hanging.
This is why a session can span two calls. A LangGraph interrupt() pauses the run, the root span deliberately stays open, and the resuming call closes it. Both calls are one session.
session_id and agent_id are optional on every event method. Omitted, they resolve from the enclosing scope:
Passing them explicitly still works and takes precedence. With nothing bound and nothing passed, the call raises a TypeError naming the fix rather than emitting an event with no session, which ingest would skip while answering 200.Scopes bind identity on context variables. Those propagate into asyncio tasks automatically but not into new threads — wrap a worker in failproofai_sdk.propagate().

Who mints which id

How adapters resolve session_id

First match wins:
  1. An explicit session_id option
  2. Per-call metadata
  3. The enclosing session() scope
  4. Framework metadata
  5. The framework’s own run id
It is never invented while one of those exists — a synthesized id would split one run across several sessions.

Keep agent_id low cardinality

It is the primary facet on every dashboard surface, and a LowCardinality(String) column. A per-run value degrades the column and fills the filter dropdown with one entry per run.Adapters defend that column for you:The real id is kept on fw_agent_id / fw_run_id, where it stays queryable without being a facet.
This guard only touches labels the framework chose. An agent_id you pass yourself — to event.*, or to failproofai_sdk.agent(...) — is recorded exactly as given. Silently rewriting an explicit argument would be worse than the cardinality it prevents, so name your own spans accordingly.
Which framework records what, measured from the runs above:A dash means the framework has no such concept. human_pause and human_interrupt describe a person acting on the agent, which no framework signals — emit those yourself.
An event never arrives alone. One opens a span, one closes it, and the closing event carries a duration the SDK measures from the opening one.
An opening event with no closing one is a span that never finishes. The session renders as still running, forever, and its active duration keeps growing. This is the failure mode to watch for when you instrument by hand.

Correlation rules

  • Reuse the same tool_call_id, hook_id, pause_id, or input_id for the matching completion event.
  • The SDK computes duration_ms for tool_result, hook_completed, agent_resume, and human_input. Passing it to those methods raises ValueError.
  • duration_ms is accepted on model_response, because only the caller knows the real provider latency. It must be an integer — a float raises ValueError at the call site, because the server reads the column as an unsigned 32-bit integer and would store NULL for anything else.
  • Correlation keys are scoped by kind and session, so a tool call and a hook may safely share an id, and two concurrent sessions may reuse the same ids without colliding. They are not scoped by agent: a pair opened under one agent and closed under another still correlates, which is the ordinary case in multi-agent frameworks.
  • request_id pairs model_request with model_response. Without it, model events pair in order per agent, so concurrent calls mispair.
  • A pair split across processes still correlates downstream, but the SDK cannot compute its in-process duration.
  • The pending map holds at most 10,000 starts and evicts the oldest entry when full.
Installing failproofai-sdk installs everything, all four adapters included. The extras pull in the framework, not the adapter.
import failproofai_sdk is contractually zero-dependency, enforced by a test that installs the built wheel with --no-deps and another that proves no framework reaches sys.modules.
There is no failproofai_sdk.crewai attribute. Adapters are deliberately not exposed on the top-level package: touching one would import the framework as a side effect of an attribute access, breaking the zero-dependency promise. Use instrument().
Auto-detection reads sys.modules, not the installed package list, so a framework you have installed but never imported is not instrumented and is never imported on your behalf. To see what is wired up:
instrument("crewai") on a machine without CrewAI does not raise. It logs a warning and returns (), so one missing framework never takes down a process that also instruments others.The warning carries the underlying ImportError, and that message names the exact install command — so the fix is in your logs, not hidden.
Set FAILPROOFAI_SDK_STRICT=1 to have it raise instead. That flag is read once and cached, so export it before your process starts rather than setting it mid-run.
instrument() must come after your framework import. Auto-detection reads sys.modules, so a bare call above the import finds nothing, installs nothing, and returns ().
Get this wrong and the process runs with the SDK imported, the adapter apparently installed, and not one event emitted. It logs a warning saying exactly that — so check your logs first when a run records nothing.
The spool is what makes this safe: your agent never blocks on the network, and a Cloud outage means a growing directory rather than lost events.Each flush writes one batch file, .tmp first, then fsync, then an atomic rename:
The daemon only picks up .jsonl, so it can never read a half-written file. The stem carries a timestamp, process id and sequence number, so two processes flushing in the same millisecond cannot collide. The queue is capped at 10,000 events; past that it drops the oldest and logs.
collector.redact does not apply to your SDK events. It never sees them.
The daemon ships your batches. It does not open or rewrite them.Redaction runs where the daemon writes its own events — not where batches are shipped. So a prompt or a tool argument holding an API key still holds it on arrival.That is deliberate. These are your own instrumentation calls, and rewriting them in transit would mean the events you receive are not the events you emitted.
You control payloads at the source, in two places:
  • Turn off content capture on the adapter. The option name differs, and one adapter has none — this is not a single universal switch:
    • LangChain / LangGraph, Pydantic AI — capture_content=False
    • LlamaIndex — capture_messages=False
    • CrewAI — no content switch at all; session_id is the only option it reads, so prompts and completions are always recorded.
    instrument() drops options an adapter does not read, so passing the wrong name raises nothing and changes nothing.
  • Don’t hand the secret to input= in the first place.
collector.redact is not a substitute for either.
An empty spool directory is the healthy state. Don’t use it to check delivery.
The daemon deletes each batch within milliseconds of shipping it, so an ls races the collector and shows a fraction of what you emitted — indistinguishable from an SDK that recorded nothing.To confirm events actually landed, check the dashboard. To watch the spool fill up, stop the daemon first.
Every callback runs inside a wrapper whose only job is to re-raise, so your call sits in exactly one try and everything the SDK does happens outside it.The default is right in production and wrong while debugging, because it can only ever prove “it did not crash”. Set FAILPROOFAI_SDK_STRICT=1 to make a swallowed failure loud.

Common problems

An opening event has no closing one: a model_request with no model_response, or a tool_use with no tool_result. Use the scopes, which guarantee the pair even when the body raises. If you call the event methods directly, use try and finally.
It is measured from the matching opening event, so it is rejected on tool_result, hook_completed, agent_resume, and human_input. It is accepted on model_response, because only you know the real provider latency, and it must be an integer.
The thread never inherited the context. Wrap the callable in failproofai_sdk.propagate(). See Threads and async.
Extra fields merge last, so one named like a real field such as model or outcome would overwrite it and change a stored column. Namespace yours; the adapters use an fw_ prefix.
agent_id is a low-cardinality facet and you put a run id in it. Use a role or node name and put the real id in a payload field.

Next

How it works

Pairs, ids, session lifecycle, and delivery.

Read a trace

Follow causality through the session you just captured.

Framework adapters

LangGraph, CrewAI, LlamaIndex, and Pydantic AI.