Streams on object storage
A stream can live under a bucket prefix instead of a local directory. Everything that takes a
stream location (the CLI, tenbrid‘s path, Tenbri.open in Python and Node) also takes
s3://bucket/<prefix> or r2://bucket/<prefix>, with credentials from the same environment
variables the providers’ own tools read. You will run a stream on a local MinIO end to end, then
see the variables for AWS S3 and Cloudflare R2.
Prerequisites: Docker for MinIO, and one of: the tenbri CLI (its default s3 feature is
on), pip install --pre tenbri, or npm install tenbri@next
(install). The Python and Node packages include object storage.
Keys never go to the bucket: they stay in $TENBRI_KEYS on the machine that writes.
1. Start MinIO and create a bucket
Section titled “1. Start MinIO and create a bucket”docker run -d --rm --name minio -p 9000:9000 \ -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \ cgr.dev/chainguard/minio:latest server /data
curl -s -o /dev/null -w "%{http_code}\n" -X PUT \ --user minioadmin:minioadmin --aws-sigv4 "aws:amz:us-east-1:s3" \ http://127.0.0.1:9000/audit-bucket200Any way of creating the bucket works (mc mb, the MinIO console); curl --aws-sigv4 just avoids
another tool.
2. Point Tenbri at it
Section titled “2. Point Tenbri at it”MinIO speaks the S3 API, so use s3:// with AWS_ENDPOINT_URL. AWS_ALLOW_HTTP=true is needed
for a plain http:// endpoint. The CLI, Python and Node read the same variables.
export AWS_ACCESS_KEY_ID=minioadminexport AWS_SECRET_ACCESS_KEY=minioadminexport AWS_REGION=us-east-1export AWS_ENDPOINT_URL=http://127.0.0.1:9000export AWS_ALLOW_HTTP=trueexport TENBRI_KEYS=./keys3. Create the stream, record and verify
Section titled “3. Create the stream, record and verify”tenbri init s3://audit-bucket/audit/billingcreated stream tnb1:50ed3445051480ab807e90de536004cb at s3://audit-bucket/audit/billingstream key fingerprint (pin it): sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93keys: ./keys/tnb1-50ed3445051480ab807e90de536004cbverify with: tenbri verify s3://audit-bucket/audit/billing --key sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93Record an intent, then its outcome:
echo '{"kind":"decision","phase":"intent","source":"sdk","principal":{"kind":"agent","id":"billing-agent"},"action":{"type":"issue_refund","target":"stripe:charge/ch_3PqX"}}' \ | tenbri append s3://audit-bucket/audit/billing{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"id":"01M5CWSJ9JFPPDBPAV5EQMY6A3","kind":"decision","phase":"intent","prev":"sha256:32b00393…","principal":{"id":"billing-agent","kind":"agent"},"seq":3,"sig":"ed25519:fde3597d…","signer":"sess:2026-10-20:19226a107c57","source":"sdk","stream":"tnb1:50ed3445051480ab807e90de536004cb","ts":"2026-10-20T14:52:35.506Z","v":1}echo '{"kind":"decision","phase":"outcome","source":"sdk","intent":"01M5CWSJ9JFPPDBPAV5EQMY6A3","status":"ok","principal":{"kind":"agent","id":"billing-agent"},"action":{"type":"issue_refund","target":"stripe:charge/ch_3PqX"}}' \ | tenbri append s3://audit-bucket/audit/billing{"action":{"target":"stripe:charge/ch_3PqX","type":"issue_refund"},"id":"01M5CWSRZQB4VBYV03VZ5B8R10","intent":"01M5CWSJ9JFPPDBPAV5EQMY6A3","kind":"decision","phase":"outcome","prev":"sha256:e529ff23…",…,"seq":4,…,"status":"ok",…}tenbri verify s3://audit-bucket/audit/billing \ --key sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93verified 4 entries, stream tnb1:50ed3445051480ab807e90de536004cb, pinned to sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93init refuses a prefix that already holds a stream:
tenbri: s3://audit-bucket/audit/billing already holds a streamfrom tenbri import Tenbri
url = "s3://audit-bucket/audit/billing-py"
# Creates the stream under the prefix; its keys stay in $TENBRI_KEYS on this machine.log = Tenbri.open(url, create=True)pin = log.fingerprintprint("created", log.id, "at", url)print("pin this:", pin)
# The intent is durable in the bucket before the block runs; the outcome follows.with log.decision("issue_refund", "stripe:charge/ch_3PqX", principal="agent:billing-agent") as d: pass # issue the refund hereprint("intent ", d.intent["seq"], d.intent["id"])print("outcome", d.outcome["seq"], d.outcome["status"])log.close()
with Tenbri.open(url, key=pin) as log: report = log.verify() print("verified:", report.ok, report.entries, "entries")python billing.pycreated tnb1:65b466e8e385e877a9b7e5ec2b8d2d36 at s3://audit-bucket/audit/billing-pypin this: sha256:65b466e8e385e877a9b7e5ec2b8d2d36781db85128d529d60605b17975131cbeintent 3 01M5CWT4T6J2SMJDG209ZSX7QSoutcome 4 okverified: True 4 entriesWith asyncio, await AsyncTenbri.open(url) takes the same URL.
import { Tenbri } from 'tenbri'
const url = 's3://audit-bucket/audit/billing-ts'
// Creates the stream under the prefix; its keys stay in $TENBRI_KEYS on this machine.const log = await Tenbri.open(url, { create: true })const pin = log.fingerprint!console.log('created', log.id, 'at', url)console.log('pin this:', pin)
// The intent is durable in the bucket before the callback runs; the outcome follows.await log.decision({ action: 'issue_refund', target: 'stripe:charge/ch_3PqX', principal: 'agent:billing-agent' }, async (intent) => { console.log('intent ', intent.seq, intent.id) // issue the refund here})await log.close()
const pinned = await Tenbri.open(url, { key: pin })const report = await pinned.verify()console.log('verified:', report.ok, report.entries, 'entries')await pinned.close()node billing.tscreated tnb1:5dc0f9eaf28a11b170ff0eaa755932a9 at s3://audit-bucket/audit/billing-tspin this: sha256:5dc0f9eaf28a11b170ff0eaa755932a9acaba75fb8ee622509f0ad5da0595292intent 3 01M5CWT4YR79S3RQKQY9W34AYFverified: true 4 entriesNode 22.18 and later run .ts files directly (with "type": "module" in package.json); on
Node 20, use npx tsx billing.ts.
Each prefix holds the same layout as a local stream:
audit/billing/HEADaudit/billing/batches/000000000001.jsonlaudit/billing/batches/000000000002.jsonlaudit/billing/batches/000000000003.jsonlaudit/billing/batches/000000000004.jsonlA stream written from Python or Node verifies with the CLI, and the other way round.
Stop MinIO when you are done:
docker stop minioAWS S3 (not run here)
Section titled “AWS S3 (not run here)”Leave out AWS_ENDPOINT_URL and AWS_ALLOW_HTTP; the other AWS_* variables work as usual.
export AWS_ACCESS_KEY_ID=...export AWS_SECRET_ACCESS_KEY=...export AWS_REGION=eu-west-1export TENBRI_KEYS=~/.config/tenbri/keystenbri init s3://my-audit-bucket/audit/billingtenbri verify s3://my-audit-bucket/audit/billing --key sha256:...from tenbri import Tenbri
log = Tenbri.open("s3://my-audit-bucket/audit/billing", create=True)import { Tenbri } from 'tenbri'
const log = await Tenbri.open('s3://my-audit-bucket/audit/billing', { create: true })Cloudflare R2 (not run here)
Section titled “Cloudflare R2 (not run here)”Use r2:// with an R2 API token’s key pair. Tenbri derives the endpoint from
CLOUDFLARE_ACCOUNT_ID (https://<account>.r2.cloudflarestorage.com), or takes
AWS_ENDPOINT_URL if you set it. The region is always auto.
export AWS_ACCESS_KEY_ID=... # R2 API token access key idexport AWS_SECRET_ACCESS_KEY=... # R2 API token secretexport CLOUDFLARE_ACCOUNT_ID=...export TENBRI_KEYS=~/.config/tenbri/keystenbri init r2://my-audit-bucket/audit/billingtenbri verify r2://my-audit-bucket/audit/billing --key sha256:...from tenbri import Tenbri
log = Tenbri.open("r2://my-audit-bucket/audit/billing", create=True)import { Tenbri } from 'tenbri'
const log = await Tenbri.open('r2://my-audit-bucket/audit/billing', { create: true })Without CLOUDFLARE_ACCOUNT_ID or AWS_ENDPOINT_URL, Tenbri stops before any request (this
output is real):
tenbri: r2://audit-bucket/audit/billing: set CLOUDFLARE_ACCOUNT_ID (or AWS_ENDPOINT_URL) for R2From Python, Tenbri.open raises the same message as a StoreUnavailableError
(TENBRI_STORE_UNAVAILABLE); Node rejects with the same error class:
tenbri.errors.StoreUnavailableError: r2://audit-bucket/audit/billing: set CLOUDFLARE_ACCOUNT_ID (or AWS_ENDPOINT_URL) for R2What happened
Section titled “What happened”Tenbri wrote the same batches and HEAD to the bucket that it writes to a local directory. Before using a bucket it runs a canary: a put-if-absent must succeed once and be refused the second time, and a compare-and-swap from a wrong ETag must fail; a store that cannot do this is refused. Those two operations are what fence concurrent writers, so no lock or lease is needed.
Related: Storage reference · Streams and entries · Keys · CLI reference
