Skip to content
Tenbri pangolinTenbri

tenbrid over REST

tenbrid serves streams over REST and gRPC, so agents in any language can write to them. You will start it on your machine with one stream and one bearer token, record an intent and its outcome, retry a write safely with the same Idempotency-Key, list and tail entries, and ask the server to verify the stream. Every call is shown with curl (the REST tab), Python (httpx) and TypeScript (the built-in fetch). A gRPC client closes the page.

Prerequisites: the tenbri and tenbrid binaries (install), and one of:

Terminal window
pip install httpx python-ulid

requests works the same way for every call but the tail, which needs a streaming response.

Terminal window
export TENBRI_KEYS=./keys
tenbri init audit/support
tenbrid token
created stream tnb1:5fb73e66e6240e0f873162fafd1480c4 at audit/support
stream key fingerprint (pin it): sha256:5fb73e66e6240e0f873162fafd1480c4428cf030483490dabcf4f502197e9deb
keys: ./keys/tnb1-5fb73e66e6240e0f873162fafd1480c4
verify with: tenbri verify audit/support --key sha256:5fb73e66e6240e0f873162fafd1480c4428cf030483490dabcf4f502197e9deb
token (give it to the client; it is not stored): tnbt_c5b3bd4c…
[[tokens]]
sha256 = "sha256:d7a060573fd59ee31df6395b9d4222475bf18dc67b054533d362c3d1921e39d5"
principal = { kind = "agent", id = "..." }

Keep the token for the client: tenbrid stores only its hash.

Each token is bound to one principal; entries written with it carry that principal.

tenbrid.toml
[streams.support]
path = "audit/support"
[[tokens]]
sha256 = "sha256:d7a060573fd59ee31df6395b9d4222475bf18dc67b054533d362c3d1921e39d5"
principal = { kind = "agent", id = "support-assistant" }

REST listens on 127.0.0.1:7070 and gRPC on 127.0.0.1:7071 by default. TENBRI_LISTEN and TENBRI_GRPC_LISTEN (or listen and grpc_listen in the file) change them. TENBRI_KEYS is read from the environment, as tenbri does.

Terminal window
TENBRI_LISTEN=127.0.0.1:8080 TENBRI_GRPC_LISTEN=127.0.0.1:8081 tenbrid --config tenbrid.toml
tenbrid 0.1.0-beta.1: REST on http://127.0.0.1:8080, gRPC on 127.0.0.1:8081, 1 streams

In another terminal, export the token from step 1 and make a client. The Python and TypeScript snippets on this page form one script: each step continues the previous one.

Terminal window
export TOKEN=tnbt_c5b3bd4c… # the token printed in step 1
client.py
import json
import os
import httpx
from ulid import ULID
client = httpx.Client(
base_url="http://127.0.0.1:8080",
headers={"Authorization": f"Bearer {os.environ['TOKEN']}"},
)
print(client.get("/v1/health").json())
{'status': 'ok'}

Only /v1/health and /v1/ready answer without a token.

Every write carries an Idempotency-Key: the entry id, a ULID your client generates. kind and phase come from the route, principal from the token, source defaults to sdk and durability to local. The response comes back once the intent is durable, so the action can run.

Send the same request again (a retry after a timeout, say) and you get the original entry back, with status 200 and "replayed":true, instead of a second entry. A first write answers 201.

client.py
intent_id = str(ULID()) # the Idempotency-Key, which becomes the entry id
draft = {
"action": {"type": "issue_refund", "target": "stripe:charge/ch_3PqX"},
"inputs": [{
"ref": "policy:refund",
"version": "14",
"digest": "hmac-sha256:9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa9f2c41aa",
}],
}
r = client.post("/v1/streams/support/intents", json=draft, headers={"Idempotency-Key": intent_id})
print(r.status_code, r.json()["entry"]["seq"], r.json()["replayed"])
# A retry with the same key and draft returns the original entry.
r = client.post("/v1/streams/support/intents", json=draft, headers={"Idempotency-Key": intent_id})
print(r.status_code, r.json()["entry"]["seq"], r.json()["replayed"])
201 3 False
200 3 True

Reusing a key for a different draft is refused with 409:

{"error":{"code":"TENBRI_IDEMPOTENCY_CONFLICT","message":"id 01K7ZJ4Q3V8M2R6T9W1X5Y7Z0A was used for a different entry"}}
409

An outcome needs only intent and status; principal, action, subject and source default to the intent’s.

client.py
r = client.post(
"/v1/streams/support/outcomes",
json={"intent": intent_id, "status": "ok"},
headers={"Idempotency-Key": str(ULID())},
)
print(r.status_code, r.json()["entry"]["seq"], r.json()["entry"]["status"])
201 4 ok

GET /v1/streams/{stream}/entries filters by input, action, principal, target, subject and after_seq, with limit (default 100, at most 1000). ?action=issue_refund returns both the intent and the outcome.

client.py
r = client.get("/v1/streams/support/entries", params={"input": "policy:refund@14"})
for m in r.json()["entries"]:
print(m["verification"], m["entry"]["seq"], m["entry"]["phase"], m["entry"]["id"])
verified 3 intent 01M5CWJKH0J5J2Z1SHT9S1PCT3

Each match carries the entry’s seq and hash; as_of is the head of the stream the answer was read from.

/tail is a server-sent event stream: the entries after after_seq, then new ones as they are written. Each event’s id is the entry’s sequence number, so a client reconnects with Last-Event-ID.

client.py
with client.stream("GET", "/v1/streams/support/tail", params={"after_seq": 2}, timeout=None) as r:
for line in r.iter_lines():
if line.startswith("data: "):
m = json.loads(line.removeprefix("data: "))
print(m["verification"], m["entry"]["seq"], m["entry"]["phase"])
if m["entry"]["phase"] == "outcome":
break # stop after the first outcome; drop this to follow forever
verified 3 intent
verified 4 outcome

The server reads the whole stream from storage and checks it against its pinned key. broken is absent when the stream verifies.

client.py
print(client.get("/v1/streams/support/verify").json())
{'stream': 'tnb1:d2908cab2599eb0e0d432a38465f3480', 'entries': 4, 'pinned_key': 'sha256:d2908cab2599eb0e0d432a38465f3480eb164e5e163a55a5f9d011deef4bc002'}

The Python and TypeScript outputs come from their own runs, on their own streams, so their ids differ from the curl run.

Stop tenbrid with Ctrl+C. It finishes the writes in flight first:

tenbrid: stopped; every acknowledged entry is durable

The gRPC API has the same operations and error codes as REST. Entries travel as their canonical JSON (entry_json) and drafts as JSON strings (draft_json); every call but health checks carries authorization: Bearer <token> metadata. The service is described in tenbri.proto; download it next to your client:

Terminal window
curl -fLO https://raw.githubusercontent.com/tenbri/tenbri/main/contract/proto/tenbri/v1/tenbri.proto

Generate the stubs with grpcio-tools:

Terminal window
pip install grpcio grpcio-tools python-ulid
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. tenbri.proto
grpc_client.py
import json
import os
import grpc
from ulid import ULID
import tenbri_pb2 as pb
import tenbri_pb2_grpc
channel = grpc.insecure_channel("127.0.0.1:8081")
api = tenbri_pb2_grpc.TenbriServiceStub(channel)
auth = [("authorization", f"Bearer {os.environ['TOKEN']}")]
intent_id = str(ULID()) # the idempotency key, which becomes the entry id
draft = {"action": {"type": "issue_refund", "target": "stripe:charge/ch_3PqX"}}
r = api.AppendIntent(
pb.AppendIntentRequest(stream="support", idempotency_key=intent_id, draft_json=json.dumps(draft)),
metadata=auth,
)
print("intent ", json.loads(r.appended.entry_json)["seq"], "replayed:", r.appended.replayed)
r = api.AppendOutcome(
pb.AppendOutcomeRequest(
stream="support",
idempotency_key=str(ULID()),
draft_json=json.dumps({"intent": intent_id, "status": "ok"}),
),
metadata=auth,
)
print("outcome", json.loads(r.appended.entry_json)["seq"])
v = api.VerifyStream(pb.VerifyStreamRequest(stream="support"), metadata=auth)
print("broken:" if v.HasField("broken") else "verified", v.entries, "entries, pinned to", v.pinned_key)
intent 3 replayed: False
outcome 4
verified 4 entries, pinned to sha256:07c2b3fa9a27514503d4e4f2e2a55001ff86cd0260616b6a10fa26dba37ae65a

A wrong token raises grpc.RpcError with StatusCode.UNAUTHENTICATED and the details unknown token.

Each gRPC run used a fresh stream, so its seq numbers start at 3.

tenbrid signed and chained each entry exactly as tenbri append does, using the stream’s session key from TENBRI_KEYS, and stamped the token’s principal on it. The Idempotency-Key became the entry id, which makes retries safe. The stream on disk is a normal stream: tenbri verify audit/support --key … checks it without the server.

Related: API reference · Streams and entries · Errors · Keys