Skip to content
Tenbri pangolinTenbri

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.

Terminal window
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-bucket
200

Any way of creating the bucket works (mc mb, the MinIO console); curl --aws-sigv4 just avoids another tool.

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.

Terminal window
export AWS_ACCESS_KEY_ID=minioadmin
export AWS_SECRET_ACCESS_KEY=minioadmin
export AWS_REGION=us-east-1
export AWS_ENDPOINT_URL=http://127.0.0.1:9000
export AWS_ALLOW_HTTP=true
export TENBRI_KEYS=./keys
Terminal window
tenbri init s3://audit-bucket/audit/billing
created stream tnb1:50ed3445051480ab807e90de536004cb at s3://audit-bucket/audit/billing
stream key fingerprint (pin it): sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93
keys: ./keys/tnb1-50ed3445051480ab807e90de536004cb
verify with: tenbri verify s3://audit-bucket/audit/billing --key sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93

Record an intent, then its outcome:

Terminal window
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}
Terminal window
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",…}
Terminal window
tenbri verify s3://audit-bucket/audit/billing \
--key sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93
verified 4 entries, stream tnb1:50ed3445051480ab807e90de536004cb, pinned to sha256:50ed3445051480ab807e90de536004cb301ccfeedf2d193ce0163fe2f38d1c93

init refuses a prefix that already holds a stream:

tenbri: s3://audit-bucket/audit/billing already holds a stream

Each prefix holds the same layout as a local stream:

audit/billing/HEAD
audit/billing/batches/000000000001.jsonl
audit/billing/batches/000000000002.jsonl
audit/billing/batches/000000000003.jsonl
audit/billing/batches/000000000004.jsonl

A stream written from Python or Node verifies with the CLI, and the other way round.

Stop MinIO when you are done:

Terminal window
docker stop minio

Leave out AWS_ENDPOINT_URL and AWS_ALLOW_HTTP; the other AWS_* variables work as usual.

Terminal window
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=eu-west-1
export TENBRI_KEYS=~/.config/tenbri/keys
Terminal window
tenbri init s3://my-audit-bucket/audit/billing
tenbri verify s3://my-audit-bucket/audit/billing --key sha256:...

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.

Terminal window
export AWS_ACCESS_KEY_ID=... # R2 API token access key id
export AWS_SECRET_ACCESS_KEY=... # R2 API token secret
export CLOUDFLARE_ACCOUNT_ID=...
export TENBRI_KEYS=~/.config/tenbri/keys
Terminal window
tenbri init r2://my-audit-bucket/audit/billing
tenbri verify r2://my-audit-bucket/audit/billing --key sha256:...

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 R2

From 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 R2

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