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:
| Purpose | Endpoint |
|---|---|
| Search runs | GET /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 selection | GET /api/evidence-pack (runs, workflow, workflow_version, environment, subject, principal, on_behalf_of, interaction_mode, since, until) |
| Run detail / trajectory | GET /api/runs/{run_id}, GET /api/runs/{run_id}/steps/{n} |
| Export a bundle | GET /api/runs/{run_id}/export?format=json|jsonl |
| Verify a bundle / report | POST /api/verify-bundle, POST /api/verify-report |
| Fork lab | GET /api/runs/{run_id}/forks, .../fork-matrix, .../reports |
| Health / coverage / retention | GET /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 orbitexact seal),run.hold_placed/run.hold_released,run.redacted,run.repaired,run.forked,capture.gap, the dual-controlredaction.*acts,evidence.exported,refusal.recorded,access.role_changed,governance.event,run.purged,alert.opened/alert.resolvedfor every health alert, andevents.test. Each is a CloudEvents 1.0 event of typeai.bitexact.<name>, carrying metadata only: ids, counts, hashes, causes and roles, never recorded content or a free-text reason. - Formats.
standard-webhookssigns each POST withv1HMAC-SHA256 under every secret file (two during a rotation) and, optionally, an ed25519v1asignature a receiver checks with a public key.splunk-hecsends batches with the event id as the indexed fieldbitexact_id, and can wait for indexer acknowledgment.datadog-logssends 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 anopenai.OpenAIclient 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 recordedprov="asserted", neverobserved: 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 RUNor 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 foranthropic.Anthropic. - TypeScript clients —
wrapOpenAI(client)andwrapAnthropic(client)insdk-tsreturn a copy of anopenaior@anthropic-ai/sdkclient whose every call is recorded aswrapFetchrecords it (an observedhttp_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_stateisoff, andrefusalsays why), declares arun_context_lostgap when it is entered, and is noted beside the runs forbitexact coverage report; your code runs as it would without BitExact. UnderBITEXACT_FAILURE_MODE=strict, and in replay, regress or fork mode, it raisesRuntimeErrorinstead. - 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_toolbefore handing it toFunctionTool,@toolorFunctionTool; 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 theX-BitExact-Run-Idheader. 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 attach | Model calls recorded as | Tool calls recorded as | Replays |
|---|---|---|---|
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 result | yes |
bitexact.langchain_handler() alone | model, asserted: what the framework reported | tool_call, asserted | no |
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 run | One model call | One tool call |
|---|---|---|
langchain_handler() | model, asserted | tool_call, asserted |
langchain_handler() + model client at the run | http_call, observed | tool_call, asserted |
langchain_handler() + record_tools | model, asserted | tool_call, observed |
langchain_handler() + model client at the run + record_tools | http_call, observed | tool_call, observed |
otel_span_processor() | model, asserted | tool_call, asserted |
otel_span_processor() + model client at the run | http_call, observed | tool_call, asserted |
otel_span_processor() + record_tools | model, asserted | tool_call, observed |
otel_span_processor() + model client at the run + record_tools | http_call, observed | tool_call, observed |
langchain_handler() + otel_span_processor() | model, asserted, twice | tool_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 throughrecord_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_toolswraps 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 aninvalid_argumentgap 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. UnderBITEXACT_FAILURE_MODE=strictit 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. UnderBITEXACT_FAILURE_MODE=strictit raises instead, into the agent's call: LangChain discards what a handler raises unless the handler setsraise_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_lostgap 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
modelentry and a tool span atool_callentry, 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 whenOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTisSPAN_ONLYorSPAN_AND_EVENT; Google's ADK readstrueasEVENT_ONLY, which leaves it off the span. - That content can be kept hash-only: set
BITEXACT_OTEL_CONTENT=hash, or passcontent="hash"tobitexact.otel_span_processor()orbitexact.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'sargsandresultand a model call'srequestandresponseare then recorded with the custody hinthash-only, so the evidence stores each field's commitment and no value, declared in the entry'scaptureblock and opened only with the capture key (capture controls).content="keep"leaves content to the capture policy whatever the environment says. Any value ofBITEXACT_OTEL_CONTENTother thankeeporhashkeeps content hash-only, so a mistyped setting never stores more than was asked. - An MCP span whose
mcp.method.nameistools/callis atool_callcarrying 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 spanexecute_tool {tool}and states it the way the mapping reads:gen_ai.operation.nameexecute_toolwithgen_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_lostgap 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 thegen_aispans 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_aiattribute, so it makes no entry. Record the decision withbitexact.record_policy_decision, and a person's approval of what it held withbitexact.record_human_decision(see Policy checks). Both areasserted: 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 indetailso 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_idandotel_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) withbitexact.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=hashto 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 span | BitExact records |
|---|---|
gen_ai.operation.name is chat | model |
gen_ai.operation.name is text_completion | model |
gen_ai.operation.name is generate_content | model |
gen_ai.operation.name is execute_tool | tool_call |
gen_ai.operation.name is invoke_agent | no entry |
gen_ai.operation.name is create_agent | no entry |
gen_ai.operation.name is invoke_workflow | no entry |
mcp.method.name is tools/call | tool_call |
carries gen_ai.tool.name | tool_call |
carries gen_ai.request.model | model |
carries gen_ai.response.model | model |
| Entry | Field | From the first the span carries | When it carries none |
|---|---|---|---|
tool_call | tool | gen_ai.tool.name | unknown |
tool_call | status | error when error.type is set | ok |
tool_call | error.type | error.type | omitted |
tool_call | mcp.session_id | mcp.session.id | omitted |
tool_call | mcp.protocol_version | mcp.protocol.version | omitted |
tool_call | mcp.jsonrpc_id | jsonrpc.request.id | omitted |
tool_call | args | gen_ai.tool.call.arguments, decoded when its text is a JSON object or array at most 30 levels deep, else kept as text | omitted |
tool_call | result | gen_ai.tool.call.result, decoded when its text is a JSON object or array at most 30 levels deep, else kept as text | omitted |
tool_call | otel_tool_call_id | gen_ai.tool.call.id | omitted |
tool_call | otel_conversation_id | gen_ai.conversation.id | omitted |
tool_call | otel_agent_name | gen_ai.agent.name | omitted |
model | model | gen_ai.request.model, gen_ai.response.model | unknown |
model | provider | gen_ai.provider.name, gen_ai.system | omitted |
model | usage.input_tokens | gen_ai.usage.input_tokens, a whole number within the safe range | omitted |
model | usage.output_tokens | gen_ai.usage.output_tokens, a whole number within the safe range | omitted |
model | error.type | error.type | omitted |
model | request.messages | gen_ai.input.messages, decoded when its text is a JSON object or array at most 30 levels deep, else kept as text | omitted |
model | response.messages | gen_ai.output.messages, decoded when its text is a JSON object or array at most 30 levels deep, else kept as text | omitted |
model | otel_tool_call_id | gen_ai.tool.call.id | omitted |
model | otel_conversation_id | gen_ai.conversation.id | omitted |
model | otel_agent_name | gen_ai.agent.name | omitted |
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 eachtools/callfrom the JSON-RPC bytes that cross it: outside observation, with the server, session and JSON-RPC id in the entry'smcpblock. A client names the run withparams._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_metanames when no run is given otherwise: on stdio without--run-id, on Streamable HTTP withoutX-BitExact-Run-Id. A call that names no run is forwarded and declaredrun_context_lost.- Sharing a run with the proxy. A run opened with
bitexact.runholds its chain's one write lock, so a proxy in another process may not write into it. Start the proxy withenv=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 itobservedthrough themcp-proxypath 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 declaredrecorder_unavailablegap. bitexact.mcp.wrap_session(session)is in-process capture, likerecord_tool: eachcall_toolof an MCPClientorClientSessionis atool_callrecorded inside the agent's own process, from what the client was handed rather than what crossed the wire, and with nomcpblock.
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, itsargumentsasargs, 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 bothstatus: "error"; the JSON-RPC error is kept aserror, with itscode,messageanddata.result_classnames the result type the session handed back. Timeouts, progress callbacks andmetareach 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
CallToolResultor anInputRequiredResult, or raises the sameMCPError, 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 capture | Framework report | One MCP tool call leaves |
|---|---|---|
bitexact mcp-proxy | none | tool_call, observed, with an mcp block |
bitexact mcp-proxy | langchain_handler() | tool_call, observed; the report, matched on the tool name, is dropped |
bitexact mcp-proxy | otel_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-proxy | openai_agents.processor() | tool_call, observed; the report, matched on the tool name, is dropped |
bitexact mcp-proxy | claude_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-proxy | adk.plugin() | tool_call, observed; the report, matched on the tool name, is dropped |
wrap_session | none | tool_call, observed in process, with no mcp block |
wrap_session | langchain_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_session | claude_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() | none | tool_call, observed, with an mcp block: the proxy's; the wrapper records none |
bitexact mcp-proxy, relayed through the recorder | an SDK's report from another process, relayed through the recorder | both 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 |
| none | any of them | the 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:
...
PostToolUserecords atool_call:tool, the tool's input asargs, its response asresult,tool_use_id,agent_idand the SDK'ssession_id.PostToolUseFailurerecords atool_callwithstatus: "error"and the SDK's error text aserror.message.SubagentStartandSubagentStoprecord acontextentry naming the subagent'sagent_idandagent_type.- An observed MCP call is not reported twice. The SDK names an MCP
server's tool
mcp__<server>__<tool>. Whenbitexact mcp-proxyrecorded 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 apermissionDecision.PreToolUseis not registered by default, because aPreToolUsecallback 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 acontextentry on a worker thread after it returns, so a store that stalls never delays the tool call. - Permission verdicts.
wrap_can_use_toolrecords each verdict as apolicy_checkwithcheck: "claude-agent-sdk:can_use_tool", andwrap_hookascheck: "claude-agent-sdk:PreToolUse":allowispassanddenyisfail. A hook'saskordeferholds the call for a human and isheld. Acan_use_toolresult can only allow or deny (the SDK raises on any other), so thereaskordeferis not a verdict. Anything else isunknown, withdetail.returnednaming it. Neither wrapper records ahuman_decision: the SDK never says who decided. A callback that knows its approver records it withrun.record_human_decision(...). A callback that raises is recorded aserror, 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 declaresrun_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 answerscontinue: falsewith astopReasonnaming the hook and the failure. In strict modePreToolUserecords 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 ofHookMatchers.
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) },
})) { /* ... */ }
});
PostToolUserecords atool_call:tool, the tool's input asargs, its response asresult,tool_use_idandagent_id.PostToolUseFailurerecords atool_callwithstatus: "error"and the SDK's error text aserror.message.SubagentStartandSubagentStoprecord acontextentry naming the subagent'sagent_idandagent_type.- An observed MCP call is not reported twice. A call to
mcp__<server>__<tool>that arecordTooltool 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 callbitexact mcp-proxyobserved keeps the hook's asserted report beside it. - Never in the way. Every hook resolves to
{}and never to a permission decision.PreToolUseis not registered by default, because aPreToolUsecallback 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 acontextentry. - Permission verdicts.
wrapCanUseToolrecords each verdict as apolicy_checkwithcheck: "claude-agent-sdk:can_use_tool"and the tool indetail:allowispassanddenyisfail. APermissionResultcan only allow or deny, so anything else,askordeferincluded, isunknown, withdetail.returnednaming it. A callback that throws is recorded aserror, 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.
| Span | Entry |
|---|---|
function_span | tool_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_span | model: model, request (messages, and the model config as params), response, usage |
guardrail_span | policy_check: check is openai-agents:<guardrail name>, outcome is fail when the guardrail was triggered and pass otherwise |
handoff_span | context named handoff, naming from_agent and to_agent |
| agent, task, turn, custom | no 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, withreported_by: "openai-agents", and carriesopenai_agents_trace_id,openai_agents_span_idandopenai_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'sargsandresultand a model call'srequestandresponseare 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'sinclude_sensitive_data=. Left unset, it is the SDK's default, read fromOPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATAwhen each span ends: on unless that variable is set to something other than1,true,yesoron. - Never in the way. Every span callback runs under the adapter guard. A
BitExact failure is a gap declared at
openai-agentsand never reaches the agent. A span that ends where no run is carried, while runs record in the process, is declaredrun_context_lost(openai-agents:<kind>). A registration that fails is a declared gap, andprocessor()returnsNone. 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: apolicy_checkwithcheck: "openai-agents:needs_approval"and outcomeheld, whosedetailnames the item'stool_nameandcall_idwhen they are strings; then ahuman_decision(actor,approveorreject, andreason) whosebinds_stepis that check's step. A run handed to a host collector gets no step back, so therebinds_stepis 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
actoror areasonthat is not a string is a gap declared atopenai-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): afunctionspan is atool_call,generationandresponsespans aremodelentries, aguardrailspan is apolicy_check(openai-agents:<name>,failwhen triggered,passotherwise), ahandoffspan is acontextentry namedhandoff, and the rest are structure. Every entry isasserted, withreported_by: "openai-agents". - No duplicates. A function span over a call that a
recordTooltool of that name ran after the span started, and a model span over an HTTP callwrapFetchrecorded after it started, add no second entry. - Content. The run's
traceIncludeSensitiveDatadecides 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 })callsstate.approve(item, { alwaysApprove }), andopenaiAgentsReject(state, item, { actor, reason, alwaysReject, message })callsstate.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 heldpolicy_checkandhuman_decisionas the Python package.call_idis read from the item's raw item (callId, elseid). The decision is linked to the check by its event id (caused_by), never a guessedbinds_step, because a host collector hands back no step. - Never in the way. A span the processor cannot read is a declared
unclassifiedgap atopenai-agents, and a span that ends where no run was carried, while runs are open, is a declaredrun_context_lost(openai-agents:<kind>) on every open run. An emptyactoror areasonthat is not a string is a declaredinvalid_argumentgap and nothing is recorded. A registration that fails is a declared gap andopenaiAgentsProcessorreturnsnull. Strict mode raises instead. - Delivery. Entries go onto the collector shipper's queue as each span
ends; the processor's
shutdownandforceFlushdeliver 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_callbackrecords atool_call:tool, the arguments asargs, the tool'sresult, and ADK'sfunction_call_idandinvocation_id.on_tool_error_callbackrecords atool_callwithstatus: "error"and the error'stypeandmessage. 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_callbackin order, then the agent's own, and the first that returns a value answers the call: the tool does not run, and ADK still runsafter_tool_callbackwith that value as the result. Such a call is recorded as apolicy_checkwithcheck: "adk:before_tool"and outcomefail, withtool,function_call_idandinvocation_idindetail. The value that stood in for the tool's result isdetail.substituted_response, recorded hash-only: committed and never stored. Notool_callis 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_callbackis seen when you wrap it withwrap_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 thetool_callADK reported. - Reported, not observed. A callback is ADK's report of a call, not the
wire. Every entry is
asserted, withreported_by: "adk", and namesadkas 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 aRuntimeError. 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 declaredrun_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 itson_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 nogen_aiattribute on the span it exports for one, sootel_span_processor()records nothing from it. LlamaIndex does open a span for every call of a tool's__call__,callandacall, whether an agent or your own code calls it. The handler reads that span. - What it records. One
tool_callper call:tool,status(ok, orerrorwhen theToolOutputsaysis_error, with the exception'stypeandmessage), the output'sraw_inputasargsand itsraw_outputasresult, and LlamaIndex's span id asllamaindex_span_id. A call that raises is recorded withstatus: "error", the exception'stypeandmessage, and the call's arguments asargs; the exception reaches your code unchanged. - One call, one entry. A tool's methods call one another (
__call__callscall, and an async adapter'sacallcalls 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, withreported_by: "llamaindex", and namesllamaindexas 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 declaredrun_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 callinginstrument()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 field | From Auditant's decision |
|---|---|
check | auditant: and policyId, or auditant when it names none |
version | policyVersion, when it is a string |
outcome | allow 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. |
detail | effect, 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
errorwith the exception's type indetail.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=)sendsPOST /v1/approvalsthrough yourhttpx2.Clientpointed at Auditant's control plane. When Auditant accepts it, BitExact records aheldpolicy_check(auditant:approval) and ahuman_decisionnaming the approver, bound to that check. An approval made in Auditant's dashboard or Slack reaches BitExact only as the re-decide'spolicy_checkcarrying itsapprovalId. 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.
- Run Auditant's control plane and BitExact's recorder as you would run each on its own. Neither calls the other.
- In the agent, wrap the Auditant handle once with
auditant.wrap, and use the wrapped handle wherever the agent decides. - 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 theeventId,logorapprovalIdin itsdetail. 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 asoutcome(the rest of it recorded asdetail). Anything else recordsunknownand 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), thenbitexact.record_human_decision( "ops@example.com", "approve", reported_by="opa", reason="within limit", hold_key=decision_id).run.record_policy_checkandrun.record_human_decisiontake the same arguments. The engine isreported_by; it is not a capture path. Every decision, and every gap these calls declare, is recorded on thegovernancecapture path (itsvia;relay:governancewhen 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 asgovernance. 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:governancefor a host collector), which readssilent,quietordarkwhen 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 ispass, a denyfail, a decision held for a personheld; a fail-open verdict, where no check ran, isunknown, neverpass.inputis never recorded: its SHA-256 over RFC 8785 canonical JSON isdetail.input_sha256(detail.input_withheldwhen it is not JSON), so an auditor holding the request can prove which one was judged. A decision binds the held check bybinds_step, or by thehold_keythat 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_stepis left out, never guessed. A call that does not fit is a declaredinvalid_argumentgap and no entry. The Auditant adapter is this API with Auditant's mapping. TypeScript:recordPolicyCheck(check, outcome, { reportedBy, input, ... })andrecordHumanDecision(actor, decision, { reportedBy, ... }). Go:PolicyCheck{ReportedBy, Input}(a JSON-null input isbitexact.JSONNull) andHumanDecision{ReportedBy}. Java:Bitexact.recordPolicyCheck(check, outcome, version, subjectStep, detail, reportedBy, input)(a JSON-null input isBitexact.JSON_NULL) andBitexact.recordHumanDecision(actor, decision, reason, bindsStep, reportedBy). They send to a host collector, which returns no step, so they bind bybindsStepalone. - Other languages. TypeScript:
recordCheck(name, fn, {version})andrecordPolicyCheck(name, outcome, {...}). Go:bitexact.RecordCheckandbitexact.RecordPolicyCheck. Java:Bitexact.recordCheckandBitexact.recordPolicyCheck. In Go and Java a wrapped check returns a boolean or an outcome string (pass,fail,warnorheld); any other value is recorded asunknown. - Distributed recording. The recorder's issuance policy must grant
policy_checkto 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'scommunicationcriterion (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 likeis_adverse_action, so no SDK needs a new call for it.is_communication,is_adverse_action,is_automated_decisionandcontains_piitake onlyTrueorFalse: any other value (the string"false",1) is refused, and a recording run declares aninvalid_argumentgap and writes no decision record. The workpaper turns a criterion on only for the booleanTrue.- A human decision on the output. Python:
run.record_human_decision(actor, decision, reason=..., binds_step=N), wherebinds_stepnames the step whose output was reviewed (the drafting step of a communication). TypeScript:recordHumanDecision(actor, decision, { reason, bindsStep }), Gobitexact.RecordHumanDecision(ctx, actor, decision, bitexact.HumanDecision{...}), JavaBitexact.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 declaredinvalid_argumentgap and no entry; outside a run nothing is sent, and a call that does not fit is still refused, logged ascapture gap: invalid_argumentnamingrecord_human_decision. Neither is ever thrown into the agent; strict mode raises it: Python the argument's ownValueErrororTypeError, TypeScript, Go and Java aCaptureGap. 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:alicedeclares adefaultduration (<n>y,<n>mor<n>d) on the workflow's governance ledger. A run of that workflow (the one its identity names) that committed noretention_deadlineis held bypurge --deleteuntil its last entry plus the duration, andpurgesays 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:aliceappends apolicy_setact declaring the SEC Rule 17a-4(f)(2)(i)(A) audit-trail alternative. Under itpurge --deleteis refused outright for the workflow's runs ("archive only under a declared records regime");bitexact archive --to DIRandpurge --archive DIRstill 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 Wprints the declared regime and the default floor. - Withdrawal.
bitexact workflow deprecate W --reason superseded --by ops:aliceappends adeprecatedact, 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, soset-retentionandset-records-regimestill 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.
| Field | From |
|---|---|
request_hash | SHA-256 of the request body's RFC 8785 canonical form, as recorded |
model | the model the request named |
outcome | ALLOWED: the request went to the provider. BitExact records calls; it never blocks them |
policy_applied | each 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_id | RUN:STEP, the entry the receipt is about |
timestamp | when the call was recorded |
attestation_id | derived from the entry's hash, so a re-export is identical |
prev_attestation_id, prev_attestation_hash | the previous receipt in the run, and SHA-256 of its canonical bytes, signature included |
cost_prevented_eur | 0 |
- 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_idnames 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-gapsexits 0. - Checking them.
bitexact verify-receipts FILE --expect-key KEYapplies 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.
Pattern A — one run per job (recommended default)#
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_tooltool 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 asjob-{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.
| Situation | Topology |
|---|---|
| Normal multi-agent task; auditing one decision | A — one run per job |
| Long-lived agents, per-agent replay, selective fork-and-compare | B — 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_endedgap. - Run development and CI with
BITEXACT_FAILURE_MODE=strict, so a missingpropagate()fails the test instead of becoming a gap in production. asynciotasks,asyncio.to_threadandloop.run_in_executorcarry the run withoutpropagate().- Cross-process workers cannot share a context variable at all: give them
the run explicitly — the
X-BitExact-Run-Idheader on proxied calls, or their own fork-linked run under Pattern B.