Skip to content
Tenbri pangolinTenbri

OpenAI Agents SDK

Two lines record what every agent of the OpenAI Agents SDK does in a Tenbri stream: tenbri.init() opens the stream, tenbri.instrument_openai_agents() (instrumentOpenAIAgents() in TypeScript) hooks every run. Each tool call becomes an intent that is durable before the tool runs, then its outcome; refused calls, handoffs between agents and the answers a person gives through RunState.approve and reject are recorded too. No hooks= to pass.

Python 3.10 or later.

Terminal window
pip install --pre tenbri-openai-agents

This brings the tenbri binding (0.1.0-beta.2) and openai-agents (0.23 or 0.24, the two releases CI tests).

Say what a refund is in tenbri.toml, next to your code. The target names the tool’s arguments in braces and is filled from each call; the policy is the rule recorded as having allowed it:

tenbri.toml
[actions.issue_refund]
target = "stripe:{charge}" # {charge} is filled from the tool call's arguments
policy = "policy:refund@14" # the rule that allowed it: ref@version

Then add the two lines to your agent. Nothing else in it changes:

agent.py
import asyncio
import tenbri
from agents import Agent, Runner, function_tool
tenbri.init() # reads TENBRI_STREAM... and ./tenbri.toml
tenbri.instrument_openai_agents() # every run from here on is recorded
@function_tool
def issue_refund(charge: str, amount: int) -> str:
"""Refunds `amount` EUR on the card charge `charge`."""
return f"refunded {amount} EUR on {charge}"
async def main():
agent = Agent(name="support", instructions="You handle refunds.", tools=[issue_refund])
result = await Runner.run(agent, "Refund 20 EUR on ch_1.")
print(result.final_output)
print("pin this:", tenbri.default().fingerprint)
asyncio.run(main())

Every Runner.run, run_sync and run_streamed from then on is recorded, and every RunState.approve and reject. Hooks you pass yourself still run.

Each tool call is by agent:<the agent's name>, here agent:support: no TENBRI_AGENT needed.

Point Tenbri at a stream, with OPENAI_API_KEY set:

Terminal window
export TENBRI_STREAM=./audit
python agent.py
Refunded 20 EUR on ch_1.
pin this: sha256:4d15c116762587de6cd7f33dc6661ac2469710405e998191961c862b8b57865e

init() creates the stream and its signing keys on first use. The keys go to $TENBRI_KEYS, else ~/.config/tenbri/keys, never into the stream (keys). Keep the fingerprint: it is what you verify against. This output comes from a run with a scripted model in place of the real one (see testing offline) that asks for issue_refund once, then answers.

Terminal window
tenbri tail ./audit -n 2 --no-follow
{"action":{"target":"stripe:ch_1","type":"issue_refund"},"durability":"local","id":"01M5D155QTPKBN2XRZANRA2515","kind":"decision","phase":"intent","prev":"sha256:ed6a298d…","principal":{"id":"support","kind":"agent"},"rule":{"ref":"policy:refund","result":"allow","version":"14"},"seq":3,"sig":"ed25519:dab653f1…","signer":"sess:2026-10-20:0483a8eeb1ee","source":"sdk","stream":"tnb1:4d15c116762587de6cd7f33dc6661ac2","trace":{"span_id":"ac620f5f8c5c4b1d","trace_id":"f70a0963ec5e406c9e103a09dbdf08d2"},"ts":"2026-10-20T16:08:50.174Z","v":1}
{"action":{"target":"stripe:ch_1","type":"issue_refund"},"id":"01M5D155RGERR0G566KRE1RMAT","intent":"01M5D155QTPKBN2XRZANRA2515","kind":"decision","phase":"outcome","prev":"sha256:3bb627c5…","principal":{"id":"support","kind":"agent"},"seq":4,"sig":"ed25519:6c9e65a1…","signer":"sess:2026-10-20:0483a8eeb1ee","source":"sdk","status":"ok","stream":"tnb1:4d15c116762587de6cd7f33dc6661ac2","ts":"2026-10-20T16:08:50.196Z","v":1}
Terminal window
tenbri verify ./audit --key sha256:4d15c116… # your full fingerprint
verified 4 entries, stream tnb1:4d15c116762587de6cd7f33dc6661ac2, pinned to sha256:4d15c116762587de6cd7f33dc6661ac2469710405e998191961c862b8b57865e

Entries 1 and 2 are the stream’s key entries. Entry 3 is the intent, written before issue_refund ran: the target comes from tenbri.toml, filled with the call’s charge, and the rule is its policy. Entry 4 is the outcome. The arguments and the result are not stored; the intent carries the SDK’s trace id.

tenbri.init() (init()) reads these; an argument (init(approver=...), init({ approver })) wins over its variable.

Variable Argument What it is
TENBRI_STREAM stream Where entries go: a directory, file://..., s3://bucket/prefix or r2://bucket/prefix. Required.
TENBRI_KEYS keys The keys directory, apart from the stream. Default ~/.config/tenbri/keys.
TENBRI_AGENT agent Not used here: each call is by agent:<the agent's name>.
TENBRI_APPROVER approver Who answers interruptions: person:alice.
TENBRI_SCHEMA schema The action schemas. Default ./tenbri.toml when there is one.
fail_open (failOpen) Let tools run, unrecorded and with a warning, when their intent cannot be written. Default: block them.

init() flushes and closes the stream when the process exits. In Python, with tenbri.init(...) as log: scopes it to a block; in TypeScript, await using log = await init(...).

A tool call is the action of the tool’s name, or of the [actions.*] table in tenbri.toml that its name ends with (an MCP tool mcp__billing__issue_refund is issue_refund). From that table, the record takes target, with each {argument} filled from the call’s arguments ({order.id} reads a nested field), and policy, "ref@version", as the intent’s rule. In tenbri.toml, a target without placeholders is a pattern for validation only and is not recorded.

tenbri.toml is the same file tenbri schema check and tenbrid validate entries against. A plain dict (object) in code says the same and wins over the file, per tool name. It can rename the action, and take functions of the arguments:

tenbri.instrument_openai_agents(
actions={
"issue_refund": {"target": "stripe:{charge}", "policy": "policy:refund@14"},
"close": {"action": "close_ticket", "target": lambda args: f"ticket:{args['id']}"},
}
)

A tool neither names is recorded under its own name, with no target.

Python: tenbri.instrument_openai_agents(**options). TypeScript: instrumentOpenAIAgents({ ... }).

Python TypeScript Default What it does
actions actions tenbri.toml Per tool name: action, target, subject (templates or functions of the arguments) and policy. Wins over the file.
approver approver TENBRI_APPROVER Who answers an interruption: a principal, or a function of the ToolApprovalItem returning one.
tools tools every tool (TypeScript: every function tool) Names to record, or a predicate of the name.
subject subject none The pseudonymous handle of the person the runs are about, for per-subject keys and erasure.
fail_open failOpen init()’s, False Let actions run, unrecorded and with a warning, when the stream cannot be written.

In Python, tenbri_openai_agents.uninstrument() stops recording the runs started after it (uninstrumentOpenAIAgents() in TypeScript).

The SDK pauses a run when a tool’s needs_approval (needsApproval) says so, and returns the pending calls as result.interruptions. Answer them the SDK’s way; each answer is recorded before the run resumes:

  • state.approve(item) writes an approval entry by the approver first. When the run resumes, the tool’s intent names it: approval: {by, entry}.
  • state.reject(item) writes the call’s intent and a refused outcome whose principal is the approver.

Calls the model asked for that never started (a tool input guardrail stopped them) are recorded as refused without a person.

Refunds over 50 EUR need a person; this one approves up to 100 EUR and rejects the rest:

refunds.py
import asyncio
import json
import tenbri
from agents import Agent, Runner, function_tool
tenbri.init() # TENBRI_STREAM, TENBRI_APPROVER, ./tenbri.toml
tenbri.instrument_openai_agents()
async def over_50(ctx, args, call_id):
return args["amount"] > 50
@function_tool(needs_approval=over_50)
def issue_refund(charge: str, amount: int) -> str:
"""Refunds `amount` EUR on the card charge `charge`."""
return f"refunded {amount} EUR on {charge}"
def ask_a_person(item):
# Your review step: a ticket, a Slack button, a CLI prompt. Here: refunds up to 100 EUR pass.
return json.loads(item.arguments)["amount"] <= 100
async def main():
agent = Agent(name="support", instructions="You handle refunds.", tools=[issue_refund])
result = await Runner.run(agent, "Refund ch_1, ch_2 and ch_3.")
while result.interruptions:
state = result.to_state()
for item in result.interruptions:
if ask_a_person(item):
state.approve(item) # an approval by TENBRI_APPROVER
else:
state.reject(item) # a refused outcome by TENBRI_APPROVER
result = await Runner.run(agent, state)
for m in tenbri.default().query(action="issue_refund"):
e = m.entry
who = f"{e['principal']['kind']}:{e['principal']['id']}"
print(e["seq"], e["kind"], e.get("phase", ""), e.get("status", ""), who, e["action"]["target"])
asyncio.run(main())
Terminal window
export TENBRI_STREAM=./audit TENBRI_APPROVER=person:alice
python refunds.py

With a scripted model that asks for 20 EUR on ch_1, 80 EUR on ch_2 and 500 EUR on ch_3 in one turn, both print:

3 decision intent agent:support stripe:ch_1
4 decision outcome ok agent:support stripe:ch_1
5 approval person:alice stripe:ch_2
6 decision intent agent:support stripe:ch_3
7 decision outcome refused person:alice stripe:ch_3
8 decision intent agent:support stripe:ch_2
9 decision outcome ok agent:support stripe:ch_2

The 20 EUR refund ran at once. The other two paused the run. Alice’s approval (seq 5) came before the intent that names it (seq 8); her rejection is the refused outcome at seq 7.

When the person who answers changes per call, pass a function of the item instead of TENBRI_APPROVER; reviewer_of(item) (reviewerOf(item)) stands for your own lookup:

tenbri.instrument_openai_agents(approver=lambda item: f"person:{reviewer_of(item)}")

With no approver at all, an approval writes no entry and logs tenbri: c2 approved, by nobody known: set TENBRI_APPROVER (TypeScript: without the comma).

The stream is wherever TENBRI_STREAM points; the code does not change.

Location TENBRI_STREAM Credentials
A local directory ./audit or file:///var/lib/audit/support none
AWS S3, MinIO s3://bucket/prefix AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION; AWS_ENDPOINT_URL (and AWS_ALLOW_HTTP=true for plain HTTP) for MinIO
Cloudflare R2 r2://bucket/prefix CLOUDFLARE_ACCOUNT_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY

Signing keys stay on the machine that writes; only entries go to the bucket, with "durability":"replicated". See Storage for every variable. The bindings do not write through a tenbrid server; to send entries to tenbrid over REST, record by hand as in any other framework.

Anyone with read access to the stream and its fingerprint can check it; no keys needed. Pin the fingerprint you got at creation: without one, tenbri verify only reports consistent and NOT pinned: this only shows the stream agrees with its own genesis key.

Terminal window
tenbri verify ./audit --key sha256:4d15c116… # the whole stream
tenbri query ./audit --action issue_refund --key sha256:4d15c116…
tenbri query ./audit --principal person:alice --key sha256:4d15c116…
tenbri schema check ./audit # entries against tenbri.toml
tenbri tail ./audit --key sha256:4d15c116… # follow new entries

Each match carries verification: verified when a key is pinned, unpinned when not. See the CLI reference.

Give the agent a scripted model: a Model that returns the next prepared turn. The SDK runs as usual, with no API key and no network, and you assert on what the stream holds. Give init() a stream and keys in a temporary directory, so tests never touch your real keys:

test_agent.py
import asyncio
import json
import tenbri
from agents import Agent, Runner, function_tool, set_trace_processors
from agents.items import ModelResponse
from agents.models.interface import Model
from agents.usage import Usage
from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText
set_trace_processors([]) # no API key: export traces nowhere (entries still carry their ids)
class Scripted(Model):
"""Plays the model's turns from a script: no API key, no network."""
def __init__(self, *turns):
self.turns = list(turns)
async def get_response(self, *args, **kwargs):
return ModelResponse(output=self.turns.pop(0), usage=Usage(), response_id=None)
def stream_response(self, *args, **kwargs):
raise NotImplementedError
def call(name, call_id, **args):
return ResponseFunctionToolCall(
id=call_id, call_id=call_id, type="function_call", name=name, arguments=json.dumps(args)
)
def say(text):
content = [ResponseOutputText(text=text, type="output_text", annotations=[])]
return ResponseOutputMessage(id="m", type="message", role="assistant", status="completed", content=content)
@function_tool
def issue_refund(charge: str, amount: int) -> str:
"""Refunds `amount` EUR on the card charge `charge`."""
return f"refunded {amount} EUR on {charge}"
def test_refund_is_recorded(tmp_path):
# A stream and keys of its own: the test never touches your real keys directory.
with tenbri.init(tmp_path / "audit", keys=tmp_path / "keys") as log:
tenbri.instrument_openai_agents(actions={"issue_refund": {"target": "stripe:{charge}"}})
model = Scripted([call("issue_refund", "c1", charge="ch_1", amount=20)], [say("Refunded.")])
agent = Agent(name="support", tools=[issue_refund], model=model)
asyncio.run(Runner.run(agent, "Refund 20 EUR on ch_1."))
entries = [m.entry for m in log.query(action="issue_refund")]
assert [(e["phase"], e.get("status")) for e in entries] == [("intent", None), ("outcome", "ok")]
assert entries[0]["action"]["target"] == "stripe:ch_1"
assert entries[0]["principal"] == {"kind": "agent", "id": "support"}
assert log.verify(log.fingerprint).ok
Terminal window
python -m pytest -q test_agent.py
. [100%]
1 passed in 0.76s

In TypeScript a tool belongs to one recorder: build fresh tools or agents per test if each test opens its own stream.

These messages come from real runs; paths are shortened.

Message Cause What to do
InvalidRequestError: no stream: pass tenbri.init(stream) or set TENBRI_STREAM to a directory or s3://bucket/prefix (TypeScript: pass init({ stream })) init() found no stream. Set TENBRI_STREAM, or pass the stream to init().
InvalidRequestError: Tenbri is not initialized: call tenbri.init() first (TypeScript: call init() first) A run started before init(). Call init() at startup, before the first run.
Python: UserError: Error running tool issue_refund: read-only: no stream key for tnb1:… in …/keys The intent could not be written (here the stream’s keys are not in this keys directory), so the run stopped before the tool ran. Set TENBRI_KEYS to the directory the stream was created with. Any write failure surfaces this way: check the __cause__.
TypeScript: ToolCallError: Failed to run function tools: NoSessionKeyError: read-only: no stream key for tnb1:… in …/keys The same, from the tenbri guardrail; error.error is the TenbriError. The same.
UserError: Error running tool issue_refund: 'stripe:{charge_id}': no argument 'charge_id' The target names an argument the tool does not have. Match the placeholder to the tool’s parameters.
InvalidRequestError: tenbri.toml: actions.issue_refund: policy: expected ref@version A policy without its version, raised by init(). Write policy = "policy:refund@14".
tenbri: c2 approved, by nobody known: set TENBRI_APPROVER (a warning) state.approve with no approver configured: no approval entry is written. Set TENBRI_APPROVER, or pass approver.
tenbri: not recorded, the action goes ahead (fail_open): … (failOpen in TypeScript) fail_open is on and the stream could not be written; the tool ran. Fix the stream; the call is not in the record.
ImportError: …: pip install --pre tenbri-openai-agents tenbri.instrument_openai_agents() without the adapter installed. Install it.
Error: already recorded by another TenbriRecorder (TypeScript) An agent or tool is instrumented twice, by two recorders. Use one recorder per agent, or build fresh tools for a second one.
An MCP tool has no entries (TypeScript) Tools an agent gets at run time are not instrumented, and only function tools are recorded. Wrap the call with decision().
broken at seq 1: untrusted_root: the genesis key is sha256:e3ed75b1…, not the pinned sha256:0edc8058… You verified against another stream’s fingerprint (here a stream that was deleted and created again). Pin the fingerprint printed when this stream was created.

A process that dies mid-tool leaves its intent open; the next writer of the stream records it as unknown.

What happens Entry
A tool call An intent by agent:<name>, action and target from the mapping, durable before the tool runs; then the outcome ok, or error if the tool raised or timed out
A tool input guardrail rejects or trips, or state.reject(item) An intent and the outcome refused (with the approver as principal, for a rejection)
A handoff An intent and an ok outcome, action handoff, target agent:<next agent>
state.approve(item) An approval entry by the approver; the tool’s intent names it in approval
The run raises (Python) Open intents end in error, or unknown when the run was cancelled

Tool arguments, results and prompts are never written. Entries carry the SDK’s trace id (span ids cut to 16 hex digits; the first 16 still find the span), so a decision and its trace find each other.

Three entries of the approvals run above, as stored (signatures and hashes shortened). Alice’s approval:

{
"action": { "target": "stripe:ch_2", "type": "issue_refund" },
"id": "01M5D15XNMXP21ECFYM6BN779H",
"kind": "approval",
"prev": "sha256:d023ad86…",
"principal": { "id": "alice", "kind": "person" },
"seq": 5,
"sig": "ed25519:46695640…",
"signer": "sess:2026-10-20:de0b1447fc5d",
"stream": "tnb1:502da15a8bf59208dd9f204c0c80ff36",
"ts": "2026-10-20T16:09:14.679Z",
"v": 1
}

Her rejection of the 500 EUR refund, an outcome by the person:

{
"action": { "target": "stripe:ch_3", "type": "issue_refund" },
"id": "01M5D15XPY4RPQCV9D9NP8NN1B",
"intent": "01M5D15XP9KB5KHTEPJAZC5HTF",
"kind": "decision",
"phase": "outcome",
"prev": "sha256:5d578df6…",
"principal": { "id": "alice", "kind": "person" },
"seq": 7,
"sig": "ed25519:3bd3c7ad…",
"signer": "sess:2026-10-20:de0b1447fc5d",
"source": "sdk",
"status": "refused",
"stream": "tnb1:502da15a8bf59208dd9f204c0c80ff36",
"ts": "2026-10-20T16:09:14.721Z",
"v": 1
}

And the intent of the refund she approved, which names her approval:

{
"action": { "target": "stripe:ch_2", "type": "issue_refund" },
"approval": {
"by": { "id": "alice", "kind": "person" },
"entry": "sha256:b43b3b21…"
},
"durability": "local",
"id": "01M5D15XQRVY98ATHYJJCG9T9D",
"kind": "decision",
"phase": "intent",
"prev": "sha256:d94f901b…",
"principal": { "id": "support", "kind": "agent" },
"rule": { "ref": "policy:refund", "result": "allow", "version": "14" },
"seq": 8,
"sig": "ed25519:80e56073…",
"signer": "sess:2026-10-20:de0b1447fc5d",
"source": "sdk",
"stream": "tnb1:502da15a8bf59208dd9f204c0c80ff36",
"trace": { "span_id": "0acacb7b5cac4bb5", "trace_id": "52847858c4924eeda1f205b6fb35f8a8" },
"ts": "2026-10-20T16:09:14.747Z",
"v": 1
}

See the entry format for every field.

The instrumentation is built on TenbriRecorder, on a stream you opened yourself, with no default stream and no environment. Use it to hold several streams, or to record some runs and not others. It writes the same entries.

recorder.py
import asyncio
from agents import Agent, Runner, function_tool
from tenbri import AsyncTenbri
from tenbri_openai_agents import TenbriRecorder
@function_tool
def issue_refund(charge: str, amount: int) -> str:
"""Refunds `amount` EUR on the card charge `charge`."""
return f"refunded {amount} EUR on {charge}"
async def main():
log = await AsyncTenbri.open("./audit", create=True)
agent = Agent(name="support", instructions="You handle refunds.", tools=[issue_refund])
recorder = TenbriRecorder(log, target=lambda tool, args: f"stripe:{args['charge']}")
async with recorder: # closes the intents a failed run leaves open
await Runner.run(agent, "Refund 20 EUR on ch_1.", hooks=recorder, run_config=recorder.run_config())
print([(m.entry["phase"], m.entry["action"]) for m in await log.query(action="issue_refund")])
await log.close()
asyncio.run(main())

The SDK awaits its run hooks, so the intent is durable before the tool runs. Pass run_config=recorder.run_config(): it is how the recorder hears that a tool raised. Use one recorder per run, or per conversation when a run resumes after approvals, and answer with await recorder.approve(state, item, "person:alice") or recorder.reject(...).

Both print the intent and the ok outcome of issue_refund on stripe:ch_1. TenbriRecorder takes tools, action, target and inputs (functions of the tool name and arguments), describe (any of action, target, rule, subject at once), subject and fail_open (failOpen). recorder.read(ref, version, content) (read(ref, { version, content })) pins what the agent read to the next decision, by keyed digest.