Skip to content
BitExactdocs
Documentation
On this page

Integration surface: API, events, and reference adapters#

BitExact integrates through three stable surfaces: a documented HTTP API, signed outbound events, and a small set of named, supported client adapters — not a vague "works with anything".

The HTTP API#

The viewer serves a machine-readable OpenAPI contract at /openapi.json. There is no interactive form at /docs: the one FastAPI used to mount loads Swagger UI from a CDN, which this viewer's own script-src 'self' blocks, so it never rendered anything in a browser that honors the header. The reference in api-reference.md is generated from the same document. The stable, supported endpoints:

PurposeEndpoint
Search runsGET /api/runs (filters: workflow, workflow_version, environment, agent_id, workflow_instance - the decision's business key, principal - the identity the agent acted as, on_behalf_of - the party it acted for, interaction_mode - interactive or autonomous, decision - a business key, an acting principal or a party acted for, any one, since/until - ISO 8601, half-open, q - a run id or model substring or a head-hash prefix, and find - either decision or q on one text, each row then naming the fields that matched; offset/limit, with X-Total-Count giving the matches beyond the page)
Evidence pack over a selectionGET /api/evidence-pack (runs, workflow, workflow_version, environment, subject, principal, on_behalf_of, interaction_mode, since, until)
Run detail / trajectoryGET /api/runs/{run_id}, GET /api/runs/{run_id}/steps/{n}
Export a bundleGET /api/runs/{run_id}/export?format=json|jsonl
Verify a bundle / reportPOST /api/verify-bundle, POST /api/verify-report
Fork labGET /api/runs/{run_id}/forks, .../fork-matrix, .../reports
Health / coverage / retentionGET /api/health, /api/coverage, /api/retention

Finding the decision a complaint is about. A complaint, a dispute or a subject-access request names a customer, an application or a person, never a run id. Give each run the business key it decides, the identity the agent acts as, and the party it acts for, when you open it:

with bitexact.run("loan-42-decision", workflow="lending",
                  workflow_instance="application-88213",
                  principal="svc:loan-agent",
                  on_behalf_of="user:7f3a9c", on_behalf_of_type="human",
                  authority_ref="consent:88412",
                  interaction_mode="interactive") as r:
    ...

The viewer's Find a run box (GET /api/runs?find=..., which also matches run ids, models and head hashes and names what matched), GET /api/runs?workflow_instance=..., ?principal=... or ?on_behalf_of=..., bitexact list --subject / --principal / --on-behalf-of, and the same flags on bitexact evidence-pack then reach it directly. A run's id stays yours to choose, one per decision. These values are committed in the run's identity entry, and the business key, the acting principal, the party acted for and the interaction mode, each as the run's latest statement of it says it, are indexed for search (bitexact reindex rebuilds the index from the chains). Use a pseudonymous or role id for the party (user:7f3a9c), never a name or an email: the chain keeps what it is given for as long as the run is kept. See Identity and access for what each field means.

Binding an identity in each SDK#

Every SDK states the same fields under the same rules (tests/protocol-vectors/vectors.json, identity_fields and identity), at a run's open and again whenever the party or the composition changes mid run. Each statement is recorded as asserted: BitExact never authenticates it.

# Python
with bitexact.run("chat-7", principal="svc:assistant",
                  on_behalf_of="user:7f3a9c", on_behalf_of_type="human",
                  interaction_mode="interactive") as r:
    r.bind_identity(on_behalf_of="org:retail-bank",
                    on_behalf_of_type="organization",
                    interaction_mode="autonomous")
// TypeScript
await run("chat-7", async () => {
  bindIdentity({ onBehalfOf: "org:retail-bank", onBehalfOfType: "organization",
                 interactionMode: "autonomous" });
}, { identity: { principal: "svc:assistant", onBehalfOf: "user:7f3a9c",
                 onBehalfOfType: "human", interactionMode: "interactive" } });
// Go
bitexact.Run(ctx, "chat-7", bitexact.Options{Identity: &bitexact.Identity{
	Principal: "svc:assistant", OnBehalfOf: "user:7f3a9c",
	OnBehalfOfType: bitexact.OnBehalfOfHuman, InteractionMode: bitexact.InteractionInteractive}},
	func(ctx context.Context) (any, error) {
		bitexact.BindIdentity(ctx, bitexact.Identity{OnBehalfOf: "org:retail-bank",
			OnBehalfOfType: bitexact.OnBehalfOfOrganization, InteractionMode: bitexact.InteractionAutonomous})
		return nil, nil
	})
// Java
Bitexact.run("chat-7", new Bitexact.Options(false, null, Bitexact.Identity.builder()
        .principal("svc:assistant").onBehalfOf("user:7f3a9c")
        .onBehalfOfType(Bitexact.OnBehalfOfType.HUMAN)
        .interactionMode(Bitexact.InteractionMode.INTERACTIVE).build()), () ->
    Bitexact.bindIdentity(Bitexact.Identity.builder().onBehalfOf("org:retail-bank")
        .onBehalfOfType(Bitexact.OnBehalfOfType.ORGANIZATION)
        .interactionMode(Bitexact.InteractionMode.AUTONOMOUS).build()));

A new on_behalf_of replaces the party's type and authority too, so give them again with it. A field in the wrong shape - an interaction_mode the protocol does not know, an empty on_behalf_of, a type or authority without the party it qualifies, or a workflow_digest (BitExact computes it) - is never committed: BitExact keeps the rest of the entry and declares an invalid_argument gap naming the field, with the digest of what was sent. With BITEXACT_FAILURE_MODE=strict, every SDK raises at the call instead (Python and TypeScript raise, Go panics with a *CaptureGap, Java throws Bitexact.CaptureGap), for development and conformance tests. Outside a run the binder sends nothing, and still holds the identity to the same rule, so a call is right or wrong whatever mode it runs in: Python raises outside a recording run, and the TypeScript, Go and Java binders declare the refusal in the log (raising in strict mode).

Auth is delegated to your reverse proxy/SSO (see Identity and access) or a token→role map — BitExact is not an IdP. State-changing calls (export, hold, redaction, …) require the X-BitExact-Action header (a CSRF defense) and the caller's role. Access is filtered by the caller's scope. The authoritative, trust-root operation — verification — also runs fully offline via the standalone verifier, needing no API at all.

Events#

Opt-in, signed notifications of what happens to the evidence, delivered to a Standard Webhooks receiver, Splunk HEC or Datadog Logs. An event is a notification, not evidence: it is derived from a fact BitExact has already written and says no more than that fact, so a receiver that needs a verdict runs the verifier on the run it names. The full reference is Event delivery.

  • The event set. run.sealed (by the SDK, the recorder or bitexact seal), run.hold_placed / run.hold_released, run.redacted, run.repaired, run.forked, capture.gap, the dual-control redaction.* acts, evidence.exported, refusal.recorded, access.role_changed, governance.event, run.purged, alert.opened / alert.resolved for every health alert, and events.test. Each is a CloudEvents 1.0 event of type ai.bitexact.<name>, carrying metadata only: ids, counts, hashes, causes and roles, never recorded content or a free-text reason.
  • Formats. standard-webhooks signs each POST with v1 HMAC-SHA256 under every secret file (two during a rotation) and, optionally, an ed25519 v1a signature a receiver checks with a public key. splunk-hec sends batches with the event id as the indexed field bitexact_id, and can wait for indexer acknowledgment. datadog-logs sends gzip batches to your site's Logs intake.
  • Receiver verification. Check the signature over the raw body, refuse a timestamp more than 5 minutes off, and drop an id already processed: any Standard Webhooks library, bitexact.events.verify_delivery, or the standard-library receiver in Event delivery.
  • Delivery is at least once, in no guaranteed order, from an outbox in the store: it survives a restart, retries for about 3 days, keeps what it cannot deliver as dead letters (bitexact events list --state dead, bitexact events retry), and raises a health alert for each. Recording never waits on it.
  • In the inspector. Settings, Events shows an administrator each destination and every dead letter, retries dead letters and sends a test event (GET /api/events, POST /api/events/retry, POST /api/events/test); destinations are set only in the file on the host (Event delivery).

Configure destinations in the TOML file BITEXACT_EVENTS_CONFIG names, with every secret in a file of its own, then check the wiring:

bitexact events check
bitexact events test

Reference adapters#

Four first-class, supported adapters wrap the clients agents actually use, plus the zero-code proxy for everything else, and two more surfaces that take a whole trajectory rather than a single client:

  • OpenAI — client = wrap_openai(client) returns a copy of an openai.OpenAI client pointed at the active run's recording endpoint. The client passed in is left untouched, so concurrent runs can share one.
  • LangChain callbacks — bitexact.langchain_handler() returns a callback handler you attach at the root of a chain or agent, capturing the whole trajectory tree rather than one client's calls.
  • OpenTelemetry ingest — bitexact.otel_span_processor() turns spans a framework already emits into recorded entries. Second-hand events are recorded prov="asserted", never observed: the recorder did not see them itself, and the evidence says so.
  • OpenTelemetry export — the other direction: a verified run as OTLP/JSON GenAI spans for your tracing backend, with bitexact otel-export RUN or the Download spans button in the inspector's export drawer. Both refuse a run that does not verify or is not signed by a pinned recorder key, and the inspector records each download in the run's custody chain.
  • Anthropic — client = wrap_anthropic(client) does the same for anthropic.Anthropic.
  • TypeScript clients — wrapOpenAI(client) and wrapAnthropic(client) in sdk-ts return a copy of an openai or @anthropic-ai/sdk client whose every call is recorded as wrapFetch records it (an observed http_call, a stream relayed as it arrives), through the fetch the client already uses; the client passed in is left untouched. Outside a run the client comes back unchanged (strict mode raises).
  • Temporal — temporal_run() opens a run scoped to the current Temporal activity execution, so each activity attempt (including retries) is its own evidence trail. Call it inside the activity, never inside the workflow: Temporal runs workflow code in a sandbox that re-imports the defining module and rejects non-deterministic imports, so a workflow defined in the same module as your BitExact imports fails to validate (RuntimeError: Failed validating workflow <Name>). Activities are not sandboxed, which is why the adapter lives there. Called outside an activity, temporal_run() has no execution to name the run for: the run it returns records nothing (recording_state is off, and refusal says why), declares a run_context_lost gap when it is entered, and is noted beside the runs for bitexact coverage report; your code runs as it would without BitExact. Under BITEXACT_FAILURE_MODE=strict, and in replay, regress or fork mode, it raises RuntimeError instead.
  • LangGraph tools — record_tools([...]) wraps LangGraph tool callables so their calls are captured.
  • LlamaIndex, CrewAI and AutoGen — each framework's model client takes a base URL: point it at the run's endpoint and every model call is recorded (OpenAI(api_base=f"{run.base_url}/v1") in LlamaIndex, LLM(model="openai/...", base_url=f"{run.base_url}/v1") in CrewAI, OpenAIChatCompletionClient(base_url=f"{run.base_url}/v1") in AutoGen). Decorate the function behind each tool with @bitexact.record_tool before handing it to FunctionTool, @tool or FunctionTool; the framework reads the same signature and docstring. bench/agents/ holds a working agent for each, run through the capture matrix. For LlamaIndex, bitexact.llamaindex.instrument() also records every tool call LlamaIndex reports, with no tool wrapped (see LlamaIndex).
  • Other providers' clients — Gemini (google-genai), Vertex AI, Azure OpenAI and Bedrock record the same way, by base URL; see Model providers.
  • Zero-code — point any client at the recording proxy (bitexact serve; set the client's base URL to it) and group calls into a run with the X-BitExact-Run-Id header. This is the fallback for any HTTP client without a named adapter — supported as the proxy path, not claimed as magic.

LangChain and LangGraph#

Most agents a bank or insurer builds run on LangChain or LangGraph. BitExact captures them three ways, and the strength of the evidence differs, so choose on purpose:

What you attachModel calls recorded asTool calls recorded asReplays
The model client pointed at the run (base_url)http_call, observed: the bytes on the wire—yes
record_tools(...) around the agent's tools—tool_call, observed: the call and its resultyes
bitexact.langchain_handler() alonemodel, asserted: what the framework reportedtool_call, assertedno

The callback handler is the quickest to attach and sees the whole trajectory tree, but it records the framework's view of each call, not the call itself. For evidence an examiner can rely on, and runs you can replay, use all three:

import bitexact
from bitexact.adapters import record_tools
from langchain_openai import ChatOpenAI

with bitexact.run("claim-881-decision", workflow="claims",
                  workflow_instance="claim-881", principal="user:alice") as r:
    llm = ChatOpenAI(model="gpt-4o", base_url=f"{r.base_url}/v1")
    agent = build_agent(llm, tools=record_tools(tools))   # your agent
    agent.invoke(question,
                 config={"callbacks": [bitexact.langchain_handler()]})

Each call is recorded once, by the strongest source attached. Observed evidence wins: the handler's (or a span's) asserted copy of a call BitExact observed itself is dropped. What one model call and one tool call leave in the run, for each combination:

Attached in the runOne model callOne tool call
langchain_handler()model, assertedtool_call, asserted
langchain_handler() + model client at the runhttp_call, observedtool_call, asserted
langchain_handler() + record_toolsmodel, assertedtool_call, observed
langchain_handler() + model client at the run + record_toolshttp_call, observedtool_call, observed
otel_span_processor()model, assertedtool_call, asserted
otel_span_processor() + model client at the runhttp_call, observedtool_call, asserted
otel_span_processor() + record_toolsmodel, assertedtool_call, observed
otel_span_processor() + model client at the run + record_toolshttp_call, observedtool_call, observed
langchain_handler() + otel_span_processor()model, asserted, twicetool_call, asserted, twice

The suite runs every row of this table and checks what it records.

  • With the model client pointed at the run, capture_llm="auto" (the default) drops the handler's copy of a call the proxy recorded, and still records a call from a client you did not point at the run.
  • With record_tools (or @bitexact.record_tool), the handler's copy of a call the wrapped tool recorded is dropped; a tool you did not wrap is still recorded from the handler. The two are matched by the tool's name - a LangChain tool whose name differs from its function's is matched through record_tools, which knows both.
  • The handler and the span processor are two second-hand views of the same calls, and neither outranks the other: attached together, each records its own. Attach one of them.
  • record_tools wraps a LangGraph tool's function (and its coroutine). A class-based tool with no function to wrap is handed back unwrapped, and the run declares an invalid_argument gap naming it (where: record_tools:<tool name>); the other tools are still wrapped. A tool that will not take the wrapped function in place of its own is handed back the same way, with a gap naming it. Under BITEXACT_FAILURE_MODE=strict it raises instead. To record its calls, decorate the callable behind it with @bitexact.record_tool.
  • A failure in BitExact's own code inside a handler callback is a declared gap naming the callback (where: langchain:<callback>), and LangChain gets the callback's usual answer. Under BITEXACT_FAILURE_MODE=strict it raises instead, into the agent's call: LangChain discards what a handler raises unless the handler sets raise_error, which this handler sets in strict mode.
  • A handler made outside any run records into the run active where each event arrives. An event that arrives where no run is active, while runs record in the process, is not dropped: every recording run declares a run_context_lost gap naming it (where: langchain:<kind>).
  • langchain_handler(langsmith_project=...) (or $LANGSMITH_PROJECT) records the LangSmith trace coordinates, so the signed record links back to the trace your engineers already use. The handler runs alongside LangSmith's own tracer.
  • Fan-out to a thread pool needs bitexact.propagate (below).

OpenTelemetry#

If your agents already emit OpenTelemetry GenAI spans (gen_ai.* attributes), BitExact can record them without another integration:

import bitexact
from opentelemetry.sdk.trace import TracerProvider

provider = TracerProvider()
provider.add_span_processor(bitexact.otel_span_processor())

with bitexact.run("claim-881-decision"):
    ...        # every gen_ai span that ends inside the run is recorded
  • A model span becomes a model entry and a tool span a tool_call entry, as the mapping below says. Both are asserted, because BitExact did not see the call itself. The entry carries the prompt, the response, and a tool's arguments and result only when your instrumentation records them on the span (its opt-in content attributes). An instrumentation on the GenAI semantic conventions puts content on the span only when OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is SPAN_ONLY or SPAN_AND_EVENT; Google's ADK reads true as EVENT_ONLY, which leaves it off the span.
  • That content can be kept hash-only: set BITEXACT_OTEL_CONTENT=hash, or pass content="hash" to bitexact.otel_span_processor() or bitexact.record_otel_spans(). The setting is for spans alone: a capture policy rule matches a field by kind and path whichever door recorded it, so it cannot keep only what your instrumentation reported hash-only. A tool call's args and result and a model call's request and response are then recorded with the custody hint hash-only, so the evidence stores each field's commitment and no value, declared in the entry's capture block and opened only with the capture key (capture controls). content="keep" leaves content to the capture policy whatever the environment says. Any value of BITEXACT_OTEL_CONTENT other than keep or hash keeps content hash-only, so a mistyped setting never stores more than was asked.
  • An MCP span whose mcp.method.name is tools/call is a tool_call carrying the span's MCP session fields. It is asserted like any span: only the MCP proxy observes an MCP call.
  • A span's name decides nothing. Mastra, through @mastra/otel-bridge, names a tool span execute_tool {tool} and states it the way the mapping reads: gen_ai.operation.name execute_tool with gen_ai.tool.name.
  • A call BitExact observed itself is recorded once, observed (see the combinations above): a model span over a call the run's proxy recorded, and a tool span over a call a wrapped tool recorded, add no second entry.
  • A gen_ai span that ends where no run is active is ignored while no run records. While runs record in the process it is not dropped: every recording run declares a run_context_lost gap naming it (where: otel:<kind>:<span name>), since which run it belonged to cannot be known. Spans that end on a thread the run's context does not reach need the run bound: otel_span_processor(run=r).
  • Already-exported spans are ingested with bitexact.record_otel_spans(run, otlp_json), in start-time order.
  • OpenTelemetry capture is a record of what your instrumentation reported. For observed, replayable evidence of the model calls themselves, point the model client at the run as well, as above.
  • The other direction, a verified run as GenAI spans for your tracing backend, is bitexact otel-export (see Reference adapters above).

Beside a policy engine, on one OpenTelemetry pipeline#

A policy engine that decides whether an action may run, and BitExact, which records what the agent did and what was decided, can share one OpenTelemetry pipeline. Neither calls the other, and each keeps its own record. BitExact decides nothing and changes no span.

import bitexact
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))  # to your collector
provider.add_span_processor(bitexact.otel_span_processor())

with bitexact.run("invoice-4417-payment"):
    verdict = engine.decide(request)          # your policy engine
    bitexact.record_policy_decision(
        "engine:payments/allow", "pass" if verdict.allowed else "fail",
        reported_by="engine", input=request,
        detail={"decision_id": verdict.id})
    ...                                       # the agent goes on
  • One provider, two processors. The exporter that feeds your collector and BitExact's span processor sit side by side on the agent's TracerProvider. Every span still reaches the collector as before; BitExact reads the gen_ai spans that end inside a run and records them as the mapping says.
  • A decision is recorded where the agent receives it. The mapping reads model calls and tool calls only. A span the engine emits about a decision carries no gen_ai attribute, so it makes no entry. Record the decision with bitexact.record_policy_decision, and a person's approval of what it held with bitexact.record_human_decision (see Policy checks). Both are asserted: the agent process reports the engine's verdict, and an auditor confirms it against the engine's own record. Put the engine's decision id in detail so the two can be matched; a decision with no id cannot be.
  • Joining the two records. Each entry recorded from a span carries otel_trace_id, otel_span_id and otel_parent_span_id, so an auditor can find the spans of the same trace in your tracing backend.
  • BitExact is not an OTLP endpoint. The collector does not export to BitExact. Spans are read in the agent process, or handed over afterwards as an OTLP/JSON document (resourceSpans) with bitexact.record_otel_spans(run, otlp_json).
  • Content on a shared pipeline. When your instrumentation puts prompts and tool arguments on its spans for the engine or the backend, set BITEXACT_OTEL_CONTENT=hash to keep BitExact's copy of them hash-only.

The span mapping#

The first row that matches a span decides its entry. The tables are generated from otel_genai_map.json, the mapping the processor and record_otel_spans read.

The spanBitExact records
gen_ai.operation.name is chatmodel
gen_ai.operation.name is text_completionmodel
gen_ai.operation.name is generate_contentmodel
gen_ai.operation.name is execute_tooltool_call
gen_ai.operation.name is invoke_agentno entry
gen_ai.operation.name is create_agentno entry
gen_ai.operation.name is invoke_workflowno entry
mcp.method.name is tools/calltool_call
carries gen_ai.tool.nametool_call
carries gen_ai.request.modelmodel
carries gen_ai.response.modelmodel
EntryFieldFrom the first the span carriesWhen it carries none
tool_calltoolgen_ai.tool.nameunknown
tool_callstatuserror when error.type is setok
tool_callerror.typeerror.typeomitted
tool_callmcp.session_idmcp.session.idomitted
tool_callmcp.protocol_versionmcp.protocol.versionomitted
tool_callmcp.jsonrpc_idjsonrpc.request.idomitted
tool_callargsgen_ai.tool.call.arguments, decoded when its text is a JSON object or array at most 30 levels deep, else kept as textomitted
tool_callresultgen_ai.tool.call.result, decoded when its text is a JSON object or array at most 30 levels deep, else kept as textomitted
tool_callotel_tool_call_idgen_ai.tool.call.idomitted
tool_callotel_conversation_idgen_ai.conversation.idomitted
tool_callotel_agent_namegen_ai.agent.nameomitted
modelmodelgen_ai.request.model, gen_ai.response.modelunknown
modelprovidergen_ai.provider.name, gen_ai.systemomitted
modelusage.input_tokensgen_ai.usage.input_tokens, a whole number within the safe rangeomitted
modelusage.output_tokensgen_ai.usage.output_tokens, a whole number within the safe rangeomitted
modelerror.typeerror.typeomitted
modelrequest.messagesgen_ai.input.messages, decoded when its text is a JSON object or array at most 30 levels deep, else kept as textomitted
modelresponse.messagesgen_ai.output.messages, decoded when its text is a JSON object or array at most 30 levels deep, else kept as textomitted
modelotel_tool_call_idgen_ai.tool.call.idomitted
modelotel_conversation_idgen_ai.conversation.idomitted
modelotel_agent_namegen_ai.agent.nameomitted

A dotted field nests: error.type is the entry's error object's type.

A span whose status is ERROR and that sets no error.type is read with the error.type the OpenTelemetry semantic conventions give it: the exception.type of its last exception event, else _OTHER. Pydantic AI and Strands report a failed call only that way. A span not in error is never read as one, whatever exception events it recorded. Both SDKs apply the rule, with _OTHER from the shared map.

MCP#

A tool served over MCP runs in another process, so record_tool cannot wrap it. Two paths record its calls, and they say different things:

  • bitexact mcp-proxy (see the CLI reference) sits between the client and the server and records each tools/call from the JSON-RPC bytes that cross it: outside observation, with the server, session and JSON-RPC id in the entry's mcp block. A client names the run with params._meta: bitexact.mcp.client_meta() (in TypeScript, mcpClientMeta(), which names the run whether or not the agent has a collector). The proxy records each call into the run its _meta names when no run is given otherwise: on stdio without --run-id, on Streamable HTTP without X-BitExact-Run-Id. A call that names no run is forwarded and declared run_context_lost.
  • Sharing a run with the proxy. A run opened with bitexact.run holds its chain's one write lock, so a proxy in another process may not write into it. Start the proxy with env=bitexact.mcp.proxy_env() instead: the environment carries the run's grant (BITEXACT_RUN_GRANT, the run's id, its loopback endpoint and its secret), and the proxy hands each call for that run to the run, which records it observed through the mcp-proxy path before the client has the answer. On stdio a call that names no run belongs to the granting run. That record is what a framework's report of the same call meets (see the table below). Whoever holds the grant can add observed tool calls to that run: the proxy keeps it out of the MCP server's environment and never sends it on the wire, and refuses a grant whose endpoint is not plain http on the loopback. A call the run could not take, because it has ended, is a declared recorder_unavailable gap.
  • bitexact.mcp.wrap_session(session) is in-process capture, like record_tool: each call_tool of an MCP Client or ClientSession is a tool_call recorded inside the agent's own process, from what the client was handed rather than what crossed the wire, and with no mcp block.
import bitexact
from mcp import Client

with bitexact.run("claim-881-decision"):
    async with Client(server) as client:
        session = bitexact.mcp.wrap_session(client)
        result = await session.call_tool("lookup", {"claim": "881"})
  • The wrapper records a call as the proxy does: the tool's name as tool, its arguments as args, and the result in its wire form. A tool error the server returns (isError: true) and a JSON-RPC error the server answers with are both status: "error"; the JSON-RPC error is kept as error, with its code, message and data. result_class names the result type the session handed back. Timeouts, progress callbacks and meta reach the session unchanged and are not recorded.
  • A live call returns the session's own result. A replay serves the recorded result as the type the session returned live, a CallToolResult or an InputRequiredResult, or raises the same MCPError, without calling the server.
  • A wrapped session whose calls also cross the proxy, started with env=bitexact.mcp.proxy_env() so the proxy hands its records to the run, leaves one entry per call: the proxy's, from outside the agent. The wrapper records a call itself only when the proxy handed the run no record of the same tool with the same arguments while the call ran. A replay serves each call from whichever entry holds it.

Replaying through the proxy. bitexact mcp-proxy --replay RUN, on stdio with the server's command after -- or over Streamable HTTP with --listen and --upstream, serves a recorded run back to an MCP client. Each tools/call is matched on its tool and arguments against the run's observed tool_call entries and answered from the entry it matched; it never reaches the server. The handshake, tools/list and notifications still go to the server. A replay records nothing and takes no --run-id. It needs a license that grants replay, and the run must verify: exit 2 when it does not. A call the run holds no match for, or one it cannot serve, is answered with a JSON-RPC error naming the step. That call, or a recorded call nobody asked for again, makes the replay exit 1.

MCP capture beside a framework's report#

A framework that reports its tool calls reports an MCP tool call too. What one MCP tool call leaves in the run, for each capture path and report:

MCP captureFramework reportOne MCP tool call leaves
bitexact mcp-proxynonetool_call, observed, with an mcp block
bitexact mcp-proxylangchain_handler()tool_call, observed; the report, matched on the tool name, is dropped
bitexact mcp-proxyotel_span_processor()tool_call, observed; the report, matched on the tool name and on jsonrpc.request.id when the span carries it, is dropped
bitexact mcp-proxyopenai_agents.processor()tool_call, observed; the report, matched on the tool name, is dropped
bitexact mcp-proxyclaude_agent.hooks()tool_call, observed; the report, matched on the tool name the SDK gives as mcp__<server>__<tool>, without that prefix, is dropped
bitexact mcp-proxyadk.plugin()tool_call, observed; the report, matched on the tool name, is dropped
wrap_sessionnonetool_call, observed in process, with no mcp block
wrap_sessionlangchain_handler(), otel_span_processor(), openai_agents.processor() or adk.plugin()tool_call, observed in process; the report, matched on the tool name, is dropped
wrap_sessionclaude_agent.hooks()tool_call, observed in process; the report, matched on the tool name the SDK gives as mcp__<server>__<tool>, without that prefix, is dropped
wrap_session and bitexact mcp-proxy started with proxy_env()nonetool_call, observed, with an mcp block: the proxy's; the wrapper records none
bitexact mcp-proxy, relayed through the recorderan SDK's report from another process, relayed through the recorderboth tool_calls, observed and asserted: neither is dropped, and the second to arrive, in either order, carries corroborates (the first's source_id and seq) in its recorder-written source block, when both name the same MCP session and JSON-RPC id
noneany of themthe report's tool_call, asserted

Who observes from outside the agent. An in-process observed entry - from @bitexact.record_tool, record_tools, @bitexact.record_check or bitexact.mcp.wrap_session - is the agent process's own capture: code running in that process wrote it, and a compromised process could write it differently. Only the recording proxy (a model client pointed at the run, or bitexact serve) and bitexact mcp-proxy observe from outside the agent's code path: what they record is the bytes that crossed them. A framework's report is asserted whichever path is attached.

Claude Agent SDK (Python)#

bitexact.claude_agent.hooks() returns a hooks dict for ClaudeAgentOptions. wrap_can_use_tool(fn) wraps its can_use_tool callback, and wrap_hook(fn) wraps your own PreToolUse decision hook. Every entry they write is asserted, with reported_by: "claude-agent-sdk", because the SDK reported it and BitExact did not see it.

import bitexact
from bitexact import claude_agent
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient

with bitexact.run("claim-881-decision"):
    options = ClaudeAgentOptions(
        hooks=claude_agent.hooks(),
        can_use_tool=claude_agent.wrap_can_use_tool(approve))
    async with ClaudeSDKClient(options=options) as client:
        ...
  • PostToolUse records a tool_call: tool, the tool's input as args, its response as result, tool_use_id, agent_id and the SDK's session_id.
  • PostToolUseFailure records a tool_call with status: "error" and the SDK's error text as error.message.
  • SubagentStart and SubagentStop record a context entry naming the subagent's agent_id and agent_type.
  • An observed MCP call is not reported twice. The SDK names an MCP server's tool mcp__<server>__<tool>. When bitexact mcp-proxy recorded that call on the run's chain, or a wrapped tool named <tool> ran it in process, the observed entry is the evidence and the hook's report is dropped. Each observed call, made since the hooks first met the run, answers for one report; a call nothing observed keeps its report.
  • Never in the way. Every hook returns {} and never a permissionDecision. PreToolUse is not registered by default, because a PreToolUse callback that times out blocks the tool call. hooks(record_requested=True) adds one that returns {"async_": True} at once, which cannot block, modify the call or inject context, and records the requested call as a context entry on a worker thread after it returns, so a store that stalls never delays the tool call.
  • Permission verdicts. wrap_can_use_tool records each verdict as a policy_check with check: "claude-agent-sdk:can_use_tool", and wrap_hook as check: "claude-agent-sdk:PreToolUse": allow is pass and deny is fail. A hook's ask or defer holds the call for a human and is held. A can_use_tool result can only allow or deny (the SDK raises on any other), so there ask or defer is not a verdict. Anything else is unknown, with detail.returned naming it. Neither wrapper records a human_decision: the SDK never says who decided. A callback that knows its approver records it with run.record_human_decision(...). A callback that raises is recorded as error, and its exception is re-raised unchanged. The callback's own result reaches the SDK. The verdict recorded is your code's, not BitExact's.
  • Losses are declared. A failure in BitExact's code, or a hook input that is not a dict, is a declared gap naming the hook (claude-agent-sdk:<event>), and the hook answers the SDK as it does on success. A hook that fires with no run carried declares run_context_lost. Strict mode stops the agent instead. A hook's exception reaches the CLI only as an error reply, which does not stop the agent, so a hook whose record fails answers continue: false with a stopReason naming the hook and the failure. In strict mode PreToolUse records in the hook, so its failure stops the agent before the tool runs.
  • Bind a run with hooks(run=...). Unbound, hooks and wrappers made in a run report into it while it is open; made outside any run, or reused after it ended, they report into the run active where each event arrives. Merge your own hooks into the same events yourself: each event's value is a list of HookMatchers.

Claude Agent SDK (TypeScript)#

claudeAgentHooks() returns hooks for a Claude Agent SDK query's options.hooks, and wrapCanUseTool(fn) wraps its canUseTool callback. Both report into the run in scope. Every entry they write is asserted, with reported_by: "claude-agent-sdk", because the SDK reported it and BitExact did not see it.

import { query } from "@anthropic-ai/claude-agent-sdk";
import { run, claudeAgentHooks, wrapCanUseTool } from "@bitexact/sdk";

await run("claim-881-decision", async () => {
  for await (const message of query({
    prompt: "Review claim 881",
    options: { hooks: claudeAgentHooks(), canUseTool: wrapCanUseTool(approve) },
  })) { /* ... */ }
});
  • PostToolUse records a tool_call: tool, the tool's input as args, its response as result, tool_use_id and agent_id.
  • PostToolUseFailure records a tool_call with status: "error" and the SDK's error text as error.message.
  • SubagentStart and SubagentStop record a context entry naming the subagent's agent_id and agent_type.
  • An observed MCP call is not reported twice. A call to mcp__<server>__<tool> that a recordTool tool named <tool> ran in the run leaves the observed entry, and the hook's report is not sent; each observed call answers for one report. This process sends to a collector and cannot read the run's chain, so a call bitexact mcp-proxy observed keeps the hook's asserted report beside it.
  • Never in the way. Every hook resolves to {} and never to a permission decision. PreToolUse is not registered by default, because a PreToolUse callback that times out blocks the tool call. claudeAgentHooks({ requested: true }) adds one that resolves at once to { async: true }, which cannot block or change the call, and records the requested call as a context entry.
  • Permission verdicts. wrapCanUseTool records each verdict as a policy_check with check: "claude-agent-sdk:can_use_tool" and the tool in detail: allow is pass and deny is fail. A PermissionResult can only allow or deny, so anything else, ask or defer included, is unknown, with detail.returned naming it. A callback that throws is recorded as error, and its error is rethrown unchanged. The callback's result reaches the SDK unchanged. The verdict recorded is your code's, not BitExact's.
  • Losses are declared. A hook called with no run carried, an input that is not an object, or a value that is not JSON is a declared gap and never an exception. Strict mode raises instead.
  • Merge your own hooks into the same events yourself. Each event's value is a list of matchers.

OpenAI Agents SDK (Python)#

bitexact.openai_agents.processor() records an agent's trajectory from the OpenAI Agents SDK's own trace spans. It adds itself with add_trace_processor, beside every processor already registered. It never calls set_trace_processors, which would remove your exporters and the SDK's default one. Register it once per process:

import bitexact
from bitexact import openai_agents

openai_agents.processor()

with bitexact.run("claim-881-decision"):
    ...  # run the agent

With run=, every span is recorded into that run. Without it, each span is recorded into the run active where the span ends.

SpanEntry
function_spantool_call: tool, the input as args (JSON text decoded), the output as result, status (error, with the span's error, when the span carries one)
generation_span, response_spanmodel: model, request (messages, and the model config as params), response, usage
guardrail_spanpolicy_check: check is openai-agents:<guardrail name>, outcome is fail when the guardrail was triggered and pass otherwise
handoff_spancontext named handoff, naming from_agent and to_agent
agent, task, turn, customno entry: these spans are structure
  • Reported, not observed. A span is the SDK's report of a call, not the wire. Every entry is asserted, with reported_by: "openai-agents", and carries openai_agents_trace_id, openai_agents_span_id and openai_agents_parent_span_id.
  • No duplicates. A tool call that a wrapped tool or the MCP proxy observed, and a model call that the run's proxy observed, add no second entry.
  • Content follows trace_include_sensitive_data. With it on, a tool call's args and result and a model call's request and response are kept as the capture policy decides. With it off, each of them is recorded hash-only: committed under the capture key and never stored. The setting is the processor's include_sensitive_data=. Left unset, it is the SDK's default, read from OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA when each span ends: on unless that variable is set to something other than 1, true, yes or on.
  • Never in the way. Every span callback runs under the adapter guard. A BitExact failure is a gap declared at openai-agents and never reaches the agent. A span that ends where no run is carried, while runs record in the process, is declared run_context_lost (openai-agents:<kind>). A registration that fails is a declared gap, and processor() returns None. Strict mode raises instead, into the agent's run: the SDK's default provider logs and discards what a processor raises, so the processor relays it, and it is raised from the span's start or end in the SDK's own call.

Approvals of a held tool call (Python)#

A tool the SDK holds for approval (needs_approval) interrupts the run, and your code decides it with RunState.approve or RunState.reject. Call them through openai_agents.approve and openai_agents.reject instead, and the decision is recorded with the person who made it:

from agents import Runner
from bitexact import openai_agents

result = await Runner.run(agent, "Pay invoice 4417")
state = result.to_state()
for item in result.interruptions:
    openai_agents.approve(state, item, actor="ops@example.com",
                          reason="within limit")
    # or: openai_agents.reject(state, item, actor="ops@example.com",
    #                          reason="over limit",
    #                          rejection_message="Refused by finance")
result = await Runner.run(agent, state)

approve(state, approval_item, *, actor, reason=None, always_approve=False, run=None) calls state.approve(approval_item, always_approve=...), and reject(state, approval_item, *, actor, reason=None, always_reject=False, rejection_message=None, run=None) calls state.reject(approval_item, always_reject=..., rejection_message=...). Each returns the SDK's result unchanged.

  • What is recorded. Two asserted entries reported by openai-agents: a policy_check with check: "openai-agents:needs_approval" and outcome held, whose detail names the item's tool_name and call_id when they are strings; then a human_decision (actor, approve or reject, and reason) whose binds_step is that check's step. A run handed to a host collector gets no step back, so there binds_step is left out rather than guessed.
  • The SDK goes first. If its call raises, the exception reaches your code and nothing is recorded, because no decision took effect.
  • Outside a recording run (no run, or replay and regress), only the SDK is called. An empty actor or a reason that is not a string is a gap declared at openai-agents, and the SDK's result still returns. Strict mode raises instead.

OpenAI Agents SDK (JavaScript)#

@bitexact/sdk records an @openai/agents agent the same way, from the SDK's trace spans. Register the processor once with the addTraceProcessor you import from @openai/agents; it is added beside every processor already there, never through setTraceProcessors, which would remove your exporters:

import { addTraceProcessor, run as runAgent } from "@openai/agents";
import { run, openaiAgentsProcessor, openaiAgentsApprove,
         openaiAgentsReject } from "@bitexact/sdk";

openaiAgentsProcessor(addTraceProcessor);

await run("invoice-4417-payment", async () => {
  let result = await runAgent(agent, "Pay invoice 4417");
  for (const item of result.interruptions) {
    openaiAgentsApprove(result.state, item,
                        { actor: "ops@example.com", reason: "within limit" });
    // or: openaiAgentsReject(result.state, item, { actor: "ops@example.com",
    //       reason: "over limit", message: "Refused by finance" });
  }
  result = await runAgent(agent, result.state);
});
  • The spans. Each span is recorded into the run in scope where it ends, mapped as the Python table above maps it (openaiAgentsEntry): a function span is a tool_call, generation and response spans are model entries, a guardrail span is a policy_check (openai-agents:<name>, fail when triggered, pass otherwise), a handoff span is a context entry named handoff, and the rest are structure. Every entry is asserted, with reported_by: "openai-agents".
  • No duplicates. A function span over a call that a recordTool tool of that name ran after the span started, and a model span over an HTTP call wrapFetch recorded after it started, add no second entry.
  • Content. The run's traceIncludeSensitiveData decides what a span carries. openaiAgentsProcessor(addTraceProcessor, { includeSensitiveData: false }) also keeps every content field a span carries (args, result, request, response) hash-only.
  • Approvals. openaiAgentsApprove(state, item, { actor, reason, alwaysApprove }) calls state.approve(item, { alwaysApprove }), and openaiAgentsReject(state, item, { actor, reason, alwaysReject, message }) calls state.reject(item, { alwaysReject, message }). Each returns the SDK's result unchanged; when the SDK throws, the error is yours and nothing is recorded. They record the same held policy_check and human_decision as the Python package. call_id is read from the item's raw item (callId, else id). The decision is linked to the check by its event id (caused_by), never a guessed binds_step, because a host collector hands back no step.
  • Never in the way. A span the processor cannot read is a declared unclassified gap at openai-agents, and a span that ends where no run was carried, while runs are open, is a declared run_context_lost (openai-agents:<kind>) on every open run. An empty actor or a reason that is not a string is a declared invalid_argument gap and nothing is recorded. A registration that fails is a declared gap and openaiAgentsProcessor returns null. Strict mode raises instead.
  • Delivery. Entries go onto the collector shipper's queue as each span ends; the processor's shutdown and forceFlush deliver it.

Google ADK#

bitexact.adk.plugin() returns an ADK BasePlugin. Add it to your app's plugins, after your own plugins:

import bitexact
from bitexact import adk
from google.adk.agents import LlmAgent
from google.adk.apps import App

recorder = adk.plugin()
agent = LlmAgent(..., before_tool_callback=recorder.wrap_before_tool(gate))
app = App(name="claims", root_agent=agent,
          plugins=[*my_plugins, recorder])

with bitexact.run("claim-881-decision"):
    ...  # run the app
  • after_tool_callback records a tool_call: tool, the arguments as args, the tool's result, and ADK's function_call_id and invocation_id.
  • on_tool_error_callback records a tool_call with status: "error" and the error's type and message. The tool's exception reaches your code as ADK raises it.
  • A call answered in the tool's place. ADK runs the plugins' before_tool_callback in order, then the agent's own, and the first that returns a value answers the call: the tool does not run, and ADK still runs after_tool_callback with that value as the result. Such a call is recorded as a policy_check with check: "adk:before_tool" and outcome fail, with tool, function_call_id and invocation_id in detail. The value that stood in for the tool's result is detail.substituted_response, recorded hash-only: committed and never stored. No tool_call is recorded for it, because no tool ran.
  • Which answers it sees. A plugin listed ahead of BitExact's is seen when it answers. The agent's own before_tool_callback is seen when you wrap it with wrap_before_tool(fn), which returns the callback's result unchanged and lets its exceptions through unchanged. An unwrapped agent callback cannot be told apart from the tool, and its answer is recorded as the tool_call ADK reported.
  • Reported, not observed. A callback is ADK's report of a call, not the wire. Every entry is asserted, with reported_by: "adk", and names adk as its capture path (via), as every gap the plugin declares does.
  • No duplicates. A tool call that a wrapped tool or the MCP proxy observed adds no second entry.
  • Never in the way. Every callback returns None, because ADK reads any other value as an answer in the agent's place. Every callback runs under the adapter guard, because ADK hands a plugin's exception to your code as a RuntimeError. A BitExact failure is a declared gap naming the callback (adk:<callback>). A callback that fires where no run is carried, while runs record in the process, is declared run_context_lost. Strict mode raises instead.
  • What it cannot see. A plugin ahead of BitExact's that replaces a tool's result in its after_tool_callback, or answers a tool's error in its on_tool_error_callback, ends ADK's chain before BitExact's plugin runs, so that call is not recorded.
  • Bind a run with plugin(run=...). Without it, each callback records into the run active where it fires. plugin(name=...) renames the plugin; ADK requires each plugin's name to be unique in the app.

LlamaIndex#

bitexact.llamaindex.instrument() registers a span handler on LlamaIndex's root dispatcher. Call it once, at startup:

import bitexact
from bitexact import llamaindex

llamaindex.instrument()

with bitexact.run("claim-881-decision"):
    ...  # run the agent, or call its tools
  • Why a span handler. LlamaIndex reports a tool call through no instrumentation event, and its OpenTelemetry bridge (llama-index-observability-otel) puts no gen_ai attribute on the span it exports for one, so otel_span_processor() records nothing from it. LlamaIndex does open a span for every call of a tool's __call__, call and acall, whether an agent or your own code calls it. The handler reads that span.
  • What it records. One tool_call per call: tool, status (ok, or error when the ToolOutput says is_error, with the exception's type and message), the output's raw_input as args and its raw_output as result, and LlamaIndex's span id as llamaindex_span_id. A call that raises is recorded with status: "error", the exception's type and message, and the call's arguments as args; the exception reaches your code unchanged.
  • One call, one entry. A tool's methods call one another (__call__ calls call, and an async adapter's acall calls its tool), so one call opens nested spans of one tool; the outermost records it. A tool that calls another tool records both calls.
  • Reported, not observed. A span is LlamaIndex's report of a call. Every entry is asserted, with reported_by: "llamaindex", and names llamaindex as its capture path (via), as every gap the handler declares does.
  • No duplicates. A tool call that a wrapped tool (@bitexact.record_tool) or the MCP proxy observed adds no second entry.
  • Never in the way. Every handler method runs under the adapter guard: a BitExact failure is a declared gap naming the method (llamaindex:<method>), and LlamaIndex's span goes on as it would. A tool span that ends where no run is carried, while runs record in the process, is declared run_context_lost. Strict mode raises instead, from the agent's tool call. LlamaIndex's dispatcher discards whatever a span handler raises, so the handler holds the failure while the dispatcher notifies it and raises it once the dispatcher has: before the tool runs when the span cannot open, and in place of the tool's result, or of its own exception, when the span ends.
  • One handler per process. Without run=, each call records into the run active where it ends, and calling instrument() again returns the handler already registered. instrument(run=...) registers a handler of its own bound to that run, for calls that end outside the run's context.
  • Model calls are recorded by pointing LlamaIndex's model client at the run (OpenAI(api_base=f"{run.base_url}/v1")), as for every framework.

Auditant#

Auditant is a policy engine: it decides whether an action may run (allow, deny or pending_approval). BitExact decides nothing. It records the verdict. bitexact.auditant.wrap(handle) returns a handle that delegates everything to Auditant's, and records each decide or adecide in the run in scope (or the run= you pass) as one policy_check. The entry is asserted, with reported_by: "auditant", because Auditant reported it and BitExact did not see the decision made.

import bitexact
from bitexact import auditant

guarded = auditant.wrap(handle)        # handle: your Auditant handle

with bitexact.run("claim-881-payout"):
    verdict = guarded.decide({"action": "wire_transfer", "amount": 25000,
                              "input": payload})
    # `verdict` is Auditant's own decision object, unchanged
policy_check fieldFrom Auditant's decision
checkauditant: and policyId, or auditant when it names none
versionpolicyVersion, when it is a string
outcomeallow is pass, deny is fail, pending_approval is held. A decision where the check did not run is unknown: one with no effect, and Auditant's fail-open verdict (an allow under policy fail-open from engine unavailable, returned when its control plane cannot be reached). A decision's fields are read under their wire names (policyId) or their Python names (policy_id). Any other effect is unknown, with detail.returned naming it. A pass is never guessed.
detaileffect, engine, reasons, monitorMode, evaluatedAgainst.approvalId (as approvalId), eventId and log when present; the request's action and amount; and input_sha256, the SHA-256 of the request's input in RFC 8785 canonical JSON
  • The input is never recorded. Auditant hashes the input and never sends it. BitExact records only its own hash of it (input_withheld: "not JSON" when it is not JSON).
  • Auditant's behavior is untouched. The decision object is returned as-is. If Auditant's call raises, the check is recorded as error with the exception's type in detail.exception, and the same exception reaches your code.
  • Losses are declared. A failure while recording is a declared capture gap at auditant, and the decision is still returned. Strict mode raises instead.
  • Approvals with a known approver. auditant.approve(client, log=, session_id=, action=, approver=, approve=, reason=) sends POST /v1/approvals through your httpx2.Client pointed at Auditant's control plane. When Auditant accepts it, BitExact records a held policy_check (auditant:approval) and a human_decision naming the approver, bound to that check. An approval made in Auditant's dashboard or Slack reaches BitExact only as the re-decide's policy_check carrying its approvalId. Auditant documents no record that names who approved, and BitExact never invents an approver.

Running BitExact and Auditant side by side#

Each product keeps its own chain. Auditant writes a policy_decision event for every decision before the action runs. BitExact's run holds the policy_check entry for the same decision in its signed chain, beside the model calls and tool calls that followed it.

  1. Run Auditant's control plane and BitExact's recorder as you would run each on its own. Neither calls the other.
  2. In the agent, wrap the Auditant handle once with auditant.wrap, and use the wrapped handle wherever the agent decides.
  3. To audit, take each auditant: check from the run (the evidence pack's procedure lists them) and find the matching event in Auditant's chain by the eventId, log or approvalId in its detail. Confirm the policy and effect match.

The entry is the agent process's report of Auditant's verdict. A compromised process could report a verdict Auditant never gave, so the cross-check in step 3 is what confirms it. A check carrying no Auditant id cannot be matched and stays unconfirmed.

Policy checks#

A safety or policy check - a PII filter, a moderation call, a guardrail, a rule engine - is recorded as a policy_check entry each time it runs: which check (check), its version, and its outcome (pass, fail, warn, held, error or unknown). held is a check that neither passed nor failed the action but held it for a human to approve. It is the record a sandbox or an auditor asks for when they ask whether a specified check ran on this decision, and what it said.

@bitexact.record_check("pii-filter", version="2.1")
def pii_filter(text: str) -> bool:      # True is pass, False is fail
    ...

# a check run somewhere else - a vendor API, another service:
run.record_policy_check("vendor-moderation", "warn", version="v3",
                        subject_step=4, detail={"category": "self-harm"})
  • Observed or asserted. A decorated check ran in this process at a boundary BitExact wraps: observed. A check reported through the hook was not seen by BitExact: asserted. The verifier enforces the pair.
  • What a check returns. True/False, "pass"/"fail"/"warn"/"held", or a dict carrying one of those as outcome (the rest of it recorded as detail). Anything else records unknown and names the type it got - never a guessed pass.
  • Never in the way. The check's return value and its exceptions reach the caller unchanged. A check that raises is recorded as error, then raises. A malformed hook call is a declared gap, never an exception.
  • Replay re-runs a check with the rest of the agent's code; a recorded check is evidence and is never served back from the record.
  • Any engine's decision, any person's approval. BitExact enforces nothing; it records decisions others made. A policy engine's decision (OPA, Cedar, Auditant, a vendor's moderation API) and a person's approval of what it held land through one API, in one shape, whoever made them: bitexact.record_policy_decision("opa:payments/allow", "held", reported_by="opa", version="v12", detail={...}, input=request, hold_key=decision_id), then bitexact.record_human_decision( "ops@example.com", "approve", reported_by="opa", reason="within limit", hold_key=decision_id). run.record_policy_check and run.record_human_decision take the same arguments. The engine is reported_by; it is not a capture path. Every decision, and every gap these calls declare, is recorded on the governance capture path (its via; relay:governance when a host collector relays it), in all four SDKs, whichever engine decided: it keeps its own liveness row and its own loss markers, and coverage lists it as governance. To be told when decision recording stops, expect it on that path, apart from the agent's other traffic: bitexact coverage expect add payments-decisions --workflow payments --paths governance --max-silence PT1H (--paths relay:governance for a host collector), which reads silent, quiet or dark when decisions stop arriving even while the agent's model and tool calls keep flowing. Its verdict is mapped onto the outcomes above by the caller: an allow is pass, a deny fail, a decision held for a person held; a fail-open verdict, where no check ran, is unknown, never pass. input is never recorded: its SHA-256 over RFC 8785 canonical JSON is detail.input_sha256 (detail.input_withheld when it is not JSON), so an auditor holding the request can prove which one was judged. A decision binds the held check by binds_step, or by the hold_key that check was recorded under in the same run; the key is kept by the run, never recorded. When the step is not known (no check held under the key, or a run handed to a host collector, which returns no step) binds_step is left out, never guessed. A call that does not fit is a declared invalid_argument gap and no entry. The Auditant adapter is this API with Auditant's mapping. TypeScript: recordPolicyCheck(check, outcome, { reportedBy, input, ... }) and recordHumanDecision(actor, decision, { reportedBy, ... }). Go: PolicyCheck{ReportedBy, Input} (a JSON-null input is bitexact.JSONNull) and HumanDecision{ReportedBy}. Java: Bitexact.recordPolicyCheck(check, outcome, version, subjectStep, detail, reportedBy, input) (a JSON-null input is Bitexact.JSON_NULL) and Bitexact.recordHumanDecision(actor, decision, reason, bindsStep, reportedBy). They send to a host collector, which returns no step, so they bind by bindsStep alone.
  • Other languages. TypeScript: recordCheck(name, fn, {version}) and recordPolicyCheck(name, outcome, {...}). Go: bitexact.RecordCheck and bitexact.RecordPolicyCheck. Java: Bitexact.recordCheck and Bitexact.recordPolicyCheck. In Go and Java a wrapped check returns a boolean or an outcome string (pass, fail, warn or held); any other value is recorded as unknown.
  • Distributed recording. The recorder's issuance policy must grant policy_check to the hosts that run checks (Distributed recording).

Decision records and the records regime#

run.record_decision(...) commits the deployer's governance record for the run's output: a retention_deadline (ISO-8601) with an optional retention_basis naming the schedule rule that set it, output_flags describing the decision, and reason_codes. The record is asserted - the deployer's statement about the output, not something BitExact observed - and each output flag is committed on its own path, so each is redactable on its own.

run.record_decision(output_flags={"is_communication": True},
                    retention_deadline="2029-11-01T00:00:00Z",
                    retention_basis="SEA 17a-4(b)(4), three years")
  • is_communication. Set it when the output is a communication the firm sends. It turns on the workpaper's communication criterion (SEA 17a-4(b)(4); FINRA 2210(b)(1)(A), (b)(4)(A); 3110(b)(4)). The classification is asserted; approved text is matched to sent text only where the send is a recorded tool call; correspondence versus retail classification is the firm's. It is an output flag like is_adverse_action, so no SDK needs a new call for it. is_communication, is_adverse_action, is_automated_decision and contains_pii take only True or False: any other value (the string "false", 1) is refused, and a recording run declares an invalid_argument gap and writes no decision record. The workpaper turns a criterion on only for the boolean True.
  • A human decision on the output. Python: run.record_human_decision(actor, decision, reason=..., binds_step=N), where binds_step names the step whose output was reviewed (the drafting step of a communication). TypeScript: recordHumanDecision(actor, decision, { reason, bindsStep }), Go bitexact.RecordHumanDecision(ctx, actor, decision, bitexact.HumanDecision{...}), Java Bitexact.recordHumanDecision(actor, decision, reason, bindsStep), each held to the same rule: a non-empty actor and decision, a string reason, a non-negative integer step. In a run a call that does not fit is a declared invalid_argument gap and no entry; outside a run nothing is sent, and a call that does not fit is still refused, logged as capture gap: invalid_argument naming record_human_decision. Neither is ever thrown into the agent; strict mode raises it: Python the argument's own ValueError or TypeError, TypeScript, Go and Java a CaptureGap. Every SDK records the decision as asserted: the deployer's report that a review took place and what it decided.
  • The declared default floor. bitexact workflow set-retention W --schedule default=6y --by ops:alice declares a default duration (<n>y, <n>m or <n>d) on the workflow's governance ledger. A run of that workflow (the one its identity names) that committed no retention_deadline is held by purge --delete until its last entry plus the duration, and purge says why: "no committed deadline; workflow W declares default=6y". A declared default that cannot be parsed refuses deletion too. Archiving is allowed under the floor. BitExact defaults no period: six years applies only where no other period is specified, and communications are three years under 17a-4(b), so the deployer commits a deadline per run or declares one per workflow.
  • The records regime. bitexact workflow set-records-regime W --regime sec-17a-4-audit-trail --by ops:alice appends a policy_set act declaring the SEC Rule 17a-4(f)(2)(i)(A) audit-trail alternative. Under it purge --delete is refused outright for the workflow's runs ("archive only under a declared records regime"); bitexact archive --to DIR and purge --archive DIR still move them to the archive. A governance ledger that cannot be read, or a run whose workflow identity field was redacted, leaves the regime unknown and refuses deletion too. bitexact workflow show W prints the declared regime and the default floor.
  • Withdrawal. bitexact workflow deprecate W --reason superseded --by ops:alice appends a deprecated act, and a deprecated workflow takes no new version, promotion or second deprecation. Its owner, records regime and retention schedule still govern the runs it kept, so set-retention and set-records-regime still apply to it.
  • Redaction under the regime. A redaction removes the stored value for good, and 17a-4(f)(2)(i)(A)(4) requires the original to be re-creatable, so under the regime BitExact refuses to redact a step until the archive catalog holds the run's verified bundle through that step, with the chain still matching the bundle's head. The redaction itself, a dual-control request before it is stored, and the viewer's per-step offer all refuse in the same words; for step 1 of a run of W: "workflow W declares records regime sec-17a-4-audit-trail: a redacted original must stay re-creatable, so step 1 cannot be redacted until the run's verified bundle is in the archive - archive it and keep its rows: bitexact archive --to DIR --before YYYY-MM --no-drop". Once archived, the bundle holds the original value and the redaction proceeds. A governance ledger that cannot be read, or a redacted workflow field, refuses redaction the same way.

Attestation receipts#

bitexact receipts RUN --key receipts.key --org acme exports a verified run's model calls as signed receipts in the format of the IETF draft Enforcement Attestation Receipts for AI Inference Decisions (draft-chueayen-attestation-receipts-03), one JSON object per line, for a party that asks for per-call receipts rather than a bundle. Make the key with bitexact keygen -o receipts.key; keep it apart from the recorder key, and publish the base64url public key the command prints.

FieldFrom
request_hashSHA-256 of the request body's RFC 8785 canonical form, as recorded
modelthe model the request named
outcomeALLOWED: the request went to the provider. BitExact records calls; it never blocks them
policy_appliedeach policy check that passed after the previous model call and before this one, or that named this call's step as its subject. name@version when the check has a version
trace_idRUN:STEP, the entry the receipt is about
timestampwhen the call was recorded
attestation_idderived from the entry's hash, so a re-export is identical
prev_attestation_id, prev_attestation_hashthe previous receipt in the run, and SHA-256 of its canonical bytes, signature included
cost_prevented_eur0
  • What a receipt does not say. A check that failed, warned, was held or errored is in the run but not on the receipt: the format cannot say a check failed, so a receipt never lists one. The receipt is signed when it is exported; the run's checkpoints and transparency log prove when the call was recorded, and trace_id names the entry to check them against.
  • No receipt rather than a wrong one. A call whose request is not JSON, was redacted, was scrubbed, kept hash-only or withheld by the capture policy (a hash of it would be a hash of bytes nobody sent), or holds a number the draft forbids signing gets no receipt. The command names each such step and exits 3; --allow-gaps exits 0.
  • Checking them. bitexact verify-receipts FILE --expect-key KEY applies the draft's section 6 to every receipt and checks the chain. The draft's reference verifier, aqta-verify-receipt, reads the same receipts. Our verifier is held to the draft's published test vectors.
  • OVERT. OVERT's receipt format (its Protocol Profile) is not public, so BitExact does not claim to emit it.

Tool implementation drift#

Replay and regress serve every recorded tool call from the record: the tool's body does not run, so they prove what the agent asked of its tools and what the model saw, not what the tool would return today. A change inside a tool's implementation is therefore invisible to both, by design. To see it, record again: a fork from the tool's step executes the tool live and records its new result in a branch beside the source run, where the inspector's compare view shows the difference and bitexact verify --provenance ties the branch to its source.

Run topology: is a job one run, or a tree of runs?#

A multi-agent job records its evidence one of two ways. Choose one per job and name runs accordingly. The failure mode to design out is three teams inventing three run-id schemes with no verifiable link between them.

The orchestrator opens a single bitexact.run() for the whole job; every agent's model calls through a wrapped client or the proxy, and its recorded tool calls, land in that one manifest. The result is one hash-chained chain of custody for the entire task — the single artifact a regulator asking "what did this system do to produce this decision?" wants, not a scavenger hunt across many runs.

  • Sub-agents that run in the same context as the orchestrator share the active run automatically — just call your tools. A sub-agent wrapped as a @record_tool tool may itself call the model through the wrapped client: those calls are recorded inside the tool's frame and served with it on replay, so the tool is never re-executed to reproduce them.
  • Sub-agents that run on their own threads, tasks, or workers must carry the run into them with bitexact.propagate (see the next section).
  • BitExact refuses a run nested in the same context: a sub-agent cannot open its own bitexact.run() inside the orchestrator's. Join the parent run; do not nest. (To branch instead of join, fork — Pattern B.)
  • Per-agent attribution inside the one run uses agent_id / bound identity, not a second run.
  • Run id: job-{id}.

Pattern B — one run per agent, fork-linked#

Each agent opens its own run, and the runs are linked by a committed fork entry, never a loose id in metadata. bitexact.fork(parent_run, at_step, run_id=...) records source_run, at_step, and the parent's prefix hash into the child's manifest; the provenance graph / BagIt bundle then reassembles the whole tree and verifies every fork edge against the parent's own committed entries. A job becomes a verifiable tree of runs.

Use it when agents are long-lived, independently replayable, or you want to fork-and-compare one agent's branch without re-running the others.

  • Run id: job-{id} for the root. Run ids are flat identifiers ([A-Za-z0-9][A-Za-z0-9._-]{0,63} — no /), so name children with a valid separator, e.g. job-{id}-{agent}, and render the tree as job-{id}/{agent} in a UI if you like. Either way the id is a label only — the identity of the link is the committed fork edge, never the string.

The linkage contract#

For Pattern B the link must be the committed fork entry. A parent_job_id dropped in run metadata is not a link: it is covered by no chain and reassembles into nothing you can verify. The fork edge makes the relationship structurally checkable — alter the parent and the child's recorded prefix hash no longer matches, and the bundle fails. Do not substitute an external id scheme for it.

Structural is not authenticated. The fork edge is the source's public hash plus a keyless seal, so anyone who can write to the store can forge a descent from a run they did not fork. bitexact verify --provenance says so in as many words and refuses the word "verified" for that tier. To prove a fork was recorded by a trusted recorder rather than fabricated, pin the key:

bitexact verify <run> --provenance --expect-recorder-key <recorder pubkey>

The key to pin is the recorder's public key, 64 hex characters. Run bitexact recorder-key on the recorder host to print it, whichever custody holds the private half (a key file, a PKCS#11 token or a KMS envelope), and take it from the recorder's operator over a channel you trust - never from the bundle you are checking.

SituationTopology
Normal multi-agent task; auditing one decisionA — one run per job
Long-lived agents, per-agent replay, selective fork-and-compareB — fork tree

Propagating the active run (a required integration step)#

The active run lives in a context variable. Work that stays on the thread that opened the run inherits it for free. Work that leaves that thread — a ThreadPoolExecutor, a queue or worker pool, a callable handed to another scheduler — does not. Carrying the run across that boundary is a required step of any integration that fans work out, not an advanced tip:

pool.submit(bitexact.propagate(tool), args)   # carries the run across the boundary

bitexact.propagate(fn) binds the run active where you call it and re-enters that run wherever fn later executes. Skip it and the tool still runs — BitExact never stops your agent — but it is not recorded, and since the call carries no run, which run it belonged to cannot be known. It is never guessed onto a run. Instead every run recording at that moment declares a run_context_lost gap naming the tool (where: record_tool:<name>), so bitexact verify exits 3 on each of them and none seals as if its capture were whole. With BITEXACT_FAILURE_MODE=strict (development, conformance tests) the call raises instead, and beside a run that replays it always raises: a replayed agent must never execute a tool live.

Every capture point does the same where no run reached it: a @bitexact.record_check check (record_check:<name>), a LangChain handler event (langchain:<kind>), an OpenTelemetry gen_ai span (otel:<kind>:<span name>), and a propagate() wrapper made before its run opened (propagate:<name>, one gap for everything the wrapper runs). The TypeScript, Go and Java SDKs declare it too, on every run open in the process, when a tool, check, entry or model call is reached without the run: in Go, through a context.Context the run was not passed into; in Java, on a thread the run was not carried to with Bitexact.carry.

Checklist for any integration that hands work to another thread, task, or worker:

  • Wrap every callable you submit to a pool/executor with bitexact.propagate(...).
  • Build the wrapper inside the run. One created before the run opened carries no run: its tool calls are the gap above. One reused after its run ended runs, and what it would have recorded is noted beside that run as a run_ended gap.
  • Run development and CI with BITEXACT_FAILURE_MODE=strict, so a missing propagate() fails the test instead of becoming a gap in production.
  • asyncio tasks, asyncio.to_thread and loop.run_in_executor carry the run without propagate().
  • Cross-process workers cannot share a context variable at all: give them the run explicitly — the X-BitExact-Run-Id header on proxied calls, or their own fork-linked run under Pattern B.