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:
pip install --pre tenbriNode 20 or later:
npm install tenbri@nextnpm pkg set type=moduleRust 1.85 or later with cargo.
Record decisions
Section titled “Record decisions”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.fingerprintprint("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()export TENBRI_KEYS=$PWD/keyspython agent.pypin this: sha256:e5e81b026abae3e4870a8c8a631ddf827737ce9c2901c87b7eddb33a9b391191intent 3 01M5CWVMV9TX4QN9EB4WES2QFF ['policy:refund']outcome 4 okoutcome 6 refusedverified: True 8 entries- Any other exception inside a decision records the outcome
errorand propagates; an interruption (KeyboardInterrupt, cancellation) recordsunknown. - For asyncio,
await AsyncTenbri.open(...)has the same methods as coroutines,async withdecisions, and decorators ofasync deffunctions. Nothing blocks the event loop. - The low-level
log.intent(...)andlog.outcome(intent, status)are in the quickstart.
import { Refused, Tenbri } from '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.const log = await Tenbri.open('audit/support', { create: true, digestKey: 'a 32-byte secret, kept apart....' })const pin = log.fingerprint!console.log('pin this:', pin)
await log.run({ principal: 'agent:support-assistant' }, async (run) => { // What the agent read is pinned to its next decision, by a keyed digest. run.read('policy:refund', { version: '14', content: 'refunds under 50 EUR are automatic' })
// The intent is durable before the callback runs; the outcome follows when it returns. await run.decision({ action: 'issue_refund', target: 'stripe:charge/ch_3PqX' }, async (intent) => { console.log('intent ', intent.seq, intent.id, intent.inputs?.map((i) => i.ref)) // call the payment API here })
// Throw Refused when your policy says no: the outcome is "refused". try { await run.decision({ action: 'issue_refund', target: 'stripe:charge/ch_9ZzY' }, async () => { throw new Refused('over the 50 EUR limit') }) } catch (e) { if (!(e instanceof Refused)) throw e console.log('refused:', e.message) }})
// decision() returns what the callback returns.const refundId = await log.decision( { action: 'issue_refund', target: 'stripe:charge/ch_7Kd2', principal: 'agent:billing' }, async () => 're_123', // call the payment API here)console.log('refund', refundId)
const report = await log.verify(pin)console.log('verified:', report.ok, report.entries, 'entries')await log.close()export TENBRI_KEYS=$PWD/keysnode agent.tspin this: sha256:cc79673ab84c420f22e7d66c8fb7da4e2f2f263ea61f134ee9732e70c353bd55intent 3 01M5CWVZ5RCBJ90SPDR60YCNE7 [ 'policy:refund' ]refused: over the 50 EUR limitrefund re_123verified: true 8 entries- Node 22.18 and later run
.tsfiles directly; on Node 20, usenpx tsx agent.ts. - Any other error thrown inside a decision records the outcome
errorand rejects with it. - Every call returns a Promise, and concurrent appends share one group commit.
- The low-level
log.intent(...)andlog.outcome(intent, status)are in the quickstart.
Create the project and add the crates:
cargo new refund-agentcd refund-agentcargo add tenbri-core@0.1.0-beta.1 tenbri-keys@0.1.0-beta.1 tenbri-store@0.1.0-beta.1 tenbri-writer@0.1.0-beta.1cargo add tokio --features macros,rtPre-release versions must be named exactly. tenbri-store handles local directories by default;
add --features s3 for s3:// and r2:// streams.
use tenbri_core::{Action, Entry, Input, Kind, Principal, PrincipalKind, Status};use tenbri_keys::{Key, SESSION_LIFETIME_MS};use tenbri_store::LocalStore;use tenbri_writer::{Writer, now_ms, verify};
#[tokio::main(flavor = "current_thread")]async fn main() -> Result<(), Box<dyn std::error::Error>> { // A fresh, empty directory for the stream. std::fs::create_dir_all("audit/support")?; let store = LocalStore::open("audit/support")?;
// The stream key identifies the stream; the session key signs entries. let stream_key = Key::new_stream()?; let session = Key::new_session(now_ms(), SESSION_LIFETIME_MS)?; let pin = stream_key.fingerprint(); let mut w = Writer::create(store.clone(), &stream_key, session).await?; println!("stream key fingerprint (pin it): {pin}");
// The intent: written and durable before the refund runs. let intent = w .intent(Entry { principal: Some(Principal { kind: PrincipalKind::Agent, id: "support-assistant".into(), }), action: Some(Action { r#type: "issue_refund".into(), target: Some("stripe:charge/ch_3PqX".into()), }), inputs: Some(vec![Input { r#ref: "policy:refund".into(), version: "14".into(), digest: "hmac-sha256:9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa" .into(), }]), ..Entry::new(Kind::Decision) }) .await?; println!("intent seq {} id {}", intent.seq, intent.id);
// ... call the payment API here ...
let outcome = w.outcome(&intent, Status::Ok).await?; println!( "outcome seq {} closes {}", outcome.seq, outcome.intent.as_deref().unwrap_or("?") );
// Read the whole stream back and check it against the pinned key. let report = verify(&store, Some(&pin)).await?; match report.broken { None => println!( "verified {} entries, stream {}", report.entries, report.stream.unwrap_or_default() ), Some(b) => println!("{b}"), } Ok(())}Writer::intent sets phase: intent and source: sdk. Writer::outcome copies the intent’s
principal, action, source and subject, and sets intent and status.
cargo run -qstream key fingerprint (pin it): sha256:2b16e61148c367e71361dbb382993c9aa931f29418d583868b7d631811592db5intent seq 3 id 01M5CWV3QKJS6AFK4VB294XHC0outcome seq 4 closes 01M5CWV3QKJS6AFK4VB294XHC0verified 4 entries, stream tnb1:2b16e61148c367e71361dbb382993c9aA second run fails, because the directory already holds a stream:
Error: ExistsThis example keeps the keys in memory and drops them on exit, so nothing can append to the stream
later. In a real program, create the stream with tenbri init (which saves the keys in
$TENBRI_KEYS), then open it with Writer::open and hand the writer its keys with
Writer::use_keys, as the CLI does.
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.
Check it with the CLI
Section titled “Check it with the CLI”The stream is a normal stream on disk. After the Python or TypeScript run:
tenbri verify audit/support --key sha256:e5e81b026abae3e4870a8c8a631ddf827737ce9c2901c87b7eddb33a9b391191tenbri 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:
tenbri verify audit/support --key sha256:2b16e61148c367e71361dbb382993c9aa931f29418d583868b7d631811592db5tenbri 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"}Other ways to write
Section titled “Other ways to write”- 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://orr2://location: see Streams on object storage.
Related: Keys · Streams and entries · Integrity · Entry format
