Skip to content
Tenbri pangolinTenbri

Write from code

Your agent is a program and you want to record its decisions in process, without the CLI or a server. You will open a stream in a local directory, record an issue_refund decision around the call that issues the refund, record a refusal, and verify the stream against its pinned fingerprint. Then you check the same stream with the tenbri CLI.

The Python and Node packages wrap the same Rust writer as the CLI and tenbrid. They add ergonomic layers on top: a run pins what the agent read to its next decision, and a decision writes the intent before your code runs and the outcome after, as a context manager, a callback or a decorator. In Rust, you call the writer directly.

Prerequisites: optionally the tenbri CLI (install) to check the result, and:

Python 3.10 or later:

Terminal window
pip install --pre tenbri
agent.py
from tenbri import Refused, Tenbri
# Creates the stream and its keys (in $TENBRI_KEYS) the first time; opens it after that.
# The digest key keys the hashes of what the agent reads: keep it in your secret store.
log = Tenbri.open("audit/support", create=True, digest_key=b"a 32-byte secret, kept apart....")
pin = log.fingerprint
print("pin this:", pin)
with log.run("agent:support-assistant") as run:
# What the agent read is pinned to its next decision, by a keyed digest.
run.read("policy:refund", "14", "refunds under 50 EUR are automatic")
# The intent is durable before the block runs; the outcome follows when it exits.
with run.decision("issue_refund", "stripe:charge/ch_3PqX") as d:
pass # call the payment API here
print("intent ", d.intent["seq"], d.intent["id"], [i["ref"] for i in d.intent["inputs"]])
print("outcome", d.outcome["seq"], d.outcome["status"])
# Raise Refused when your policy says no: the outcome is "refused".
try:
with run.decision("issue_refund", "stripe:charge/ch_9ZzY") as big:
raise Refused("over the 50 EUR limit")
except Refused:
pass
print("outcome", big.outcome["seq"], big.outcome["status"])
# Or decorate a function: the target can come from its arguments.
@log.decision("issue_refund", target=lambda charge, amount: f"stripe:charge/{charge}", principal="agent:billing")
def refund(charge: str, amount: int) -> str:
return "re_123" # call the payment API here
refund("ch_7Kd2", 20)
report = log.verify(pin)
print("verified:", report.ok, report.entries, "entries")
log.close()
Terminal window
export TENBRI_KEYS=$PWD/keys
python agent.py
pin this: sha256:e5e81b026abae3e4870a8c8a631ddf827737ce9c2901c87b7eddb33a9b391191
intent 3 01M5CWVMV9TX4QN9EB4WES2QFF ['policy:refund']
outcome 4 ok
outcome 6 refused
verified: True 8 entries
  • Any other exception inside a decision records the outcome error and propagates; an interruption (KeyboardInterrupt, cancellation) records unknown.
  • For asyncio, await AsyncTenbri.open(...) has the same methods as coroutines, async with decisions, and decorators of async def functions. Nothing blocks the event loop.
  • The low-level log.intent(...) and log.outcome(intent, status) are in the quickstart.

Seq 1 and 2 are the key entries that creating the stream wrote: the genesis entry signed by the new stream key, and the certification of a 24-hour session key that signs everything after. Each intent and outcome returned only once its batch and HEAD were durable.

The stream is a normal stream on disk. After the Python or TypeScript run:

Terminal window
tenbri verify audit/support --key sha256:e5e81b026abae3e4870a8c8a631ddf827737ce9c2901c87b7eddb33a9b391191
tenbri tail audit/support -n 6 --no-follow | jq -c '{seq, phase, status, principal: .principal.id, target: .action.target}'
verified 8 entries, stream tnb1:e5e81b026abae3e4870a8c8a631ddf82, pinned to sha256:e5e81b026abae3e4870a8c8a631ddf827737ce9c2901c87b7eddb33a9b391191
{"seq":3,"phase":"intent","status":null,"principal":"support-assistant","target":"stripe:charge/ch_3PqX"}
{"seq":4,"phase":"outcome","status":"ok","principal":"support-assistant","target":"stripe:charge/ch_3PqX"}
{"seq":5,"phase":"intent","status":null,"principal":"support-assistant","target":"stripe:charge/ch_9ZzY"}
{"seq":6,"phase":"outcome","status":"refused","principal":"support-assistant","target":"stripe:charge/ch_9ZzY"}
{"seq":7,"phase":"intent","status":null,"principal":"billing","target":"stripe:charge/ch_7Kd2"}
{"seq":8,"phase":"outcome","status":"ok","principal":"billing","target":"stripe:charge/ch_7Kd2"}

After the Rust run, the stream holds one decision:

Terminal window
tenbri verify audit/support --key sha256:2b16e61148c367e71361dbb382993c9aa931f29418d583868b7d631811592db5
tenbri tail audit/support -n 2 --no-follow | jq -c '{seq, kind, phase, status, action: .action.type}'
verified 4 entries, stream tnb1:2b16e61148c367e71361dbb382993c9a, pinned to sha256:2b16e61148c367e71361dbb382993c9aa931f29418d583868b7d631811592db5
{"seq":3,"kind":"decision","phase":"intent","status":null,"action":"issue_refund"}
{"seq":4,"kind":"decision","phase":"outcome","status":"ok","action":"issue_refund"}
  • Agents built on the Claude Agent SDK, the OpenAI Agents SDK or LangGraph record every tool call with one line: see Adapters.
  • Agents in other languages, or on machines without the keys, write through tenbrid over REST or gRPC.
  • The same code writes to a bucket with an s3:// or r2:// location: see Streams on object storage.

Related: Keys · Streams and entries · Integrity · Entry format